diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..bb9dcf0a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb637a87..2f8d8cb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: - node-version: '24' - - uses: oven-sh/setup-bun@v2 + node-version: "24" + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.3.11 - name: Install dependencies diff --git a/.github/workflows/codex-update.yml b/.github/workflows/codex-update.yml index eba8d87f..9632f479 100644 --- a/.github/workflows/codex-update.yml +++ b/.github/workflows/codex-update.yml @@ -2,7 +2,7 @@ name: Codex Update on: schedule: - - cron: '0 5 * * *' + - cron: "0 5 * * *" workflow_dispatch: permissions: @@ -10,9 +10,9 @@ permissions: packages: read env: - CODEX_PACKAGE: '@openai/codex' - CODEX_UPDATE_LABEL: 'codex-update' - GITHUB_BOT_USERNAME: 'github-actions[bot]' + CODEX_PACKAGE: "@openai/codex" + CODEX_UPDATE_LABEL: "codex-update" + GITHUB_BOT_USERNAME: "github-actions[bot]" jobs: check: @@ -22,7 +22,7 @@ jobs: version: ${{ steps.check.outputs.version }} branch: ${{ steps.check.outputs.branch }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -58,13 +58,13 @@ jobs: VERSION: ${{ needs.check.outputs.version }} steps: - name: Generate GitHub token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 id: generate-token with: app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: token: ${{ steps.generate-token.outputs.token }} @@ -83,7 +83,7 @@ jobs: - name: Finalize update with Codex id: codex-finalize - uses: openai/codex-action@v1 + uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} codex-version: ${{ env.VERSION }} @@ -91,12 +91,13 @@ jobs: codex-args: >- -c sandbox_workspace_write.writable_roots=["${{ github.workspace }}/.git"] prompt: > - Finalize the update using codex-update-compat skill. - Commit the changes, the message should mention that types or/and tests after the update were fixed. + Finalize the update using codex-update-compat skill. + Commit only tracked file changes, and never create an empty commit. + If there are changes, the commit message should mention that types or/and tests after the update were fixed. When creating the final message do not mention: * Validation run details. * Commits. - * Hyperlinks. + * Hyperlinks. Mention only previously failed tests with failure reasons, also what you changed and why. - name: Push branch updates @@ -141,6 +142,6 @@ jobs: fi gh pr close "$pr_number" \ + --delete-branch \ --comment "Closing obsolete update PR" done - diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 237a0f76..3a4fa2c3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: '24' @@ -40,7 +40,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: '24' @@ -54,14 +54,14 @@ jobs: permissions: {} steps: - name: Generate GitHub token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 id: generate-token with: app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - name: Create Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 80d56b32..2aeeeb9c 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Generate GitHub token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 id: generate-token with: app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: token: ${{ steps.generate-token.outputs.token }} diff --git a/README.md b/README.md index 2af93036..e4345a35 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,20 @@ CODEX_PATH=/path/to/codex npx -y acp-extension-codex The adapter advertises ACP auth methods during initialization. Clients can authenticate with: -- ChatGPT login. -- OpenAI API key. +- ChatGPT login. Set `NO_BROWSER=1` to hide this method in remote or browserless environments. +- API key via `CODEX_API_KEY` or `OPENAI_API_KEY`. - A custom OpenAI-compatible gateway, when the client opts in to the gateway auth capability. ## Runtime options +- `CODEX_API_KEY` - API key used when the API-key auth method is selected. Takes precedence over `OPENAI_API_KEY`. +- `OPENAI_API_KEY` - fallback API key used when the API-key auth method is selected. - `CODEX_PATH` - run a specific Codex executable instead of the bundled package dependency. - `CODEX_CONFIG` - JSON object merged into the Codex session config. - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. - `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`. +- `NO_BROWSER` - hide browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. ## Development diff --git a/docs/superpowers/plans/2026-07-10-upstream-goal-fast-cleanup.md b/docs/superpowers/plans/2026-07-10-upstream-goal-fast-cleanup.md new file mode 100644 index 00000000..34872c6a --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-upstream-goal-fast-cleanup.md @@ -0,0 +1,11 @@ +# Upstream Goal/Fast Cleanup Implementation Plan + +1. Remove fork goal ACP extension constants, request types, parsers, route registration, and server/client forwarding wrappers. +2. Preserve upstream goal setters, runtime-effect tracking, session metadata, cancellation, and `/goal` orchestration. +3. Remove raw goal notifications and restore upstream goal event tests/snapshots. +4. Restore upstream no-argument `/goal` behavior and remove fork-only goal formatting/tests. +5. Delete the fork goal extension test suite and scan for remaining fork goal symbols. +6. Verify Plan Mode/proposed-plan code is unchanged. +7. Verify Fast Mode source and behavior match upstream and the ACP SDK dependency/lock resolution matches upstream. +8. Run targeted tests, typecheck, the complete test suite, npm build/package checks, and all CI Bun bundles. +9. Review the final diff and commit the implementation. diff --git a/docs/superpowers/specs/2026-07-10-upstream-goal-fast-cleanup-design.md b/docs/superpowers/specs/2026-07-10-upstream-goal-fast-cleanup-design.md new file mode 100644 index 00000000..717403ac --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-upstream-goal-fast-cleanup-design.md @@ -0,0 +1,96 @@ +# Upstream Goal/Fast Cleanup Design + +## Objective + +Remove the fork-specific goal implementation added by commit `20f61f9` now that upstream implements goal behavior. Keep the fork-only Plan Mode because upstream does not implement it. Keep Fast Mode as an upstream-owned feature and align the ACP SDK dependency with upstream. + +## Historical Boundary + +The fork-specific goal layer was introduced by `20f61f9` after the shared base `37a98b3`. It added ACP extension routes for `thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`; raw goal notifications; a custom no-argument `/goal` view; and corresponding tests. + +Fast Mode was introduced before the fork diverged by upstream commit `64d3bc2`. The fork has no independent Fast Mode implementation to remove. Later upstream commits `fbc8f84` and `ef5e169` own model-capability filtering and boolean config support. + +Upstream does not contain `PlanModeConfig.ts` or the fork's collaboration-mode plumbing, so Plan Mode remains fork-owned. + +## Selected Approach + +Use a symbol-level cleanup based on the historical diff rather than replacing entire files with upstream versions. This removes only the duplicated goal layer while preserving unrelated fork extensions and Plan Mode. + +Whole-file replacement was rejected because files such as `AcpExtensions.ts`, `CodexAcpServer.ts`, and `CodexEventHandler.ts` also contain retained fork behavior for usage, rate limits, proposed plans, packaging identity, and Plan Mode. + +Reverting all of `20f61f9` was rejected because it would remove retained features and create a substantially larger reconstruction diff. + +## Runtime Changes + +### Remove fork goal extensions + +- Remove the `THREAD_GOAL_GET_METHOD`, `THREAD_GOAL_SET_METHOD`, and `THREAD_GOAL_CLEAR_METHOD` ACP extension constants and request types. +- Remove their Zod request parsers and server route registration from `src/index.ts`. +- Restore extension-method dispatch to the upstream-supported authentication and legacy model methods. +- Remove fork-only goal parameter parsing and wrapper methods from `CodexAcpServer` and `CodexAcpClient`. +- Remove only `threadGoalGet` from `CodexAppServerClient`. Retain upstream's `threadGoalSet`, `threadGoalClear`, `runGoalSet`, and `runGoalClear` implementation. + +### Use upstream goal behavior exclusively + +- Restore the upstream `/goal` command behavior, including the no-argument usage message. +- Remove the fork-only goal summary formatting and direct goal lookup. +- Stop forwarding `thread/goal/updated` and `thread/goal/cleared` as raw ACP notifications. +- Retain upstream `session_info_update` goal metadata, duplicate suppression, continuation-turn routing, grace periods, and cancellation behavior. + +### Retain Plan Mode + +- Keep `src/PlanModeConfig.ts` and its tests. +- Keep `planModeEnabled` and `planModeExplicitlySet` session state. +- Keep the `collaborationMode` turn parameter and plan/default mode switching. +- Keep `_acp_ext:codex_proposed_plan` streaming/completion notifications. +- Keep `experimentalApi: true` and `requestAttestation: false` as the existing fork capability configuration required by the retained Plan Mode integration. + +### Retain unrelated fork extensions + +- Keep `_acp_ext:session_usage_update`. +- Keep `_acp_ext:session_rate_limits`. +- Keep authentication extensions, the legacy session-model extension, fork package identity, and fork binary names. + +## Fast Mode and SDK Alignment + +Fast Mode remains enabled and should behave exactly like upstream: + +- expose Fast Mode only for models with a fast speed tier; +- use boolean config options when the ACP client advertises support; +- otherwise use the upstream select option; +- retain the selected preference across model switches but apply the service tier only to supported models. + +`src/FastModeConfig.ts` must have no fork-only behavioral diff from `upstream/main`. Adjacent `CodexAcpServer` changes may differ only where Plan Mode is inserted. + +The dependency range for `@agentclientprotocol/sdk` must match `upstream/main` exactly (`^1.2.1` at design time). The resolved SDK package and transitive dependency graph in `package-lock.json` must follow upstream; only fork root metadata such as package name, version, and binary name may differ. + +## Tests and Fixtures + +- Delete `thread-goal-ext-methods.test.ts`. +- Restore upstream `thread-goal-events.test.ts` expectations and its three goal metadata snapshots. +- Remove the fork-only no-argument goal-view test and restore the upstream missing-input test. +- Keep upstream goal runtime, routing, cancellation, deduplication, and metadata coverage. +- Keep Plan Mode and proposed-plan tests unchanged except for mechanical fixture updates if required. +- Keep upstream Fast Mode tests unchanged. + +## Error Handling and Compatibility + +Requests to the removed fork-only goal extension routes will no longer be registered. Goal operations remain available through upstream's `/goal` command and app-server flow. + +No compatibility shim or deprecation response will be added because the requested outcome is complete removal of the local implementation. + +## Verification + +1. Confirm no fork goal constants, parsers, wrappers, raw notifications, or tests remain. +2. Compare Fast Mode source and behavior with `upstream/main`. +3. Compare the SDK dependency range and resolved package with `upstream/main`. +4. Run targeted goal, Plan Mode, proposed-plan, Fast Mode, and initialization tests. +5. Run `npm run typecheck` and `npm test`. +6. Run `npm run build`, `npm pack --dry-run`, and `npm run bundle:all` with the CI Bun version. + +## Success Criteria + +- Goal behavior is provided only by upstream code paths. +- Fast Mode and the ACP SDK follow upstream. +- Plan Mode and unrelated fork extensions remain functional. +- The repository passes the full verification workflow with a clean working tree after commit. diff --git a/package-lock.json b/package-lock.json index 9ef398b7..36c0ac73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,50 +9,50 @@ "version": "1.0.1", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.28.1", - "@openai/codex": "^0.142.0", - "diff": "^8.0.3", + "@agentclientprotocol/sdk": "^1.2.1", + "@openai/codex": "^0.144.0", + "diff": "^9.0.0", "open": "^11.0.0", - "vscode-jsonrpc": "^8.2.1", + "vscode-jsonrpc": "^9.0.1", "zod": "^4.0.0" }, "bin": { "acp-extension-codex": "dist/index.js" }, "devDependencies": { - "@types/node": "^24.10.1", + "@types/node": "^26.1.0", "esbuild": "^0.28.1", "mcp-hello-world": "^1.1.2", - "tsx": "^4.20.6", - "typescript": "^5.9.3", - "vitest": "^4.0.10" + "tsx": "^4.23.0", + "typescript": "^6.0.3", + "vitest": "^4.1.10" } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz", - "integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz", + "integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -61,9 +61,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -589,21 +589,21 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -613,10 +613,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", "engines": { @@ -637,6 +651,24 @@ "node": ">=6.6.0" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -714,9 +746,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -780,6 +812,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -838,29 +877,47 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -872,9 +929,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0.tgz", - "integrity": "sha512-c7WftbRyE4zOLJV5p73mcGn4jVK3FmK84Q65hrZrXboZzLPDWRfbFedCbsIjU4PJS/kvgyMPhv7dH4/nhXzUyg==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0.tgz", + "integrity": "sha512-QFh6f+v5QUx/Vg0HjIl9HB94p7aDLBDkZjc4IXX5RXUcXHPVCZNb6Hl2R49Og/fqW7orgZkeDcgWfRANUa1WoQ==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -883,19 +940,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.0-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.0-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.0-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.142.0-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.0-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.142.0-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.144.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.144.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.142.0-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-darwin-arm64.tgz", - "integrity": "sha512-jwbriCRTSNfqoGov5bNnnYAKarnNv4QfGkut8psKAajebL6VPI2ZjCxCJ1OFA5HhUQsN8GQgEjKlj6WhYcMmUA==", + "version": "0.144.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-arm64.tgz", + "integrity": "sha512-rqFAJdOa2I0VRgepVsSZeLxs96+Y+LXTjccOOvH6894FyaFAYPZ/o+6hgpB1iGHxxdoY/DsGa8jrJC8Leqn9Kg==", "cpu": [ "arm64" ], @@ -910,9 +967,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.142.0-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-darwin-x64.tgz", - "integrity": "sha512-qwdPfW8sOBEfWw1Rt7baveUsOjrudrM7qfKNnJvHPRrwtt4jemTd22VtZ6r02kUQdLwt6Ddvs0Pwzk6QJW6PqA==", + "version": "0.144.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-x64.tgz", + "integrity": "sha512-4p2jxRbN+Khg5UQzpkzT9upFj+qkEF/abmdvrtflkkWmVKP6Nt+yi8ospdqv9PDqvQ9SotPvX7iXaFaeUTrtmA==", "cpu": [ "x64" ], @@ -927,9 +984,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.142.0-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-linux-arm64.tgz", - "integrity": "sha512-gX9hCK59bE0Itnous1MCrKiXTYuoGVE6oJUE0kyRSzaBD267sh9TNR3DXKbY7TsP7iAs19n8YXDJNdHZJtakSQ==", + "version": "0.144.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-arm64.tgz", + "integrity": "sha512-k++xhZrn9P3laO00Q92APG6mdOFDD66nUBo+8ExCa1NXi2pjLEMLC4+UNJTUUtUT1PEflOZ5pDKxPXgzaiFFFg==", "cpu": [ "arm64" ], @@ -944,9 +1001,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.142.0-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-linux-x64.tgz", - "integrity": "sha512-nywS+ogFPRhZnQnKL+jzWKzFhmjTQbYPh6n8dtQ/E+sJ3FoZVcb9a6kvHL5yjgtlCiDkP0jPDYY5AK3tDShYyw==", + "version": "0.144.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-x64.tgz", + "integrity": "sha512-GmKtQeX+cO9lN7mQD1FEVcXYEMLMgMByHwZdvlluH0bj/+c2ind3hwbRtE3eECFDekNhEiB80Ez0FfbkyFQqoA==", "cpu": [ "x64" ], @@ -961,9 +1018,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.142.0-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-win32-arm64.tgz", - "integrity": "sha512-Lz8xEn7uNn0XTi8k0jApKM5P9BD7QvAH3ZbtlGi1zfSOgh1kmgpfXxaTlJF503mozdHaA2r6UA7hfTi+bI0CvQ==", + "version": "0.144.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-arm64.tgz", + "integrity": "sha512-e2yGSgwdzrT1SoJMoOzWD58WBEsIaAMZpEchuV2VGkE2T955SG7dn7EyVQTQcy7/rdpE8aEDktZ/1eQQfjkdtQ==", "cpu": [ "arm64" ], @@ -978,9 +1035,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.142.0-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-win32-x64.tgz", - "integrity": "sha512-LkARkXh3NM0XA8R8hFAgjx017fadfb9RgmqLzdhO1Qt/bDVSJnmDf4cwJ2h+FWZcm9/rRk3JC/IuqBvwg/TN+Q==", + "version": "0.144.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-x64.tgz", + "integrity": "sha512-QiholLCYqNeYvNM77HOmPtrOFrY0rQc/N9nXt+sQGXO3rEGmcWjpLzujY4Oegl3CLRHoieWqlep3EqEvFBjoIA==", "cpu": [ "x64" ], @@ -994,9 +1051,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1004,9 +1061,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1021,9 +1078,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1038,9 +1095,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1055,9 +1112,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1072,9 +1129,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1089,9 +1146,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -1106,9 +1163,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1123,9 +1180,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1140,9 +1197,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1157,9 +1214,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1174,9 +1231,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1191,9 +1248,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1208,9 +1265,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -1218,18 +1275,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -1244,9 +1301,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1275,9 +1332,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1311,54 +1368,81 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~8.3.0" } }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1366,14 +1450,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1382,9 +1466,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1392,15 +1476,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1473,9 +1557,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "license": "MIT", "dependencies": { @@ -1497,23 +1581,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -1677,27 +1744,19 @@ } }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "ms": "2.0.0" } }, "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -1766,9 +1825,9 @@ } }, "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -1827,16 +1886,16 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -1929,9 +1988,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "dev": true, "license": "MIT", "engines": { @@ -1939,9 +1998,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2014,23 +2073,6 @@ "express": ">= 4.11" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2039,9 +2081,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -2092,23 +2134,6 @@ "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2220,9 +2245,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -2233,9 +2258,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "dev": true, "license": "MIT", "engines": { @@ -2356,9 +2381,9 @@ "license": "MIT" }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -2378,9 +2403,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "dev": true, "license": "MIT", "funding": { @@ -2778,16 +2803,16 @@ } }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -2837,9 +2862,9 @@ } }, "node_modules/obug": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", - "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2935,9 +2960,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2958,9 +2983,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -3013,13 +3038,14 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -3055,9 +3081,9 @@ } }, "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3072,9 +3098,9 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "dev": true, "license": "MIT", "peer": true, @@ -3083,9 +3109,9 @@ } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "dev": true, "license": "MIT", "peer": true, @@ -3093,7 +3119,7 @@ "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.7" } }, "node_modules/require-from-string": { @@ -3107,13 +3133,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3123,21 +3149,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/router": { @@ -3157,6 +3183,31 @@ "node": ">= 18" } }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/router/node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -3241,20 +3292,10 @@ "node": ">= 0.8.0" } }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, @@ -3305,15 +3346,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -3325,14 +3366,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -3415,9 +3456,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -3484,9 +3525,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "dev": true, "license": "MIT", "dependencies": { @@ -3517,9 +3558,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3531,9 +3572,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -3567,197 +3608,178 @@ "node": ">= 0.8" } }, - "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", - "why-is-node-running": "^2.3.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" }, "bin": { - "vitest": "vitest.mjs" + "vite": "bin/vite.js" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { + "@types/node": { "optional": true }, - "@opentelemetry/api": { + "@vitejs/devtools": { "optional": true }, - "@types/node": { + "esbuild": { "optional": true }, - "@vitest/browser-playwright": { + "jiti": { "optional": true }, - "@vitest/browser-preview": { + "less": { "optional": true }, - "@vitest/browser-webdriverio": { + "sass": { "optional": true }, - "@vitest/ui": { + "sass-embedded": { "optional": true }, - "happy-dom": { + "stylus": { "optional": true }, - "jsdom": { + "sugarss": { "optional": true }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.0", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { + "terser": { "optional": true }, - "vite": { + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, - "node_modules/vitest/node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", - "tinyglobby": "^0.2.17" + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite": "bin/vite.js" + "vitest": "vitest.mjs" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "@types/node": { + "@edge-runtime/vm": { "optional": true }, - "@vitejs/devtools": { + "@opentelemetry/api": { "optional": true }, - "esbuild": { + "@types/node": { "optional": true }, - "jiti": { + "@vitest/browser-playwright": { "optional": true }, - "less": { + "@vitest/browser-preview": { "optional": true }, - "sass": { + "@vitest/browser-webdriverio": { "optional": true }, - "sass-embedded": { + "@vitest/coverage-istanbul": { "optional": true }, - "stylus": { + "@vitest/coverage-v8": { "optional": true }, - "sugarss": { + "@vitest/ui": { "optional": true }, - "terser": { + "happy-dom": { "optional": true }, - "tsx": { + "jsdom": { "optional": true }, - "yaml": { - "optional": true + "vite": { + "optional": false } } }, "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", + "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -3804,9 +3826,9 @@ "license": "ISC" }, "node_modules/wsl-utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz", - "integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", "license": "MIT", "dependencies": { "is-wsl": "^3.1.0", @@ -3829,13 +3851,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "dev": true, "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } } diff --git a/package.json b/package.json index 8d2ea191..2c25cc17 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.0.1", + "version": "1.1.2", "description": "An ACP-compatible coding agent powered by Codex", "main": "dist/index.js", "bin": { @@ -18,18 +18,20 @@ "scripts": { "prepublishOnly": "npm run build", "build": "node build.mjs", - "bundle:all": "npm run bundle:linux-x64 && npm run bundle:linux-arm64 && npm run bundle:darwin-x64 && npm run bundle:darwin-arm64 && npm run bundle:win-x64", + "bundle:all": "npm run bundle:linux-x64 && npm run bundle:linux-arm64 && npm run bundle:darwin-x64 && npm run bundle:darwin-arm64 && npm run bundle:win-x64 && npm run bundle:win-arm64", "bundle:linux-x64": "bun build src/index.ts --minify --sourcemap --compile --target=bun-linux-x64-baseline --outfile dist/bin/acp-extension-codex-x64-linux", "bundle:linux-arm64": "bun build src/index.ts --minify --sourcemap --compile --target=bun-linux-arm64 --outfile dist/bin/acp-extension-codex-arm64-linux", "bundle:darwin-x64": "bun build src/index.ts --minify --sourcemap --compile --target=bun-darwin-x64-baseline --outfile dist/bin/acp-extension-codex-x64-darwin", "bundle:darwin-arm64": "bun build src/index.ts --minify --sourcemap --compile --target=bun-darwin-arm64 --outfile dist/bin/acp-extension-codex-arm64-darwin", "bundle:win-x64": "bun build src/index.ts --minify --sourcemap --compile --target=bun-windows-x64-baseline --outfile dist/bin/acp-extension-codex-x64-windows.exe", - "package:all": "npm run package:linux-x64 && npm run package:linux-arm64 && npm run package:darwin-x64 && npm run package:darwin-arm64 && npm run package:win-x64", + "bundle:win-arm64": "bun build src/index.ts --minify --sourcemap --compile --target=bun-windows-arm64 --outfile dist/bin/acp-extension-codex-arm64-windows.exe", + "package:all": "npm run package:linux-x64 && npm run package:linux-arm64 && npm run package:darwin-x64 && npm run package:darwin-arm64 && npm run package:win-x64 && npm run package:win-arm64", "package:linux-x64": "cd dist/bin && zip acp-extension-codex-x64-linux.zip acp-extension-codex-x64-linux", "package:linux-arm64": "cd dist/bin && zip acp-extension-codex-arm64-linux.zip acp-extension-codex-arm64-linux", "package:darwin-x64": "cd dist/bin && zip acp-extension-codex-x64-darwin.zip acp-extension-codex-x64-darwin", "package:darwin-arm64": "cd dist/bin && zip acp-extension-codex-arm64-darwin.zip acp-extension-codex-arm64-darwin", "package:win-x64": "cd dist/bin && zip acp-extension-codex-x64-windows.zip acp-extension-codex-x64-windows.exe", + "package:win-arm64": "cd dist/bin && zip acp-extension-codex-arm64-windows.zip acp-extension-codex-arm64-windows.exe", "start": "node --import tsx src/index.ts", "generate-types": "./node_modules/.bin/codex app-server generate-ts --out src/app-server", "test": "vitest run", @@ -58,19 +60,19 @@ "license": "Apache-2.0", "type": "module", "devDependencies": { - "@types/node": "^24.10.1", + "@types/node": "^26.1.0", "esbuild": "^0.28.1", "mcp-hello-world": "^1.1.2", - "tsx": "^4.20.6", - "typescript": "^5.9.3", - "vitest": "^4.0.10" + "tsx": "^4.23.0", + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.28.1", - "@openai/codex": "^0.142.0", - "diff": "^8.0.3", + "@agentclientprotocol/sdk": "^1.2.1", + "@openai/codex": "^0.144.0", + "diff": "^9.0.0", "open": "^11.0.0", - "vscode-jsonrpc": "^8.2.1", + "vscode-jsonrpc": "^9.0.1", "zod": "^4.0.0" } -} +} \ No newline at end of file diff --git a/readme-dev.md b/readme-dev.md index bd2e4c26..41009c7b 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -1,6 +1,18 @@ This package uses the bundled `@openai/codex` dependency by default. Set `CODEX_PATH` to run a different Codex binary; versions other than the one specified in `package.json` may not be compatible. +### Runtime environment + +- `CODEX_API_KEY` - API key used when the API-key auth method is selected. Takes precedence over `OPENAI_API_KEY`. +- `OPENAI_API_KEY` - fallback API key used when the API-key auth method is selected. +- `CODEX_PATH` - run a specific Codex executable instead of the bundled package dependency. +- `CODEX_CONFIG` - JSON object merged into the Codex session config. +- `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. +- `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. +- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`. +- `NO_BROWSER` - hide browser-based ChatGPT auth when set. +- `APP_SERVER_LOGS` - directory for adapter logs. + ### Quick start #### Develop on Windows? @@ -31,10 +43,10 @@ Run from sources Run from binaries -1. Download an `acp-extension-codex-.zip` archive from https://github.com/Leeeon233/acp-extension-codex/releases +1. Download an `acp-extension-codex--.zip` archive from https://github.com/Leeeon233/acp-extension-codex/releases (`` is `x64` or `arm64`; `` is `linux`, `darwin`, or `windows`). 2. Unzip the archive: ```bash - unzip acp-extension-codex-.zip + unzip acp-extension-codex--.zip ``` 3. Adjust ACP client config @@ -69,6 +81,6 @@ npm run package:all ### Update supported Codex version -1. Update Codex dependency: `package.json` +1. Update the `@openai/codex` version in `package.json` (under `dependencies`). 2. Regenerate Codex types in `src/app-server/`: `npm run generate-types` 3. Ensure there are no type errors or failed tests: `npm run typecheck` and `npm run test` diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index e6838217..411f5807 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -5,16 +5,8 @@ import type { ResumeSessionResponse, SessionId, } from "@agentclientprotocol/sdk"; -import type { - ThreadGoalClearParams, - ThreadGoalGetParams, - ThreadGoalSetParams, -} from "./app-server/v2"; export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; -export const THREAD_GOAL_GET_METHOD = "thread/goal/get"; -export const THREAD_GOAL_SET_METHOD = "thread/goal/set"; -export const THREAD_GOAL_CLEAR_METHOD = "thread/goal/clear"; export const ACP_EXT_SESSION_USAGE_UPDATE_METHOD = "_acp_ext:session_usage_update"; export const ACP_EXT_SESSION_RATE_LIMITS_METHOD = "_acp_ext:session_rate_limits"; export const ACP_EXT_CODEX_PROPOSED_PLAN_METHOD = "_acp_ext:codex_proposed_plan"; @@ -82,28 +74,11 @@ export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest | LegacySetSessionModelExtRequest - | ThreadGoalGetExtRequest - | ThreadGoalSetExtRequest - | ThreadGoalClearExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { - return resolveExtMethod(request.method) !== null; -} - -export type ExtMethodName = ExtMethodRequest["method"]; - -export function resolveExtMethod(method: string): ExtMethodName | null { - switch (method) { - case "authentication/status": - case "authentication/logout": - case LEGACY_SET_SESSION_MODEL_METHOD: - case THREAD_GOAL_GET_METHOD: - case THREAD_GOAL_SET_METHOD: - case THREAD_GOAL_CLEAR_METHOD: - return method; - default: - return null; - } + return request.method === "authentication/status" + || request.method === "authentication/logout" + || request.method === LEGACY_SET_SESSION_MODEL_METHOD; } export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } @@ -117,21 +92,6 @@ export type LegacySetSessionModelExtRequest = { params: LegacySetSessionModelRequest; } -export type ThreadGoalGetExtRequest = { - method: typeof THREAD_GOAL_GET_METHOD; - params: ThreadGoalGetParams; -} - -export type ThreadGoalSetExtRequest = { - method: typeof THREAD_GOAL_SET_METHOD; - params: ThreadGoalSetParams; -} - -export type ThreadGoalClearExtRequest = { - method: typeof THREAD_GOAL_CLEAR_METHOD; - params: ThreadGoalClearParams; -} - export async function legacySetSessionModel( connection: Pick, params: LegacySetSessionModelRequest, diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 5577b1e6..e884df56 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,4 +1,4 @@ -import {isCodexAuthRequest} from "./CodexAuthMethod"; +import {CODEX_API_KEY_ENV_VAR, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod"; import type {EmbeddedResourceResource} from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk"; import {type McpServer, RequestError} from "@agentclientprotocol/sdk"; @@ -33,13 +33,8 @@ import type { SkillsListParams, SkillsListResponse, SandboxPolicy, - ThreadGoalClearParams, - ThreadGoalClearResponse, - ThreadGoalGetParams, - ThreadGoalGetResponse, - ThreadGoalSetParams, - ThreadGoalSetResponse, Thread, + ThreadGoalStatus, ThreadSourceKind, TurnCompletedNotification, TurnStartParams, @@ -95,17 +90,15 @@ export class CodexAcpClient { switch (authRequest.methodId) { case "api-key": { - if (!authRequest._meta || !authRequest._meta["api-key"]) throw RequestError.invalidRequest(); - const loginCompletedPromise = this.awaitNextLoginCompleted(); - await this.codexClient.accountLogin({ - type: "apiKey", - apiKey: authRequest._meta["api-key"].apiKey - }); - this.gatewayConfig = null; - const result = await loginCompletedPromise; - return result.success; + const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv(); + return await this.authenticateWithApiKey(apiKey); } case "chat-gpt": { + const accountResponse = await this.codexClient.accountRead({refreshToken: true}); + if (accountResponse.account?.type === "chatgpt") { + this.gatewayConfig = null; + return true; + } const loginCompletedPromise = this.awaitNextLoginCompleted(); const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"}); if (loginResponse.type == "chatgpt") { @@ -118,7 +111,7 @@ export class CodexAcpClient { case "gateway": if (!authRequest._meta) throw RequestError.invalidRequest(); - const gatewaySettings = authRequest._meta["gateway"] + const gatewaySettings = authRequest._meta["gateway"]; if (!gatewaySettings) throw RequestError.invalidRequest(); const baseUrl = gatewaySettings.baseUrl; @@ -138,7 +131,7 @@ export class CodexAcpClient { http_headers: headers, wire_api: "responses" } - } + }; // Early return: model provider information will be sent to Codex later during the session creation return true; @@ -150,6 +143,30 @@ export class CodexAcpClient { return false; } + private async authenticateWithApiKey(apiKey: string): Promise { + const loginCompletedPromise = this.awaitNextLoginCompleted(); + await this.codexClient.accountLogin({ + type: "apiKey", + apiKey, + }); + this.gatewayConfig = null; + const result = await loginCompletedPromise; + return result.success; + } + + private readApiKeyFromEnv(): string { + for (const envVar of [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]) { + const value = process.env[envVar]?.trim(); + if (value) { + return value; + } + } + throw RequestError.internalError( + {envVars: [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]}, + `${CODEX_API_KEY_ENV_VAR} or ${OPENAI_API_KEY_ENV_VAR} is not set` + ); + } + async getAuthenticationStatus(): Promise { const modelProvider = await this.getCurrentModelProvider(); @@ -189,7 +206,7 @@ export class CodexAcpClient { return sessionModelProvider; } const settingsModelProvider = await this.codexClient.configRead({includeLayers: false}); - return settingsModelProvider.config.model_provider ?? null; + return settingsModelProvider?.config?.model_provider ?? null; } async logout(): Promise { @@ -211,20 +228,12 @@ export class CodexAcpClient { return response.requiresOpenaiAuth && !response.account; } - async getAccount(): Promise { - return this.codexClient.accountRead({refreshToken: false}); - } - - async getThreadGoal(params: ThreadGoalGetParams): Promise { - return await this.codexClient.threadGoalGet(params); - } - - async setThreadGoal(params: ThreadGoalSetParams): Promise { - return await this.codexClient.threadGoalSet(params); + hasGatewayAuth(): boolean { + return this.gatewayConfig !== null; } - async clearThreadGoal(params: ThreadGoalClearParams): Promise { - return await this.codexClient.threadGoalClear(params); + async getAccount(): Promise { + return this.codexClient.accountRead({refreshToken: false}); } async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise { @@ -234,7 +243,7 @@ export class CodexAcpClient { const response = await this.codexClient.threadResume({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, - modelProvider: this.getResumeModelProvider(), + modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, }); onSubscribed?.(); @@ -244,6 +253,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, } @@ -256,7 +266,7 @@ export class CodexAcpClient { const response = await this.codexClient.threadResume({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, - modelProvider: this.getResumeModelProvider(), + modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, }); onSubscribed?.(); @@ -270,6 +280,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, thread: historyResponse.thread, additionalDirectories, @@ -295,6 +306,7 @@ export class CodexAcpClient { sessionId: response.thread.id, currentModelId: currentModelId, models: codexModels, + modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, }; @@ -315,7 +327,7 @@ export class CodexAcpClient { async runReview( sessionId: string, target: ReviewTarget, - onTurnStarted?: (turnId: string) => void, + onTurnStarted?: (turnId: string, threadId: string) => void, ): Promise { return await this.codexClient.runReview({ threadId: sessionId, @@ -328,6 +340,39 @@ export class CodexAcpClient { await this.codexClient.runCompact({threadId: sessionId}); } + async setGoal( + sessionId: string, + objective: string, + onTurnStarted?: (turnId: string) => void, + ): Promise { + return await this.codexClient.runGoalSet({ + threadId: sessionId, + objective, + status: "active", + }, onTurnStarted); + } + + async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { + await this.codexClient.runGoalSet({ + threadId: sessionId, + status, + }); + } + + async resumeGoal( + sessionId: string, + onTurnStarted?: (turnId: string) => void, + ): Promise { + return await this.codexClient.runGoalSet({ + threadId: sessionId, + status: "active", + }, onTurnStarted); + } + + async clearGoal(sessionId: string): Promise { + await this.codexClient.runGoalClear({threadId: sessionId}); + } + async awaitMcpServerStartup(serverNames: Array, afterVersion: number): Promise { return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion); } @@ -386,10 +431,10 @@ export class CodexAcpClient { return this.gatewayConfig?.modelProvider ?? this.modelProvider; } - private getResumeModelProvider(): string { - // Passing `null` forces codex to use the persisted provider for resumed session instead of default one - // Explicit fallback to "openai" fixes error `Model provider not found` at least for ChatGPT authentication - return this.getModelProvider() ?? "openai"; + private async getResumeModelProvider(): Promise { + // Prefer an explicit/gateway provider, then the provider persisted in Codex config. + // Keep OpenAI as the final fallback for ChatGPT-authenticated sessions without a configured provider. + return (await this.getCurrentModelProvider()) ?? "openai"; } private async refreshSkills( @@ -440,15 +485,30 @@ export class CodexAcpClient { * Falls back to model defaults if parameters are missing or unsupported. */ createModelId(availableModels: Model[], modelId: string | null, reasoningEffort: ReasoningEffort | null): ModelId { - const selectedModel = - availableModels.find(m => m.id === modelId) ?? - availableModels.find(m => m.isDefault); - - if (!selectedModel) { + const selectedModel = availableModels.find(m => m.id === modelId); + if (selectedModel) { + return ModelId.create(selectedModel.id, reasoningEffort ?? selectedModel.defaultReasoningEffort); + } + + // The configured model is not in Codex's advertised catalog. This is + // expected for custom providers (e.g. a self-hosted or third-party + // model), whose model ids the catalog does not enumerate. Keep the + // requested model id instead of silently substituting the built-in + // default. This mirrors the Codex CLI, which keeps the configured model + // and merely warns "Model metadata not found. Defaulting to fallback + // metadata." Substituting the default here pins a wrong model id onto + // every turn and makes requests to custom-provider endpoints fail with + // "unknown model". + if (modelId) { + return ModelId.create(modelId, reasoningEffort ?? "medium"); + } + + const defaultModel = availableModels.find(m => m.isDefault); + if (!defaultModel) { throw new Error(`Model selection failed: No model found for ID "${modelId}" and no default model is defined.`); } - return ModelId.create(selectedModel.id, reasoningEffort ?? selectedModel.defaultReasoningEffort); + return ModelId.create(defaultModel.id, reasoningEffort ?? defaultModel.defaultReasoningEffort); } async subscribeToSessionEvents( @@ -608,11 +668,6 @@ export class CodexAcpClient { "vscode", "exec", "appServer", - "subAgent", - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "subAgentOther", "unknown", ]; const requestedCwd = request.cwd?.trim() ?? null; @@ -633,26 +688,23 @@ export class CodexAcpClient { sourceKinds: sourceKinds, }); + const mapThreadToSession = (thread: Thread) => ({ + sessionId: thread.id, + cwd: thread.cwd, + title: (thread.name ?? thread.preview) || null, + updatedAt: new Date(thread.updatedAt * 1000).toISOString(), + }); + if (listResponse.data.length === 0) { const diagnostics = await this.runSessionListDiagnostics(); logger.log("Session list diagnostics", diagnostics); } - let sessions = listResponse.data.map((thread) => ({ - sessionId: thread.id, - cwd: thread.cwd, - title: (thread.name ?? thread.preview) || null, - updatedAt: new Date(thread.updatedAt * 1000).toISOString(), - })); + let sessions = listResponse.data.map(mapThreadToSession); if (requestedCwd) { const filtered = listResponse.data .filter(filterByCwd) - .map((thread) => ({ - sessionId: thread.id, - cwd: thread.cwd, - title: (thread.name ?? thread.preview) || null, - updatedAt: new Date(thread.updatedAt * 1000).toISOString(), - })); + .map(mapThreadToSession); if (filtered.length > 0 || path.isAbsolute(requestedCwd)) { sessions = filtered; } else { @@ -717,6 +769,7 @@ export type SessionMetadata = { sessionId: string, currentModelId: string, models: Model[], + modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: string[], } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index ca47e3ce..06475fc7 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -3,25 +3,19 @@ import {RequestError, type SessionId, type SessionModeState} from "@agentclientp import {CodexEventHandler} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; -import {type CodexAuthRequest, getCodexAuthMethods} from "./CodexAuthMethod"; +import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; import type {McpStartupResult} from "./CodexAppServerClient"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type {CollaborationMode, InputModality, ReasoningEffort} from "./app-server"; import type { Account, - CollabAgentToolCallStatus, Model, ReasoningEffortOption, - ThreadGoalClearParams, - ThreadGoalClearResponse, - ThreadGoalGetParams, - ThreadGoalGetResponse, - ThreadGoalSetParams, - ThreadGoalSetResponse, ThreadGoalStatus, Thread, ThreadItem, + TurnCompletedNotification, UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; @@ -48,13 +42,11 @@ import { type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, - THREAD_GOAL_CLEAR_METHOD, - THREAD_GOAL_GET_METHOD, - THREAD_GOAL_SET_METHOD, - resolveExtMethod, } from "./AcpExtensions"; import { + createCollabAgentToolCallUpdate, createCommandExecutionCompleteUpdate, createCommandExecutionUpdate, createDynamicToolCallUpdate, @@ -65,6 +57,7 @@ import { formatWebSearchTitle, } from "./CodexToolCallMapper"; import { + clientSupportsBooleanConfigOptions, createFastModeConfigOption, FAST_MODE_CONFIG_ID, FAST_MODE_OFF, @@ -81,6 +74,18 @@ import { import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode"; +import { + createCodexMessagePhaseMeta, + createAgentTextMessageChunk, + createAgentTextThoughtChunk, + createUserMessageChunk, +} from "./ContentChunks"; + +export interface ThreadGoalSnapshot { + objective: string; + status: ThreadGoalStatus; + tokenBudget: number | null; +} export interface SessionState { sessionId: string, @@ -95,6 +100,8 @@ export interface SessionState { modelContextWindow: number | null; rateLimits: RateLimitsMap | null; account: Account | null; + authConfigured: boolean; + authProvider: string | null; cwd: string; additionalDirectories: string[]; fastModeEnabled: boolean; @@ -103,6 +110,12 @@ export interface SessionState { planModeExplicitlySet: boolean; sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; + currentGoal?: ThreadGoalSnapshot | null; +} + +interface ActiveAuthState { + account: Account | null; + authConfigured: boolean; } interface PendingMcpStartupSession { @@ -118,19 +131,14 @@ interface PendingTurnStart { interface ActivePrompt { completion: Promise; closeSignal: Promise; + cancelSignal: Promise; + signal: AbortSignal; + currentTurn: { threadId: string, turnId: string } | null; + requestCancel: () => void; requestClose: () => void; complete: () => void; } -const THREAD_GOAL_STATUSES: ReadonlySet = new Set([ - "active", - "paused", - "blocked", - "usageLimited", - "budgetLimited", - "complete", -]); - export class CodexAcpServer { private static readonly MODEL_NAME_TOKEN_OVERRIDES: Record = { gpt: "GPT", @@ -145,7 +153,9 @@ export class CodexAcpServer { private readonly getRecentStderr: () => string; private readonly availableCommands: CodexCommands; private clientInfo: acp.Implementation | null; + private clientCapabilities: acp.ClientCapabilities | null; private terminalOutputMode: TerminalOutputMode; + private booleanConfigOptionsSupported: boolean; private readonly sessions: Map; private readonly pendingMcpStartupSessions: Map; @@ -175,11 +185,14 @@ export class CodexAcpServer { this.getExitCode = getExitCode ?? (() => null); this.getRecentStderr = getRecentStderr ?? (() => ""); this.clientInfo = null; + this.clientCapabilities = null; this.terminalOutputMode = "terminal_output_delta"; + this.booleanConfigOptionsSupported = false; this.availableCommands = new CodexCommands( connection, codexAcpClient, - (operation) => this.runWithProcessCheck(operation) + (operation) => this.runWithProcessCheck(operation), + () => this.refreshSessionsAuthState(null) ); } @@ -188,7 +201,9 @@ export class CodexAcpServer { ): Promise { logger.log("Initialize request received"); this.clientInfo = _params.clientInfo ?? null; + this.clientCapabilities = _params.clientCapabilities ?? null; this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities); + this.booleanConfigOptionsSupported = clientSupportsBooleanConfigOptions(_params.clientCapabilities); await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params)); return { protocolVersion: acp.PROTOCOL_VERSION, @@ -224,11 +239,11 @@ export class CodexAcpServer { } async extMethod(method: string, params: Record): Promise> { - const extMethod = resolveExtMethod(method); - if (!extMethod) { + const methodRequest = { method: method, params: params }; + if (!isExtMethodRequest(methodRequest)) { return {}; } - switch (extMethod) { + switch (methodRequest.method) { case "authentication/status": return await this.runWithProcessCheck(() => this.codexAcpClient.getAuthenticationStatus()); case "authentication/logout": { @@ -236,13 +251,7 @@ export class CodexAcpServer { return {}; } case LEGACY_SET_SESSION_MODEL_METHOD: - return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(params)); - case THREAD_GOAL_GET_METHOD: - return await this.getThreadGoal(this.parseThreadGoalGetParams(params)); - case THREAD_GOAL_SET_METHOD: - return await this.setThreadGoal(this.parseThreadGoalSetParams(params)); - case THREAD_GOAL_CLEAR_METHOD: - return await this.clearThreadGoal(this.parseThreadGoalClearParams(params)); + return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); } } @@ -276,6 +285,7 @@ export class CodexAcpServer { async handleError(e: Error){ if (e.message.includes("log out") || e.message.includes("cloud requirements")) { await this.runWithProcessCheck(() => this.codexAcpClient.logout()); + await this.refreshSessionsAuthState(null); throw RequestError.internalError(`${(e.message)}\n\nYou have been logged out. Please try again.`); } } @@ -356,7 +366,7 @@ export class CodexAcpServer { let sessionMetadata: SessionMetadata; let resumeSubscribed = false; if ("sessionId" in request) { - logger.log(`Resume existing session: ${request.sessionId}...`) + logger.log(`Resume existing session: ${request.sessionId}...`); try { sessionMetadata = await this.runWithProcessCheck(() => this.codexAcpClient.resumeSession(request, () => { @@ -370,14 +380,15 @@ export class CodexAcpServer { throw err; } } else { - logger.log(`Create new session...`) + logger.log(`Create new session...`); sessionMetadata = await this.runWithProcessCheck(() => this.codexAcpClient.newSession(request)); } const {sessionId, currentModelId, models} = sessionMetadata; - let account: Account | null; + const authProvider = sessionMetadata.modelProvider ?? this.codexAcpClient.getModelProvider(); + let authState: ActiveAuthState; try { - account = await this.getActiveAccount(); + authState = await this.getAuthStateForProvider(authProvider); } catch (err) { if (resumeSubscribed && requestedSessionGeneration !== null) { await this.cleanupStaleSessionOpen(sessionId, requestedSessionGeneration); @@ -404,7 +415,9 @@ export class CodexAcpServer { totalTokenUsage: null, modelContextWindow: null, rateLimits: null, - account: account, + account: authState.account, + authConfigured: authState.authConfigured, + authProvider: authProvider, cwd: request.cwd, additionalDirectories: sessionMetadata.additionalDirectories, fastModeEnabled: sessionMetadata.currentServiceTier === "fast", @@ -413,7 +426,7 @@ export class CodexAcpServer { planModeExplicitlySet: false, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, - } + }; this.sessions.set(sessionId, sessionState); resumeSubscribed = false; @@ -425,19 +438,43 @@ export class CodexAcpServer { this.publishMcpStartupStatusAsync(sessionId); } - this.publishAvailableCommandsAsync(sessionId); + this.publishAvailableCommandsAsync(sessionState); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); return [sessionId, sessionModelState, sessionModeState]; } - private async getActiveAccount(){ - if (this.codexAcpClient.getModelProvider()) { - return null + private async getAuthStateForProvider(authProvider: string | null): Promise { + if (!this.authProviderUsesOpenAiAccount(authProvider)) { + return { + account: null, + authConfigured: true, + }; } const accountResponse = await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); - return accountResponse.account; + return { + account: accountResponse.account, + authConfigured: accountResponse.account !== null || !accountResponse.requiresOpenaiAuth, + }; + } + + private authProviderUsesOpenAiAccount(authProvider: string | null): boolean { + return authProvider === null || authProvider === "openai"; + } + + private authProvidersMatch(a: string | null, b: string | null): boolean { + if (this.authProviderUsesOpenAiAccount(a) && this.authProviderUsesOpenAiAccount(b)) { + return true; + } + return a === b; + } + + private getAuthProviderForAuthenticateRequest(request: acp.AuthenticateRequest): string | null { + if (isCodexAuthRequest(request) && request.methodId === "gateway") { + return "custom-gateway"; + } + return null; } async loadSession(params: acp.LoadSessionRequest): Promise { @@ -596,6 +633,7 @@ export class CodexAcpServer { logger.log("Authenticate request failed"); throw RequestError.invalidParams(); } + await this.refreshSessionsAuthState(this.getAuthProviderForAuthenticateRequest(_params)); logger.log("Authenticate request completed"); return { }; } @@ -603,9 +641,24 @@ export class CodexAcpServer { async logout(_params: acp.LogoutRequest): Promise { logger.log("Logout request received"); await this.runWithProcessCheck(() => this.codexAcpClient.logout()); + await this.refreshSessionsAuthState(null); logger.log("Logout request completed"); } + private async refreshSessionsAuthState(authProvider: string | null): Promise { + if (this.sessions.size === 0) return; + + const sessionsToRefresh = [...this.sessions.values()] + .filter(sessionState => this.authProvidersMatch(sessionState.authProvider, authProvider)); + if (sessionsToRefresh.length === 0) return; + + const authState = await this.getAuthStateForProvider(authProvider); + for (const sessionState of sessionsToRefresh) { + sessionState.account = authState.account; + sessionState.authConfigured = authState.authConfigured; + } + } + async setSessionMode( _params: acp.SetSessionModeRequest, ): Promise { @@ -628,26 +681,21 @@ export class CodexAcpServer { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); - if (typeof params.value !== "string") { - throw RequestError.invalidParams(); - } - const value = params.value; - switch (params.configId) { case FAST_MODE_CONFIG_ID: - this.applyFastModeChange(sessionState, value); + this.applyFastModeChange(sessionState, params); break; case PLAN_MODE_CONFIG_ID: - this.applyPlanModeChange(sessionState, value); + this.applyPlanModeChange(sessionState, this.stringConfigValue(params)); break; case MODE_CONFIG_ID: - this.applyModeChange(sessionState, value); + this.applyModeChange(sessionState, this.stringConfigValue(params)); break; case MODEL_CONFIG_ID: - this.applyModelChange(sessionState, value); + this.applyModelChange(sessionState, this.stringConfigValue(params)); break; case REASONING_EFFORT_CONFIG_ID: - this.applyReasoningEffortChange(sessionState, value); + this.applyReasoningEffortChange(sessionState, this.stringConfigValue(params)); break; default: throw RequestError.invalidParams(); @@ -658,7 +706,12 @@ export class CodexAcpServer { }; } - private applyFastModeChange(sessionState: SessionState, value: string): void { + private applyFastModeChange(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): void { + const value = params.value; + if (typeof value === "boolean") { + sessionState.fastModeEnabled = value; + return; + } if (value !== FAST_MODE_ON && value !== FAST_MODE_OFF) { throw RequestError.invalidParams(); } @@ -673,6 +726,13 @@ export class CodexAcpServer { sessionState.planModeExplicitlySet = true; } + private stringConfigValue(params: acp.SetSessionConfigOptionRequest): string { + if (typeof params.value !== "string") { + throw RequestError.invalidParams(); + } + return params.value; + } + private applyModeChange(sessionState: SessionState, value: string): void { const newMode = AgentMode.find(value); if (!newMode) { @@ -684,6 +744,10 @@ export class CodexAcpServer { private applyModelChange(sessionState: SessionState, value: string): void { const model = sessionState.availableModels.find(m => m.id === value); if (!model) { + const currentModel = ModelId.fromString(sessionState.currentModelId).model; + if (value === currentModel) { + return; + } throw RequestError.invalidParams(); } const currentEffort = ModelId.fromString(sessionState.currentModelId).effort; @@ -739,18 +803,6 @@ export class CodexAcpServer { return {}; } - async getThreadGoal(params: ThreadGoalGetParams): Promise { - return await this.runWithProcessCheck(() => this.codexAcpClient.getThreadGoal(params)); - } - - async setThreadGoal(params: ThreadGoalSetParams): Promise { - return await this.runWithProcessCheck(() => this.codexAcpClient.setThreadGoal(params)); - } - - async clearThreadGoal(params: ThreadGoalClearParams): Promise { - return await this.runWithProcessCheck(() => this.codexAcpClient.clearThreadGoal(params)); - } - private parseLegacySetSessionModelParams(params: Record): LegacySetSessionModelRequest { const sessionId = params["sessionId"]; const modelId = params["modelId"]; @@ -763,80 +815,25 @@ export class CodexAcpServer { }; } - private parseThreadGoalGetParams(params: Record): ThreadGoalGetParams { - return { - threadId: this.parseThreadGoalThreadId(params), - }; - } - - private parseThreadGoalClearParams(params: Record): ThreadGoalClearParams { - return { - threadId: this.parseThreadGoalThreadId(params), - }; - } - - private parseThreadGoalSetParams(params: Record): ThreadGoalSetParams { - const parsed: ThreadGoalSetParams = { - threadId: this.parseThreadGoalThreadId(params), - }; - - if (Object.prototype.hasOwnProperty.call(params, "objective")) { - parsed.objective = this.parseOptionalString(params["objective"]); - } - if (Object.prototype.hasOwnProperty.call(params, "status")) { - parsed.status = this.parseOptionalThreadGoalStatus(params["status"]); - } - if (Object.prototype.hasOwnProperty.call(params, "tokenBudget")) { - parsed.tokenBudget = this.parseOptionalNumber(params["tokenBudget"]); - } - - return parsed; - } - - private parseThreadGoalThreadId(params: Record): string { - const threadId = params["threadId"]; - if (typeof threadId !== "string") { - throw RequestError.invalidParams(); - } - return threadId; - } - - private parseOptionalString(value: unknown): string | null { - if (value === null || typeof value === "string") { - return value; - } - throw RequestError.invalidParams(); - } - - private parseOptionalNumber(value: unknown): number | null { - if (value === null) { - return null; - } - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - throw RequestError.invalidParams(); - } - - private parseOptionalThreadGoalStatus(value: unknown): ThreadGoalStatus | null { - if (value === null) { - return null; - } - if (typeof value === "string" && THREAD_GOAL_STATUSES.has(value as ThreadGoalStatus)) { - return value as ThreadGoalStatus; - } - throw RequestError.invalidParams(); - } - private createSessionConfigOptions(sessionState: SessionState): Array { const currentModelId = ModelId.fromString(sessionState.currentModelId); - return [ + const configOptions = [ sessionState.agentMode.toConfigOption(), createModelConfigOption(sessionState.availableModels, currentModelId.model), - createReasoningEffortConfigOption(sessionState.supportedReasoningEfforts, currentModelId.effort), - createFastModeConfigOption(sessionState.fastModeEnabled), - createPlanModeConfigOption(sessionState.planModeEnabled), ]; + if (sessionState.supportedReasoningEfforts.length > 0) { + configOptions.push( + createReasoningEffortConfigOption(sessionState.supportedReasoningEfforts, currentModelId.effort), + ); + } + if (sessionState.currentModelSupportsFast) { + configOptions.push(createFastModeConfigOption( + sessionState.fastModeEnabled, + this.booleanConfigOptionsSupported, + )); + } + configOptions.push(createPlanModeConfigOption(sessionState.planModeEnabled)); + return configOptions; } private createSessionConfigOptionsResponse(sessionState: SessionState): { @@ -869,8 +866,8 @@ export class CodexAcpServer { }; } - private publishAvailableCommandsAsync(sessionId: string) { - void this.availableCommands.publish(sessionId); + private publishAvailableCommandsAsync(sessionState: SessionState) { + void this.availableCommands.publish(sessionState); } private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { @@ -932,9 +929,10 @@ export class CodexAcpServer { } const {sessionId, currentModelId, models, thread} = sessionMetadata; - let account: Account | null; + const authProvider = sessionMetadata.modelProvider ?? this.codexAcpClient.getModelProvider(); + let authState: ActiveAuthState; try { - account = await this.getActiveAccount(); + authState = await this.getAuthStateForProvider(authProvider); } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); @@ -960,7 +958,9 @@ export class CodexAcpServer { totalTokenUsage: null, modelContextWindow: null, rateLimits: null, - account: account, + account: authState.account, + authConfigured: authState.authConfigured, + authProvider: authProvider, cwd: request.cwd, additionalDirectories: sessionMetadata.additionalDirectories, fastModeEnabled: sessionMetadata.currentServiceTier === "fast", @@ -981,7 +981,7 @@ export class CodexAcpServer { this.publishMcpStartupStatusAsync(sessionId); } - await this.availableCommands.publish(sessionId); + await this.availableCommands.publish(sessionState); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -1025,11 +1025,15 @@ export class CodexAcpServer { case "subAgentActivity": case "sleep": return []; - case "agentMessage": + case "agentMessage": { + const meta = createCodexMessagePhaseMeta(item.phase); return [{ sessionUpdate: "agent_message_chunk", + messageId: item.id, content: { type: "text", text: item.text }, + ...(meta ? { _meta: meta } : {}), }]; + } case "reasoning": return this.createReasoningUpdates(item); case "fileChange": @@ -1047,7 +1051,7 @@ export class CodexAcpServer { case "dynamicToolCall": return [await createDynamicToolCallUpdate(item)]; case "collabAgentToolCall": - return [this.createCollabAgentToolCallUpdate(item)]; + return [createCollabAgentToolCallUpdate(item)]; case "webSearch": return [this.createWebSearchUpdate(item)]; case "imageView": @@ -1067,13 +1071,11 @@ export class CodexAcpServer { private createUserMessageUpdates(item: ThreadItem & { type: "userMessage" }): UpdateSessionEvent[] { const updates: UpdateSessionEvent[] = []; + const messageId = item.id; for (const input of item.content) { const blocks = this.userInputToContentBlocks(input); for (const block of blocks) { - updates.push({ - sessionUpdate: "user_message_chunk", - content: block, - }); + updates.push(createUserMessageChunk(block, messageId)); } } return updates; @@ -1081,29 +1083,8 @@ export class CodexAcpServer { private createReasoningUpdates(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent[] { const parts = item.summary.length > 0 ? item.summary : item.content; - return parts.map((text) => ({ - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: text }, - })); - } - - private createCollabAgentToolCallUpdate( - item: ThreadItem & { type: "collabAgentToolCall" } - ): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "other", - title: `collab.${item.tool}`, - status: this.toAcpToolCallStatus(item.status), - rawInput: { - prompt: item.prompt, - senderThreadId: item.senderThreadId, - receiverThreadIds: item.receiverThreadIds, - agentsStates: item.agentsStates, - status: item.status, - }, - }; + const messageId = item.id; + return parts.map((text) => createAgentTextThoughtChunk(text, messageId)); } private createWebSearchUpdate( @@ -1157,17 +1138,6 @@ export class CodexAcpServer { }; } - private toAcpToolCallStatus(status: CollabAgentToolCallStatus): "in_progress" | "completed" | "failed" { - switch (status) { - case "inProgress": - return "in_progress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - } - } - private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { switch (input.type) { case "text": @@ -1286,17 +1256,33 @@ export class CodexAcpServer { const closeSignal = new Promise((resolve) => { resolveCloseSignal = resolve; }); + let resolveCancelSignal: (value: null) => void = () => {}; + const cancelSignal = new Promise((resolve) => { + resolveCancelSignal = resolve; + }); + const abortController = new AbortController(); let completed = false; let closeRequested = false; const activePrompt: ActivePrompt = { completion, closeSignal, + cancelSignal, + signal: abortController.signal, + currentTurn: null, + requestCancel: () => { + if (abortController.signal.aborted) { + return; + } + abortController.abort(); + resolveCancelSignal(null); + }, requestClose: () => { if (closeRequested) { return; } closeRequested = true; + activePrompt.requestCancel(); resolveCloseSignal(null); }, complete: () => { @@ -1315,6 +1301,46 @@ export class CodexAcpServer { return activePrompt; } + private cancelBeforeTurnStarted(activePrompt: ActivePrompt): Promise { + return activePrompt.cancelSignal.then(() => { + if (activePrompt.currentTurn === null) { + return null; + } + return new Promise(() => {}); + }); + } + + private observePromptRequestCancellation( + signal: AbortSignal | undefined, + sessionState: SessionState, + activePrompt: ActivePrompt, + ): () => void { + if (!signal) { + return () => {}; + } + + const onAbort = () => { + if (this.activePrompts.get(sessionState.sessionId) !== activePrompt) { + return; + } + logger.log("Prompt request cancelled", {sessionId: sessionState.sessionId}); + activePrompt.requestCancel(); + const turn = activePrompt.currentTurn; + if (!turn) { + return; + } + void this.requestTurnInterrupt(turn, "Cancel"); + }; + + if (signal.aborted) { + onAbort(); + return () => {}; + } + + signal.addEventListener("abort", onAbort, {once: true}); + return () => signal.removeEventListener("abort", onAbort); + } + private createPendingTurnStart(): PendingTurnStart { let resolve: (turnId: string | null) => void = () => {}; const promise = new Promise((innerResolve) => { @@ -1323,26 +1349,48 @@ export class CodexAcpServer { return {promise, resolve}; } - private interruptLateStartedTurn(sessionId: string, turnId: string): void { + private async interruptPromptTurn( + turn: { threadId: string, turnId: string }, + requestName: "Cancel" | "Close", + ): Promise { this.codexAcpClient.markTurnStale({ - threadId: sessionId, - turnId, + threadId: turn.threadId, + turnId: turn.turnId, }); - void this.runWithProcessCheck(() => this.codexAcpClient.turnInterrupt({ - threadId: sessionId, - turnId, - })).catch((err) => { - logger.error(`Close - late turnInterrupt failed`, err); - }).finally(() => { + try { + await this.requestTurnInterrupt(turn, requestName); + } finally { this.codexAcpClient.resolveTurnInterrupted({ - threadId: sessionId, - turnId, + threadId: turn.threadId, + turnId: turn.turnId, }); - }); + } + } + + private async requestTurnInterrupt( + turn: { threadId: string, turnId: string }, + requestName: "Cancel" | "Close", + ): Promise { + try { + await this.runWithProcessCheck(() => this.codexAcpClient.turnInterrupt({ + threadId: turn.threadId, + turnId: turn.turnId, + })); + logger.log(`${requestName} - turnInterrupt succeeded`, { + sessionId: turn.threadId, + currentTurnId: turn.turnId, + }); + } catch (err) { + logger.error(`${requestName} - turnInterrupt failed`, err); + } + } + + private interruptLateStartedTurn(turn: { threadId: string, turnId: string }): void { + void this.interruptPromptTurn(turn, "Close"); } - private promptIsClosedOrStale(sessionId: string, activePrompt: ActivePrompt): boolean { - return this.activePrompts.get(sessionId) !== activePrompt || this.sessionIsClosing(sessionId); + private promptShouldStop(sessionId: string, activePrompt: ActivePrompt): boolean { + return activePrompt.signal.aborted || this.activePrompts.get(sessionId) !== activePrompt || this.sessionIsClosing(sessionId); } private async interruptSessionTurn( @@ -1412,7 +1460,7 @@ export class CodexAcpServer { return turnId; } - async prompt(params: acp.PromptRequest): Promise { + async prompt(params: acp.PromptRequest, signal?: AbortSignal): Promise { logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, @@ -1422,43 +1470,72 @@ export class CodexAcpServer { sessionState.lastTokenUsage = null; const activePrompt = this.trackActivePrompt(params.sessionId); let pendingTurnStart: PendingTurnStart | null = null; + const ensurePendingTurnStart = (): PendingTurnStart => { + if (pendingTurnStart === null) { + pendingTurnStart = this.createPendingTurnStart(); + this.pendingTurnStarts.set(params.sessionId, pendingTurnStart); + } + return pendingTurnStart; + }; + const disposePromptRequestCancellation = this.observePromptRequestCancellation(signal, sessionState, activePrompt); try { const eventHandler = new CodexEventHandler(this.connection, sessionState); - const approvalHandler = new CodexApprovalHandler(this.connection, sessionState); - const elicitationHandler = new CodexElicitationHandler(this.connection, sessionState); + const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, activePrompt.signal); + const elicitationHandler = new CodexElicitationHandler( + this.connection, + sessionState, + this.clientCapabilities, + activePrompt.signal, + ); await this.codexAcpClient.subscribeToSessionEvents(params.sessionId, - (event) => { - elicitationHandler.handleNotification(event); + async (event) => { + await elicitationHandler.handleNotification(event); return eventHandler.handleNotification(event); }, approvalHandler, elicitationHandler); - const commandResult = await this.availableCommands.tryHandleCommand(params.prompt, sessionState); + if (activePrompt.signal.aborted) { + return this.cancelledPromptResponse(sessionState); + } + + const commandPromise = this.availableCommands.tryHandleCommand(params.prompt, sessionState, { + onTurnStartPending: () => { + ensurePendingTurnStart(); + }, + onTurnStarted: (turnId, threadId) => { + const turn = {threadId, turnId}; + activePrompt.currentTurn = turn; + if (this.promptShouldStop(params.sessionId, activePrompt)) { + this.interruptLateStartedTurn(turn); + return; + } + sessionState.currentTurnId = turnId; + pendingTurnStart?.resolve(turnId); + }, + }); + void commandPromise.catch((err) => { + if (this.activePrompts.get(params.sessionId) !== activePrompt) { + logger.error(`Command for cancelled prompt ${params.sessionId} failed after prompt returned`, err); + } + }); + const commandResult = await Promise.race([ + commandPromise, + activePrompt.closeSignal, + this.cancelBeforeTurnStarted(activePrompt), + ]); + if (commandResult === null) { + return this.cancelledPromptResponse(sessionState); + } if (commandResult.handled) { logger.log("Prompt handled by a command"); await this.codexAcpClient.waitForSessionNotifications(params.sessionId); if (commandResult.turnCompleted?.turn.status === "interrupted") { - if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) { - await this.connection.notify(acp.methods.client.session.update, { - sessionId: params.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "*Conversation interrupted*" - } - } - }); - } - return { - stopReason: "cancelled", - usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), - }; + await this.notifyConversationInterrupted(params.sessionId); + return this.cancelledPromptResponse(sessionState); } - const error = eventHandler.getFailure() + const error = eventHandler.getFailure(); if (error) { // noinspection ExceptionCaughtLocallyJS throw error; @@ -1471,11 +1548,7 @@ export class CodexAcpServer { } if (this.sessionIsClosing(params.sessionId)) { - return { - stopReason: "cancelled", - usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), - }; + return this.cancelledPromptResponse(sessionState); } const modelId = ModelId.fromString(sessionState.currentModelId); @@ -1499,8 +1572,7 @@ export class CodexAcpServer { sessionState.currentModelSupportsFast, ); const collaborationMode = this.createPlanModeCollaborationMode(sessionState, modelId); - pendingTurnStart = this.createPendingTurnStart(); - this.pendingTurnStarts.set(params.sessionId, pendingTurnStart); + ensurePendingTurnStart(); const sendPromptPromise = this.runWithProcessCheck( () => this.codexAcpClient.sendPrompt( params, @@ -1512,57 +1584,40 @@ export class CodexAcpServer { sessionState.cwd, sessionState.additionalDirectories, (turnId) => { - if (this.promptIsClosedOrStale(params.sessionId, activePrompt)) { - this.interruptLateStartedTurn(params.sessionId, turnId); + const turn = {threadId: params.sessionId, turnId}; + activePrompt.currentTurn = turn; + if (this.promptShouldStop(params.sessionId, activePrompt)) { + this.interruptLateStartedTurn(turn); return; } sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); }, - () => this.promptIsClosedOrStale(params.sessionId, activePrompt), + () => this.promptShouldStop(params.sessionId, activePrompt), )); void sendPromptPromise.catch((err) => { if (this.activePrompts.get(params.sessionId) !== activePrompt) { - logger.error(`Prompt for closed session ${params.sessionId} failed after close`, err); + logger.error(`Prompt for cancelled session ${params.sessionId} failed after prompt returned`, err); } }); const turnCompleted = await Promise.race([ sendPromptPromise, activePrompt.closeSignal, + this.cancelBeforeTurnStarted(activePrompt), ]); if (turnCompleted === null) { - return { - stopReason: "cancelled", - usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), - }; + return this.cancelledPromptResponse(sessionState); } await this.codexAcpClient.waitForSessionNotifications(params.sessionId); - // Check if turn was interrupted (cancelled) if (turnCompleted.turn.status === "interrupted") { - if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) { - await this.connection.notify(acp.methods.client.session.update, { - sessionId: params.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "*Conversation interrupted*" - } - } - }); - } - return { - stopReason: "cancelled", - usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), - }; + await this.notifyConversationInterrupted(params.sessionId); + return this.cancelledPromptResponse(sessionState); } - const error = eventHandler.getFailure() + const error = eventHandler.getFailure(); if (error) { // noinspection ExceptionCaughtLocallyJS throw error; @@ -1578,15 +1633,35 @@ export class CodexAcpServer { throw err; } finally { logger.log("Prompt completed", {sessionId: params.sessionId}); + disposePromptRequestCancellation(); sessionState.currentTurnId = null; - if (pendingTurnStart !== null && this.pendingTurnStarts.get(params.sessionId) === pendingTurnStart) { + const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId); + if (registeredPendingTurnStart !== undefined) { this.pendingTurnStarts.delete(params.sessionId); + registeredPendingTurnStart.resolve(null); } - pendingTurnStart?.resolve(null); activePrompt.complete(); } } + private cancelledPromptResponse(sessionState: SessionState): acp.PromptResponse { + return { + stopReason: "cancelled", + usage: this.buildPromptUsage(sessionState.lastTokenUsage), + _meta: this.buildQuotaMeta(sessionState), + }; + } + + private async notifyConversationInterrupted(sessionId: string): Promise { + if (this.sessionIsClosing(sessionId) || !this.sessions.has(sessionId)) { + return; + } + await this.connection.notify(acp.methods.client.session.update, { + sessionId, + update: createAgentTextMessageChunk("*Conversation interrupted*"), + }); + } + private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; @@ -1662,26 +1737,33 @@ function mergeHistoryUpdates( merged.push(update); }; - const flushFallbackThrough = (targetKey: string): boolean => { + const flushFallbackBeforeMatchingDuplicate = (targetUpdate: UpdateSessionEvent): void => { + const targetKey = historyUpdateKey(targetUpdate); + const targetContentKey = historyUpdateContentKey(targetUpdate); + if (!targetKey && !targetContentKey) { + return; + } + const matchIndex = responseItemFallbackUpdates.findIndex((update, index) => ( - index >= fallbackIndex && historyUpdateKey(update) === targetKey + index >= fallbackIndex + && ( + (targetKey !== null && historyUpdateKey(update) === targetKey) + || (targetContentKey !== null && historyUpdateContentKey(update) === targetContentKey) + ) )); if (matchIndex === -1) { - return false; + return; } - while (fallbackIndex <= matchIndex) { + while (fallbackIndex < matchIndex) { pushUpdate(responseItemFallbackUpdates[fallbackIndex]!); fallbackIndex += 1; } - return true; + fallbackIndex += 1; }; for (const update of threadUpdates) { - const key = historyUpdateKey(update); - if (key && flushFallbackThrough(key)) { - continue; - } + flushFallbackBeforeMatchingDuplicate(update); pushUpdate(update); } @@ -1698,7 +1780,7 @@ function historyUpdateKey(update: UpdateSessionEvent): string | null { case "user_message_chunk": case "agent_message_chunk": case "agent_thought_chunk": - return `${update.sessionUpdate}:${JSON.stringify(update.content)}`; + return `${update.sessionUpdate}:${update.messageId ?? ""}:${JSON.stringify(update.content)}`; case "tool_call": return `tool_call:${update.toolCallId}:start`; case "tool_call_update": @@ -1708,6 +1790,17 @@ function historyUpdateKey(update: UpdateSessionEvent): string | null { } } +function historyUpdateContentKey(update: UpdateSessionEvent): string | null { + switch (update.sessionUpdate) { + case "user_message_chunk": + case "agent_message_chunk": + case "agent_thought_chunk": + return `${update.sessionUpdate}:${JSON.stringify(update.content)}`; + default: + return historyUpdateKey(update); + } +} + function getRequestedMcpServerNames(mcpServers: Array): Array { return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name)))); } diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 4ca5f84e..d1bc210c 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -26,14 +26,17 @@ import type { SkillsExtraRootsSetParams, SkillsListParams, SkillsListResponse, + ThreadGoal, + ThreadGoalUpdatedNotification, + ThreadStatus, + ThreadStatusChangedNotification, ThreadArchiveParams, ThreadArchiveResponse, ThreadCompactStartParams, ThreadCompactStartResponse, + ThreadGoalClearedNotification, ThreadGoalClearParams, ThreadGoalClearResponse, - ThreadGoalGetParams, - ThreadGoalGetResponse, ThreadGoalSetParams, ThreadGoalSetResponse, ThreadLoadedListParams, @@ -107,6 +110,8 @@ const McpServerElicitationRequest = new RequestType< void >('mcpServer/elicitation/request'); +const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000; + /** * A type-safe client over the Codex App Server's JSON-RPC API. * Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations. @@ -121,6 +126,10 @@ export class CodexAppServerClient { private readonly pendingTurnCompletionResolvers = new Map void>>(); private readonly pendingCompactionCompletionResolvers = new Map void>>(); private readonly turnCompletionCaptures = new Map void>>(); + private readonly turnRoutingCaptures = new Map void>>(); + private readonly threadStatusCaptures = new Map void>>(); + private readonly threadGoalUpdateCaptures = new Map void>>(); + private readonly threadGoalClearedCaptures = new Map void>>(); private readonly staleTurnIds = new Map>(); constructor(connection: MessageConnection) { @@ -142,15 +151,21 @@ export class CodexAppServerClient { if (isCompactionCompletedNotification(serverNotification)) { this.recordCompactionCompleted(serverNotification); } + if (isThreadStatusChangedNotification(serverNotification)) { + this.recordThreadStatusChanged(serverNotification.params); + } + if (isThreadGoalUpdatedNotification(serverNotification)) { + this.recordThreadGoalUpdated(serverNotification.params); + } + if (isThreadGoalClearedNotification(serverNotification)) { + this.recordThreadGoalCleared(serverNotification.params); + } const routing = extractTurnRouting(serverNotification); - const staleTurnNotification = this.isStaleTurn(routing.threadId, routing.turnId); - if (staleTurnNotification) { - if (isTurnCompletedNotification(serverNotification) && routing.threadId !== null && routing.turnId !== null) { - this.clearStaleTurn(routing.threadId, routing.turnId); - } - for (const callback of this.codexEventHandlers) { - callback({ eventType: "notification", ...serverNotification }); - } + if (this.handleStaleTurnNotification(serverNotification, routing)) { + return; + } + this.recordTurnRouting(routing); + if (this.handleStaleTurnNotification(serverNotification, routing)) { return; } this.notify(serverNotification); @@ -248,7 +263,10 @@ export class CodexAppServerClient { } } - async runReview(params: ReviewStartParams, onTurnStarted?: (turnId: string) => void): Promise { + async runReview( + params: ReviewStartParams, + onTurnStarted?: (turnId: string, threadId: string) => void, + ): Promise { const capturedCompletions: Array = []; const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => { capturedCompletions.push(event); @@ -256,7 +274,7 @@ export class CodexAppServerClient { try { const reviewStarted = await this.reviewStart(params); - onTurnStarted?.(reviewStarted.turn.id); + onTurnStarted?.(reviewStarted.turn.id, reviewStarted.reviewThreadId); const earlyCompletion = capturedCompletions.find(event => event.turn.id === reviewStarted.turn.id); releaseCapture(); if (earlyCompletion) { @@ -268,6 +286,185 @@ export class CodexAppServerClient { } } + async runGoalSet( + params: ThreadGoalSetParams, + onTurnStarted?: (turnId: string) => void, + runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS, + ): Promise { + let goalTurnId: string | null = null; + const capturedCompletions: Array = []; + let resolveGoalTurnCompleted: (event: TurnCompletedNotification) => void = () => {}; + const goalTurnCompleted = new Promise((resolve) => { + resolveGoalTurnCompleted = resolve; + }); + const releaseCompletionCapture = this.captureTurnCompletions(params.threadId, (event) => { + capturedCompletions.push(event); + if (goalTurnId === event.turn.id) { + resolveGoalTurnCompleted(event); + } + }); + let resolveGoalTurnStarted: (turnId: string) => void = () => {}; + const goalTurnStarted = new Promise((resolve) => { + resolveGoalTurnStarted = resolve; + }); + let resolveGoalUpdateHandled: () => void = () => {}; + const matchingGoalUpdateHandled = new Promise((resolve) => { + resolveGoalUpdateHandled = () => resolve(null); + }); + let goalUpdateHandled = false; + let expectedGoal: ThreadGoal | null = null; + const noGoalTurnStarted = this.createNoGoalTurnStartedPromise(runtimeEffectsGraceMs); + const capturedGoalUpdates: Array = []; + const releaseRoutingCapture = this.captureTurnRoutings(params.threadId, (turnId) => { + if (!goalUpdateHandled || goalTurnId !== null) { + return; + } + goalTurnId = turnId; + onTurnStarted?.(turnId); + resolveGoalTurnStarted(turnId); + }); + const releaseGoalUpdateCapture = this.captureThreadGoalUpdates(params.threadId, (event) => { + capturedGoalUpdates.push(event); + if (expectedGoal !== null && goalsMatch(event.goal, expectedGoal)) { + goalUpdateHandled = true; + resolveGoalUpdateHandled(); + noGoalTurnStarted.goalUpdated(); + } + }); + const releaseStatusCapture = this.captureThreadStatuses(params.threadId, (status) => { + if (!goalUpdateHandled || goalTurnId !== null) { + return; + } + noGoalTurnStarted.threadStatusChanged(status); + }); + + try { + const goalSetResponse = await this.threadGoalSet(params); + expectedGoal = goalSetResponse.goal; + if (capturedGoalUpdates.some(event => goalsMatch(event.goal, expectedGoal!))) { + goalUpdateHandled = true; + resolveGoalUpdateHandled(); + noGoalTurnStarted.goalUpdated(); + } + if (expectedGoal.status !== "active") { + await matchingGoalUpdateHandled; + return null; + } + const turnId = goalTurnId ?? await Promise.race([goalTurnStarted, noGoalTurnStarted.promise]); + noGoalTurnStarted.release(); + releaseRoutingCapture(); + releaseStatusCapture(); + releaseGoalUpdateCapture(); + if (turnId === null) { + return null; + } + const earlyCompletion = capturedCompletions.find(event => event.turn.id === turnId); + if (earlyCompletion) { + return earlyCompletion; + } + return await goalTurnCompleted; + } finally { + noGoalTurnStarted.release(); + releaseCompletionCapture(); + releaseRoutingCapture(); + releaseStatusCapture(); + releaseGoalUpdateCapture(); + } + } + + async runGoalClear(params: ThreadGoalClearParams): Promise { + let goalClearedHandled = false; + let resolveGoalClearedHandled: () => void = () => {}; + const matchingGoalClearedHandled = new Promise((resolve) => { + resolveGoalClearedHandled = () => resolve(); + }); + const releaseGoalClearedCapture = this.captureThreadGoalClears(params.threadId, () => { + goalClearedHandled = true; + resolveGoalClearedHandled(); + }); + + try { + const response = await this.threadGoalClear(params); + if (!response.cleared || goalClearedHandled) { + return; + } + await matchingGoalClearedHandled; + } finally { + releaseGoalClearedCapture(); + } + } + + private createNoGoalTurnStartedPromise( + runtimeEffectsGraceMs: number, + ): { + promise: Promise, + release: () => void, + goalUpdated: () => void, + threadStatusChanged: (status: ThreadStatus) => void, + } { + let released = false; + let resolved = false; + let goalUpdated = false; + let activeAfterGoalUpdate = false; + let timeout: ReturnType | null = null; + let resolveNoGoalTurnStarted: () => void = () => {}; + const clearTimer = () => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; + const resolveNoTurn = () => { + if (released || resolved) { + return; + } + resolved = true; + clearTimer(); + resolveNoGoalTurnStarted(); + }; + const scheduleNoTurnTimer = () => { + if (released || resolved || !goalUpdated || activeAfterGoalUpdate || timeout !== null) { + return; + } + timeout = setTimeout(resolveNoTurn, runtimeEffectsGraceMs); + }; + const release = () => { + if (released) { + return; + } + released = true; + clearTimer(); + }; + const promise = new Promise((resolve) => { + resolveNoGoalTurnStarted = () => { + resolve(null); + }; + }); + const handleGoalUpdated = () => { + goalUpdated = true; + scheduleNoTurnTimer(); + }; + const handleThreadStatusChanged = (status: ThreadStatus) => { + if (!goalUpdated || released || resolved) { + return; + } + if (status.type === "active") { + activeAfterGoalUpdate = true; + clearTimer(); + return; + } + if (activeAfterGoalUpdate) { + resolveNoTurn(); + } + }; + return { + promise, + release, + goalUpdated: handleGoalUpdated, + threadStatusChanged: handleThreadStatusChanged, + }; + } + async runCompact(params: ThreadCompactStartParams): Promise { const compactionCompleted = this.awaitCompactionCompleted(params.threadId); await this.threadCompactStart(params); @@ -320,16 +517,12 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/compact/start", params: params }); } - async threadGoalGet(params: ThreadGoalGetParams): Promise { - return await this.sendRequest({ method: "thread/goal/get", params }); - } - async threadGoalSet(params: ThreadGoalSetParams): Promise { - return await this.sendRequest({ method: "thread/goal/set", params }); + return await this.sendRequest({ method: "thread/goal/set", params: params }); } async threadGoalClear(params: ThreadGoalClearParams): Promise { - return await this.sendRequest({ method: "thread/goal/clear", params }); + return await this.sendRequest({ method: "thread/goal/clear", params: params }); } async listMcpServerStatus(params: ListMcpServerStatusParams): Promise { @@ -484,6 +677,65 @@ export class CodexAppServerClient { } } + private recordThreadStatusChanged(event: ThreadStatusChangedNotification): void { + const captures = this.threadStatusCaptures.get(event.threadId); + if (!captures) { + return; + } + for (const capture of captures) { + capture(event.status); + } + } + + private recordThreadGoalUpdated(event: ThreadGoalUpdatedNotification): void { + const captures = this.threadGoalUpdateCaptures.get(event.threadId); + if (!captures) { + return; + } + for (const capture of captures) { + capture(event); + } + } + + private recordThreadGoalCleared(event: ThreadGoalClearedNotification): void { + const captures = this.threadGoalClearedCaptures.get(event.threadId); + if (!captures) { + return; + } + for (const capture of captures) { + capture(); + } + } + + private recordTurnRouting(routing: { threadId: string | null, turnId: string | null }): void { + if (routing.threadId === null || routing.turnId === null) { + return; + } + const captures = this.turnRoutingCaptures.get(routing.threadId); + if (!captures) { + return; + } + for (const capture of captures) { + capture(routing.turnId); + } + } + + private handleStaleTurnNotification( + notification: ServerNotification, + routing: { threadId: string | null, turnId: string | null }, + ): boolean { + if (!this.isStaleTurn(routing.threadId, routing.turnId)) { + return false; + } + if (isTurnCompletedNotification(notification) && routing.threadId !== null && routing.turnId !== null) { + this.clearStaleTurn(routing.threadId, routing.turnId); + } + for (const callback of this.codexEventHandlers) { + callback({ eventType: "notification", ...notification }); + } + return true; + } + private isStaleTurn(threadId: string | null, turnId: string | null): boolean { if (threadId === null || turnId === null) { return false; @@ -529,6 +781,74 @@ export class CodexAppServerClient { }; } + private captureTurnRoutings(threadId: string, capture: (turnId: string) => void): () => void { + const captures = this.turnRoutingCaptures.get(threadId) ?? new Set<(turnId: string) => void>(); + captures.add(capture); + this.turnRoutingCaptures.set(threadId, captures); + let released = false; + return () => { + if (released) { + return; + } + released = true; + captures.delete(capture); + if (captures.size === 0) { + this.turnRoutingCaptures.delete(threadId); + } + }; + } + + private captureThreadStatuses(threadId: string, capture: (status: ThreadStatus) => void): () => void { + const captures = this.threadStatusCaptures.get(threadId) ?? new Set<(status: ThreadStatus) => void>(); + captures.add(capture); + this.threadStatusCaptures.set(threadId, captures); + let released = false; + return () => { + if (released) { + return; + } + released = true; + captures.delete(capture); + if (captures.size === 0) { + this.threadStatusCaptures.delete(threadId); + } + }; + } + + private captureThreadGoalUpdates(threadId: string, capture: (event: ThreadGoalUpdatedNotification) => void): () => void { + const captures = this.threadGoalUpdateCaptures.get(threadId) ?? new Set<(event: ThreadGoalUpdatedNotification) => void>(); + captures.add(capture); + this.threadGoalUpdateCaptures.set(threadId, captures); + let released = false; + return () => { + if (released) { + return; + } + released = true; + captures.delete(capture); + if (captures.size === 0) { + this.threadGoalUpdateCaptures.delete(threadId); + } + }; + } + + private captureThreadGoalClears(threadId: string, capture: () => void): () => void { + const captures = this.threadGoalClearedCaptures.get(threadId) ?? new Set<() => void>(); + captures.add(capture); + this.threadGoalClearedCaptures.set(threadId, captures); + let released = false; + return () => { + if (released) { + return; + } + released = true; + captures.delete(capture); + if (captures.size === 0) { + this.threadGoalClearedCaptures.delete(threadId); + } + }; + } + private resolveMcpServerStartupResolvers(): void { const pendingResolvers: Array = []; for (const resolver of this.mcpServerStartupResolvers) { @@ -633,6 +953,27 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica return notification.method === "turn/completed"; } +function isThreadStatusChangedNotification(notification: ServerNotification): notification is { + method: "thread/status/changed"; + params: ThreadStatusChangedNotification; +} { + return notification.method === "thread/status/changed"; +} + +function isThreadGoalUpdatedNotification(notification: ServerNotification): notification is { + method: "thread/goal/updated"; + params: ThreadGoalUpdatedNotification; +} { + return notification.method === "thread/goal/updated"; +} + +function isThreadGoalClearedNotification(notification: ServerNotification): notification is { + method: "thread/goal/cleared"; + params: ThreadGoalClearedNotification; +} { + return notification.method === "thread/goal/cleared"; +} + function isCompactionCompletedNotification(notification: ServerNotification): notification is CompactionCompletedNotification { if (notification.method === "thread/compacted") { return true; @@ -640,6 +981,14 @@ function isCompactionCompletedNotification(notification: ServerNotification): no return notification.method === "item/completed" && notification.params.item.type === "contextCompaction"; } +function goalsMatch(left: ThreadGoal, right: ThreadGoal): boolean { + return left.threadId === right.threadId + && left.objective === right.objective + && left.status === right.status + && left.tokenBudget === right.tokenBudget + && left.updatedAt === right.updatedAt; +} + function extractThreadId(notification: ServerNotification): string | null { const params = notification.params as { threadId?: unknown } | undefined; if (params && typeof params.threadId === "string") { diff --git a/src/CodexApprovalHandler.ts b/src/CodexApprovalHandler.ts index 868bbe73..dd73b8ae 100644 --- a/src/CodexApprovalHandler.ts +++ b/src/CodexApprovalHandler.ts @@ -46,13 +46,16 @@ function permissionOption( export class CodexApprovalHandler implements ApprovalHandler { private readonly connection: AcpClientConnection; private readonly sessionState: SessionState; + private readonly cancellationSignal: AbortSignal | undefined; constructor( connection: AcpClientConnection, - sessionState: SessionState + sessionState: SessionState, + cancellationSignal?: AbortSignal, ) { this.connection = connection; this.sessionState = sessionState; + this.cancellationSignal = cancellationSignal; } async handleCommandExecution( @@ -61,7 +64,11 @@ export class CodexApprovalHandler implements ApprovalHandler { try { const sessionId = this.sessionState.sessionId; const acpRequest = this.buildCommandPermissionRequest(sessionId, params); - const response = await this.connection.request(acp.methods.client.session.requestPermission, acpRequest); + const response = await this.connection.request( + acp.methods.client.session.requestPermission, + acpRequest, + this.requestOptions(), + ); return this.convertCommandResponse(params, response); } catch (error) { logger.error("Error requesting command execution permission", error); @@ -75,7 +82,11 @@ export class CodexApprovalHandler implements ApprovalHandler { try { const sessionId = this.sessionState.sessionId; const acpRequest = this.buildFileChangePermissionRequest(sessionId, params); - const response = await this.connection.request(acp.methods.client.session.requestPermission, acpRequest); + const response = await this.connection.request( + acp.methods.client.session.requestPermission, + acpRequest, + this.requestOptions(), + ); return this.convertFileChangeResponse(params, response); } catch (error) { logger.error("Error requesting file change permission", error); @@ -89,7 +100,11 @@ export class CodexApprovalHandler implements ApprovalHandler { try { const sessionId = this.sessionState.sessionId; const acpRequest = this.buildPermissionsRequest(sessionId, params); - const response = await this.connection.request(acp.methods.client.session.requestPermission, acpRequest); + const response = await this.connection.request( + acp.methods.client.session.requestPermission, + acpRequest, + this.requestOptions(), + ); return this.convertPermissionsResponse(params, response); } catch (error) { logger.error("Error requesting permissions", error); @@ -97,6 +112,10 @@ export class CodexApprovalHandler implements ApprovalHandler { } } + private requestOptions(): acp.SendRequestOptions | undefined { + return this.cancellationSignal ? {cancellationSignal: this.cancellationSignal} : undefined; + } + private buildCommandPermissionRequest( sessionId: string, params: CommandExecutionRequestApprovalParams diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index d0fa327b..94c1833a 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -1,6 +1,18 @@ import type {AuthenticateRequest, AuthMethod, ClientCapabilities} from "@agentclientprotocol/sdk"; +export const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY"; +export const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY"; + +interface ApiKeyAuthRequest extends AuthenticateRequest { + methodId: "api-key"; + _meta?: { + "api-key"?: { + apiKey: string; + } + } | null; +} + const ApiKeyAuthMethod: AuthMethod = { id: "api-key", name: "API Key", @@ -12,15 +24,6 @@ const ApiKeyAuthMethod: AuthMethod = { } } -interface ApiKeyAuthRequest extends AuthenticateRequest { - methodId: "api-key"; - _meta: { - "api-key": { - apiKey: string; - } - }; -} - const ChatGptAuthMethod: AuthMethod = { id: "chat-gpt", name: "ChatGPT", @@ -54,8 +57,11 @@ export interface GatewayAuthRequest extends AuthenticateRequest { }; } -export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | null): AuthMethod[] { - const authMethods: AuthMethod[] = [ApiKeyAuthMethod, ChatGptAuthMethod]; +export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | null, env: NodeJS.ProcessEnv = process.env): AuthMethod[] { + const authMethods: AuthMethod[] = [ApiKeyAuthMethod]; + if (!env["NO_BROWSER"]) { + authMethods.push(ChatGptAuthMethod); + } const supportsGatewayAuth = clientCapabilities?.auth?._meta?.["gateway"] === true; if (supportsGatewayAuth) { authMethods.push(GatewayAuthMethod); diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 25ffd38b..d7057b8e 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -2,11 +2,18 @@ import type * as acp from "@agentclientprotocol/sdk"; import type {AvailableCommand} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection} from "./ACPSessionConnection"; import type {CodexAcpClient} from "./CodexAcpClient"; -import type {RateLimitSnapshot, ReviewTarget, ThreadGoal, TurnCompletedNotification} from "./app-server/v2"; +import type { + RateLimitSnapshot, + ReviewTarget, + SkillsListEntry, + SkillsListParams, + TurnCompletedNotification, +} from "./app-server/v2"; import type {SessionState} from "./CodexAcpServer"; import type {RateLimitsMap} from "./RateLimitsMap"; import type {TokenCount} from "./TokenCount"; import {logger} from "./Logger"; +import {createAgentTextMessageChunk} from "./ContentChunks"; type ParsedSlashCommand = { name: string; @@ -17,45 +24,75 @@ export type CommandHandleResult = | { handled: false } | { handled: true, turnCompleted?: TurnCompletedNotification }; +export type CommandHandleOptions = { + onTurnStartPending?: () => void; + onTurnStarted?: (turnId: string, threadId: string) => void; +}; + +export type LogoutHandler = () => void | Promise; + export class CodexCommands { private readonly connection: AcpClientConnection; private readonly codexAcpClient: CodexAcpClient; private readonly runWithProcessCheck: (operation: () => Promise) => Promise; + private readonly onLogout: LogoutHandler; constructor( connection: AcpClientConnection, codexAcpClient: CodexAcpClient, - runWithProcessCheck: (operation: () => Promise) => Promise + runWithProcessCheck: (operation: () => Promise) => Promise, + onLogout: LogoutHandler = () => {} ) { this.connection = connection; this.codexAcpClient = codexAcpClient; this.runWithProcessCheck = runWithProcessCheck; + this.onLogout = onLogout; } - async publish(sessionId: string): Promise { + async publish(sessionState: SessionState): Promise { try { - const availableCommands = this.buildAvailableCommands(); + const skillsResponse = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills(this.createSkillsListParams(sessionState))); + const availableCommands = this.buildAvailableCommands(skillsResponse?.data ?? []); if (availableCommands.length === 0) { return; } - const session = new ACPSessionConnection(this.connection, sessionId); + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); await session.update({ sessionUpdate: "available_commands_update", availableCommands }); } catch (err) { - logger.error(`Failed to publish available commands for session ${sessionId}`, err); + logger.error(`Failed to publish available commands for session ${sessionState.sessionId}`, err); } } - private buildAvailableCommands(): AvailableCommand[] { + private createSkillsListParams(sessionState: SessionState): SkillsListParams { + return { + cwds: [sessionState.cwd, ...sessionState.additionalDirectories], + }; + } + + private buildAvailableCommands(skillsEntries: SkillsListEntry[]): AvailableCommand[] { const commands = new Map(); for (const builtin of this.getBuiltinCommands()) { commands.set(builtin.name, builtin); } + for (const entry of skillsEntries) { + for (const skill of entry.skills) { + const name = `$${skill.name}`; + if (commands.has(name)) continue; + const description = skill.shortDescription ?? skill.description ?? skill.name; + commands.set(name, { + name, + description, + input: null, + }); + } + } + return Array.from(commands.values()); } @@ -79,11 +116,6 @@ export class CodexCommands { description: "Display session configuration and token usage.", input: null }, - { - name: "goal", - description: "Set or view the goal for a long-running task.", - input: { hint: "objective | pause | resume | clear" } - }, { name: "review", description: "Review uncommitted changes, or review with custom instructions.", @@ -104,6 +136,11 @@ export class CodexCommands { description: "Summarize conversation to avoid hitting the context limit.", input: null }, + { + name: "goal", + description: "Set, pause, resume, or clear a task goal.", + input: { hint: "[|clear|pause|resume]" } + }, { name: "logout", description: "Sign out of Codex. This option is available when you are logged in via ChatGPT.", @@ -131,7 +168,11 @@ export class CodexCommands { }; } - async tryHandleCommand(prompt: acp.ContentBlock[], sessionState: SessionState): Promise { + async tryHandleCommand( + prompt: acp.ContentBlock[], + sessionState: SessionState, + options: CommandHandleOptions = {}, + ): Promise { const command = this.parseCommand(prompt); if (command === null) return { handled: false }; const commandName = command.name; @@ -143,9 +184,12 @@ export class CodexCommands { await this.runWithProcessCheck(() => this.codexAcpClient.runCompact(sessionId)); return { handled: true }; } + case "goal": { + return await this.runGoalCommand(sessionState, command.rest, options); + } case "review": { const target = this.buildReviewTarget(command.rest); - const turnCompleted = await this.runReviewCommand(sessionState, target); + const turnCompleted = await this.runReviewCommand(sessionState, target, options); return { handled: true, turnCompleted }; } case "review-branch": { @@ -156,7 +200,7 @@ export class CodexCommands { const turnCompleted = await this.runReviewCommand(sessionState, { type: "baseBranch", branch: command.rest, - }); + }, options); return { handled: true, turnCompleted }; } case "review-commit": { @@ -168,7 +212,7 @@ export class CodexCommands { type: "commit", sha: command.rest, title: null, - }); + }, options); return { handled: true, turnCompleted }; } case "status": { @@ -176,17 +220,14 @@ export class CodexCommands { await this.sendCommandMessage(message, sessionId); return { handled: true }; } - case "goal": { - await this.handleGoalCommand(command.rest, sessionId); - return { handled: true }; - } case "logout": { await this.runWithProcessCheck(() => this.codexAcpClient.logout()); + await this.onLogout(); await this.sendCommandMessage("Logged out from Codex account.", sessionId); return { handled: true }; } case "skills": { - const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills()); + const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills(this.createSkillsListParams(sessionState))); const skills = (response?.data ?? []).flatMap(entry => entry.skills); const lines = skills.map(skill => { const description = skill.shortDescription ?? skill.description ?? ""; @@ -221,16 +262,89 @@ export class CodexCommands { } } - private async runReviewCommand(sessionState: SessionState, target: ReviewTarget): Promise { + private async runReviewCommand( + sessionState: SessionState, + target: ReviewTarget, + options: CommandHandleOptions, + ): Promise { + options.onTurnStartPending?.(); return await this.runWithProcessCheck(() => this.codexAcpClient.runReview( sessionState.sessionId, target, - (turnId) => { - sessionState.currentTurnId = turnId; + (turnId, threadId) => { + this.handleCommandTurnStarted(sessionState, options, turnId, threadId); }, )); } + private async runGoalCommand( + sessionState: SessionState, + rest: string, + options: CommandHandleOptions, + ): Promise { + const sessionId = sessionState.sessionId; + const argument = rest.trim(); + if (argument.length === 0) { + await this.sendCommandUsageMessage("goal", "[|clear|pause|resume]", sessionId); + return { handled: true }; + } + + switch (argument.toLowerCase()) { + case "pause": + await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionId, "paused")); + return { handled: true }; + case "resume": + options.onTurnStartPending?.(); + return this.createGoalCommandResult(await this.runWithProcessCheck(() => this.codexAcpClient.resumeGoal( + sessionId, + (turnId) => { + this.handleCommandTurnStarted(sessionState, options, turnId, sessionId); + }, + ))); + case "clear": + await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionId)); + return { handled: true }; + } + + if (argument.length > 4000) { + const session = new ACPSessionConnection(this.connection, sessionId); + await session.update(createAgentTextMessageChunk('Command "/goal" requires goal text of at most 4000 characters.')); + return { handled: true }; + } + + options.onTurnStartPending?.(); + return this.createGoalCommandResult(await this.runWithProcessCheck(() => this.codexAcpClient.setGoal( + sessionId, + argument, + (turnId) => { + this.handleCommandTurnStarted(sessionState, options, turnId, sessionId); + }, + ))); + } + + private handleCommandTurnStarted( + sessionState: SessionState, + options: CommandHandleOptions, + turnId: string, + threadId: string, + ): void { + if (options.onTurnStarted) { + options.onTurnStarted(turnId, threadId); + } else { + sessionState.currentTurnId = turnId; + } + } + + private createGoalCommandResult(turnCompleted: TurnCompletedNotification | null): CommandHandleResult { + if (turnCompleted === null) { + return { handled: true }; + } + return { + handled: true, + turnCompleted, + }; + } + private buildReviewTarget(instructions: string): ReviewTarget { if (instructions.length === 0) { return { type: "uncommittedChanges" }; @@ -247,57 +361,7 @@ export class CodexCommands { private async sendCommandMessage(text: string, sessionId: string): Promise { const session = new ACPSessionConnection(this.connection, sessionId); - await session.update({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text } - }); - } - - private async handleGoalCommand(args: string, sessionId: string): Promise { - const trimmed = args.trim(); - switch (trimmed.toLowerCase()) { - case "": { - const response = await this.runWithProcessCheck(() => this.codexAcpClient.getThreadGoal({ threadId: sessionId })); - const text = response.goal - ? this.formatThreadGoalSummary(response.goal) - : "Usage: /goal \nNo goal is currently set."; - await this.sendCommandMessage(text, sessionId); - return; - } - case "clear": { - const response = await this.runWithProcessCheck(() => this.codexAcpClient.clearThreadGoal({ threadId: sessionId })); - if (!response.cleared) { - await this.sendCommandMessage("No goal to clear. This thread does not currently have a goal.", sessionId); - } - return; - } - case "pause": - await this.handleGoalStatusCommand(sessionId, "paused"); - return; - case "resume": - await this.handleGoalStatusCommand(sessionId, "active"); - return; - default: - await this.runWithProcessCheck(() => this.codexAcpClient.setThreadGoal({ - threadId: sessionId, - objective: trimmed, - status: "active", - })); - return; - } - } - - private async handleGoalStatusCommand(sessionId: string, status: "active" | "paused"): Promise { - const response = await this.runWithProcessCheck(() => this.codexAcpClient.getThreadGoal({ threadId: sessionId })); - if (!response.goal) { - await this.sendCommandMessage("No goal is currently set. Use `/goal ` to create one.", sessionId); - return; - } - - await this.runWithProcessCheck(() => this.codexAcpClient.setThreadGoal({ - threadId: sessionId, - status, - })); + await session.update(createAgentTextMessageChunk(text)); } private async sendUnknownCommandMessage(name: string, sessionId: string): Promise { @@ -454,28 +518,6 @@ export class CodexCommands { return ` (resets ${timeStr} on ${dateStr})`; } - private formatThreadGoalSummary(goal: ThreadGoal): string { - const usage = goal.tokenBudget === null - ? `tokens used: ${goal.tokensUsed}` - : `tokens used: ${goal.tokensUsed} of ${goal.tokenBudget}`; - return [ - `Goal ${this.formatThreadGoalStatus(goal.status)}: ${goal.objective}`, - usage, - `time used: ${goal.timeUsedSeconds} seconds`, - ].join("\n"); - } - - private formatThreadGoalStatus(status: ThreadGoal["status"]): string { - switch (status) { - case "usageLimited": - return "usage-limited"; - case "budgetLimited": - return "budget-limited"; - default: - return status; - } - } - private formatTokenCount(count: number): string { if (count >= 1000000) { return `${(count / 1000000).toFixed(1)}M`; diff --git a/src/CodexElicitationHandler.ts b/src/CodexElicitationHandler.ts index 21515e56..b99bb51b 100644 --- a/src/CodexElicitationHandler.ts +++ b/src/CodexElicitationHandler.ts @@ -2,6 +2,7 @@ import * as acp from "@agentclientprotocol/sdk"; import type { SessionState } from "./CodexAcpServer"; import type { ElicitationHandler } from "./CodexAppServerClient"; import type { ServerNotification } from "./app-server"; +import type {JsonValue} from "./app-server/serde_json/JsonValue"; import type { ItemCompletedNotification, ItemStartedNotification, @@ -11,6 +12,10 @@ import type { import { logger } from "./Logger"; import { McpApprovalOptionId } from "./McpApprovalOptionId"; import type {AcpClientConnection} from "./ACPSessionConnection"; +import { + clientSupportsFormElicitation, + clientSupportsUrlElicitation, +} from "./ElicitationCapabilities"; // Standard elicitation options (non-tool-call approval). const ELICITATION_OPTIONS: acp.PermissionOption[] = [ @@ -19,6 +24,17 @@ const ELICITATION_OPTIONS: acp.PermissionOption[] = [ ]; type PersistValue = "session" | "always"; +type ToolApprovalPersistValue = PersistValue | "once"; + +type McpElicitationContext = { + isToolApproval: boolean; + persistOptions: Set; + correlatedCallId: string | undefined; +}; +type AcpBackedMcpElicitationParams = Extract< + McpServerElicitationRequestParams, + { mode: "form" } | { mode: "url" } +>; /** * Parses the `persist` field from the elicitation request `_meta`. @@ -48,6 +64,161 @@ function isMcpToolCallApproval(meta: unknown): boolean { ); } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function normalizeJsonValue(value: unknown): JsonValue { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + if (Array.isArray(value)) { + return value.map(normalizeJsonValue); + } + if (typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, nested]) => nested !== undefined) + .map(([key, nested]) => [key, normalizeJsonValue(nested)]) + ); + } + return String(value); +} + +function normalizeJsonObject(value: Record): Record { + return Object.fromEntries( + Object.entries(value) + .filter(([, nested]) => nested !== undefined) + .map(([key, nested]) => [key, normalizeJsonValue(nested)]) + ); +} + +function normalizeElicitationSchema(value: unknown): acp.ElicitationSchema { + const normalized = normalizeElicitationSchemaValue(value); + if (!isRecord(normalized)) { + return { type: "object", properties: {} }; + } + + return { + ...normalized, + type: "object", + } as acp.ElicitationSchema; +} + +function normalizeElicitationSchemaValue(value: unknown): unknown { + if (typeof value === "bigint") { + return Number(value); + } + if (Array.isArray(value)) { + return value.map(normalizeElicitationSchemaValue); + } + if (!isRecord(value)) { + return value; + } + + const result: Record = Object.fromEntries( + Object.entries(value) + .filter(([, nested]) => nested !== undefined) + .map(([key, nested]) => [key, normalizeElicitationSchemaValue(nested)]) + ); + + if ( + result["type"] === "string" && + Array.isArray(result["enum"]) && + Array.isArray(result["enumNames"]) && + !Array.isArray(result["oneOf"]) + ) { + const values = result["enum"]; + const names = result["enumNames"]; + result["oneOf"] = values.map((value, index) => ({ + const: String(value), + title: String(names[index] ?? value), + })); + delete result["enum"]; + delete result["enumNames"]; + } + + return result; +} + +function metaRecord(meta: unknown): Record | null { + return isRecord(meta) ? meta : null; +} + +function persistChoiceOption(value: ToolApprovalPersistValue): acp.EnumOption { + switch (value) { + case "once": + return { const: "once", title: "Allow once" }; + case "session": + return { const: "session", title: "Allow for this session" }; + case "always": + return { const: "always", title: "Allow and don't ask again" }; + } +} + +function addPersistChoiceToSchema( + schema: acp.ElicitationSchema, + persistOptions: Set +): acp.ElicitationSchema { + if (persistOptions.size === 0) { + return schema; + } + + const choices: ToolApprovalPersistValue[] = ["once"]; + if (persistOptions.has("session")) choices.push("session"); + if (persistOptions.has("always")) choices.push("always"); + + return { + ...schema, + properties: { + ...schema.properties, + persist: { + type: "string", + title: "Approval scope", + oneOf: choices.map(persistChoiceOption), + default: "once", + }, + }, + required: Array.from(new Set([...(schema.required ?? []), "persist"])), + }; +} + +function contentRecord(content: unknown): Record { + return isRecord(content) ? content as Record : {}; +} + +function jsonObjectOrNull( + content: Record +): JsonValue | null { + const entries = Object.entries(content); + if (entries.length === 0) { + return null; + } + return Object.fromEntries(entries.map(([key, value]) => [key, normalizeJsonValue(value)])); +} + +function elicitationResponseMeta( + response: acp.CreateElicitationResponse, + context: McpElicitationContext, + persist: unknown = undefined +): JsonValue | null { + const responseMeta = metaRecord(response._meta); + const meta = responseMeta ? normalizeJsonObject(responseMeta) : {}; + if (context.isToolApproval) { + delete meta["persist"]; + } + if (persist === "session" || persist === "always") { + meta["persist"] = persist; + } + return Object.keys(meta).length === 0 ? null : meta; +} + /** * Builds the ACP permission options for an MCP tool call approval elicitation. * Always includes "Allow Once"; adds session/always persist options when advertised. @@ -69,6 +240,8 @@ function buildToolApprovalOptions(persistOptions: Set): acp.Permis export class CodexElicitationHandler implements ElicitationHandler { private readonly connection: AcpClientConnection; private readonly sessionState: SessionState; + private readonly clientCapabilities: acp.ClientCapabilities | null; + private readonly cancellationSignal: AbortSignal | undefined; // In Rust, the MCP elicitation handler receives ElicitationRequestEvent directly from the MCP // protocol layer, where id is set to "mcp_tool_call_approval_" — the call ID is extracted // by stripping that prefix. @@ -85,13 +258,23 @@ export class CodexElicitationHandler implements ElicitationHandler { // call's elicitation before starting the next, so there is at most one pending approval per // (threadId, serverName). private readonly pendingMcpApprovals = new Map(); + // The app-server handler exposes URL elicitationId, while serverRequest/resolved only exposes + // threadId here, so accepted URL elicitations are completed at thread scope. + private readonly pendingUrlElicitations = new Map>(); - constructor(connection: AcpClientConnection, sessionState: SessionState) { + constructor( + connection: AcpClientConnection, + sessionState: SessionState, + clientCapabilities: acp.ClientCapabilities | null = null, + cancellationSignal?: AbortSignal + ) { this.connection = connection; this.sessionState = sessionState; + this.clientCapabilities = clientCapabilities; + this.cancellationSignal = cancellationSignal; } - handleNotification(notification: ServerNotification): void { + async handleNotification(notification: ServerNotification): Promise { switch (notification.method) { case "item/started": this.handleItemStarted(notification.params); @@ -101,6 +284,7 @@ export class CodexElicitationHandler implements ElicitationHandler { return; case "serverRequest/resolved": this.clearThread(notification.params.threadId); + await this.completeUrlElicitations(notification.params.threadId); return; default: return; @@ -111,8 +295,27 @@ export class CodexElicitationHandler implements ElicitationHandler { params: McpServerElicitationRequestParams ): Promise { try { - const { request, correlatedCallId } = this.buildPermissionRequest(params); - const response = await this.connection.request(acp.methods.client.session.requestPermission, request); + const context = this.createMcpElicitationContext(params); + if (this.shouldUseAcpElicitation(params)) { + const response = await this.connection.request( + acp.methods.client.elicitation.create, + this.buildElicitationRequest(params, context), + this.requestOptions(), + ); + const result = this.convertElicitationResponse(response, context); + if (params.mode === "url" && result.action === "accept") { + this.trackUrlElicitation(params.threadId, params.elicitationId); + } + await this.publishAcceptedMcpToolApproval(context, result.action === "accept"); + return result; + } + + const { request, correlatedCallId } = this.buildPermissionRequest(params, context); + const response = await this.connection.request( + acp.methods.client.session.requestPermission, + request, + this.requestOptions(), + ); if (correlatedCallId !== undefined && response.outcome.outcome !== "cancelled") { const optionId = response.outcome.optionId; if (optionId !== McpApprovalOptionId.Decline) { @@ -122,15 +325,77 @@ export class CodexElicitationHandler implements ElicitationHandler { }); } } - return this.convertResponse(response); + return this.convertPermissionResponse(response); } catch (error) { logger.error("Error handling MCP elicitation request", error); return { action: "cancel", content: null, _meta: null }; } } - private buildPermissionRequest( + private requestOptions(): acp.SendRequestOptions | undefined { + return this.cancellationSignal ? {cancellationSignal: this.cancellationSignal} : undefined; + } + + private createMcpElicitationContext(params: McpServerElicitationRequestParams): McpElicitationContext { + const isToolApproval = isMcpToolCallApproval(params._meta); + const persistOptions = parsePersistOptions(params._meta); + const correlatedCallId = isToolApproval && (params.mode === "form" || params.mode === "openai/form") + ? this.popPendingApproval(params.threadId, params.serverName) + : undefined; + return { isToolApproval, persistOptions, correlatedCallId }; + } + + private shouldUseAcpElicitation( params: McpServerElicitationRequestParams + ): params is AcpBackedMcpElicitationParams { + switch (params.mode) { + case "form": + return clientSupportsFormElicitation(this.clientCapabilities); + case "url": + return clientSupportsUrlElicitation(this.clientCapabilities); + case "openai/form": + return false; + } + } + + private buildElicitationRequest( + params: AcpBackedMcpElicitationParams, + context: McpElicitationContext + ): acp.CreateElicitationRequest { + const base = { + sessionId: this.sessionState.sessionId, + ...(context.correlatedCallId ? { toolCallId: context.correlatedCallId } : {}), + message: params.message, + _meta: metaRecord(params._meta), + }; + + switch (params.mode) { + case "form": { + const requestedSchema = context.isToolApproval + ? addPersistChoiceToSchema( + normalizeElicitationSchema(params.requestedSchema), + context.persistOptions, + ) + : normalizeElicitationSchema(params.requestedSchema); + return { + ...base, + mode: "form", + requestedSchema, + }; + } + case "url": + return { + ...base, + mode: "url", + url: params.url, + elicitationId: params.elicitationId, + }; + } + } + + private buildPermissionRequest( + params: McpServerElicitationRequestParams, + context: McpElicitationContext ): { request: acp.RequestPermissionRequest; correlatedCallId: string | undefined } { const sessionId = this.sessionState.sessionId; const messageContent: acp.ToolCallContent = { @@ -138,17 +403,12 @@ export class CodexElicitationHandler implements ElicitationHandler { content: { type: "text", text: params.message }, }; - const meta = params._meta; - const isToolApproval = isMcpToolCallApproval(meta); - const options = isToolApproval - ? buildToolApprovalOptions(parsePersistOptions(meta)) + const options = context.isToolApproval + ? buildToolApprovalOptions(context.persistOptions) : ELICITATION_OPTIONS; if (params.mode === "form" || params.mode === "openai/form") { - const correlatedCallId = isToolApproval - ? this.popPendingApproval(params.threadId, params.serverName) - : undefined; - if (correlatedCallId !== undefined) { + if (context.correlatedCallId !== undefined) { // The tool call item is already visible in the IDE conversation history because // item/started was emitted before the elicitation request. Sending content or // rawInput here would duplicate that information in the approval widget. @@ -156,7 +416,7 @@ export class CodexElicitationHandler implements ElicitationHandler { request: { sessionId, toolCall: { - toolCallId: correlatedCallId, + toolCallId: context.correlatedCallId, kind: "execute", status: "pending", // content: [messageContent], — omitted: already rendered via item/started @@ -165,7 +425,7 @@ export class CodexElicitationHandler implements ElicitationHandler { _meta: { is_mcp_tool_approval: true }, options, }, - correlatedCallId, + correlatedCallId: context.correlatedCallId, }; } return { @@ -173,12 +433,12 @@ export class CodexElicitationHandler implements ElicitationHandler { sessionId, toolCall: { toolCallId: `elicitation-${params.serverName}`, - kind: isToolApproval ? "execute" : "other", + kind: context.isToolApproval ? "execute" : "other", status: "pending", content: [messageContent], rawInput: { serverName: params.serverName, schema: params.requestedSchema }, }, - ...(isToolApproval ? { _meta: { is_mcp_tool_approval: true } } : {}), + ...(context.isToolApproval ? { _meta: { is_mcp_tool_approval: true } } : {}), options, }, correlatedCallId: undefined, @@ -201,7 +461,7 @@ export class CodexElicitationHandler implements ElicitationHandler { } } - private convertResponse( + private convertPermissionResponse( response: acp.RequestPermissionResponse ): McpServerElicitationRequestResponse { if (response.outcome.outcome === "cancelled") { @@ -221,6 +481,74 @@ export class CodexElicitationHandler implements ElicitationHandler { return { action: "decline", content: null, _meta: null }; } + private convertElicitationResponse( + response: acp.CreateElicitationResponse, + context: McpElicitationContext + ): McpServerElicitationRequestResponse { + if (acp.CreateElicitationResponse.isAccept(response)) { + const content = contentRecord(response.content); + const persist = context.isToolApproval ? content["persist"] : undefined; + if (persist === "session" || persist === "always" || persist === "once") { + delete content["persist"]; + } + return { + action: "accept", + content: jsonObjectOrNull(content), + _meta: elicitationResponseMeta(response, context, persist), + }; + } + + if (acp.CreateElicitationResponse.isDecline(response)) { + return { action: "decline", content: null, _meta: elicitationResponseMeta(response, context) }; + } + + if (acp.CreateElicitationResponse.isCancel(response)) { + return { action: "cancel", content: null, _meta: elicitationResponseMeta(response, context) }; + } + + if (acp.CreateElicitationResponse.isCustom(response)) { + return { action: "cancel", content: null, _meta: null }; + } + + // Malformed known variants match none of the SDK guards. + return { action: "cancel", content: null, _meta: null }; + } + + private async publishAcceptedMcpToolApproval( + context: McpElicitationContext, + accepted: boolean + ): Promise { + if (!accepted || context.correlatedCallId === undefined) { + return; + } + await this.connection.notify(acp.methods.client.session.update, { + sessionId: this.sessionState.sessionId, + update: { sessionUpdate: "tool_call_update", toolCallId: context.correlatedCallId, status: "in_progress" }, + }); + } + + private trackUrlElicitation(threadId: string, elicitationId: string): void { + const existing = this.pendingUrlElicitations.get(threadId); + if (existing) { + existing.add(elicitationId); + return; + } + this.pendingUrlElicitations.set(threadId, new Set([elicitationId])); + } + + private async completeUrlElicitations(threadId: string): Promise { + const elicitationIds = this.pendingUrlElicitations.get(threadId); + if (!elicitationIds) { + return; + } + this.pendingUrlElicitations.delete(threadId); + for (const elicitationId of elicitationIds) { + await this.connection.notify(acp.methods.client.elicitation.complete, { + elicitationId, + }); + } + } + private handleItemStarted(event: ItemStartedNotification): void { if (event.item.type !== "mcpToolCall") { return; diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 715fd51f..13594fb0 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -3,8 +3,7 @@ import type { FuzzyFileSearchSessionUpdatedNotification, ServerNotification } from "./app-server"; -import type {SessionState} from "./CodexAcpServer"; -import * as acp from "@agentclientprotocol/sdk"; +import type {SessionState, ThreadGoalSnapshot} from "./CodexAcpServer"; import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import { @@ -33,6 +32,8 @@ import type { ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification, TerminalInteractionNotification, + ThreadGoalClearedNotification, + ThreadGoalUpdatedNotification, ThreadTokenUsageUpdatedNotification, TurnCompletedNotification, TurnPlanUpdatedNotification, @@ -42,6 +43,8 @@ import type { McpStartupCompleteEvent } from "./app-server"; import {toTokenCount} from "./TokenCount"; import { commandExecutionUsesTerminalOutput, + createCollabAgentToolCallCompleteUpdate, + createCollabAgentToolCallUpdate, createCommandExecutionUpdate, createDynamicToolCallUpdate, createFileChangeUpdate, @@ -62,6 +65,11 @@ import { } from "./CodexToolCallMapper"; import { stripShellPrefix } from "./CommandUtils"; import {createTerminalOutputMeta, type TerminalOutputMode} from "./TerminalOutputMode"; +import { + createCodexMessagePhaseMeta, + createAgentTextMessageChunk, + createAgentTextThoughtChunk, +} from "./ContentChunks"; export { stripShellPrefix }; @@ -79,6 +87,7 @@ export class CodexEventHandler { private readonly terminalCommandOutputIds = new Set(); private proposedPlanMarkdown = ""; private proposedPlanTurnId: string | null = null; + private readonly agentMessagePhases = new Map(); constructor(connection: AcpClientConnection, sessionState: SessionState) { this.connection = connection; @@ -178,9 +187,9 @@ export class CodexEventHandler { case "fuzzyFileSearch/sessionCompleted": return this.handleFuzzyFileSearchSessionCompleted(notification.params); case "thread/goal/updated": - return null; + return this.createThreadGoalUpdatedEvent(notification.params); case "thread/goal/cleared": - return null; + return this.createThreadGoalClearedEvent(notification.params); case "item/commandExecution/terminalInteraction": return this.createTerminalInteractionEvent(notification.params); // ignored events @@ -240,12 +249,6 @@ export class CodexEventHandler { this.createSessionRateLimitsExtNotification(notification.params) ); return; - case "thread/goal/updated": - await this.connection.notify(notification.method, notification.params); - return; - case "thread/goal/cleared": - await this.connection.notify(notification.method, notification.params); - return; case "item/plan/delta": await this.emitCodexProposedPlanDelta(notification.params); return; @@ -342,66 +345,79 @@ export class CodexEventHandler { } private async createTextEvent(event: AgentMessageDeltaNotification): Promise { - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: event.delta - } - } + const phase = this.agentMessagePhases.get(event.itemId) ?? null; + return createAgentTextMessageChunk(event.delta, event.itemId, createCodexMessagePhaseMeta(phase)); } private async createConfigWarningEvent(event: ConfigWarningNotification): Promise { const detailsText = event.details ? `\n\n${event.details}` : ""; - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `Config warning: ${event.summary}${detailsText}\n\n` - } - } + return createAgentTextMessageChunk(`Config warning: ${event.summary}${detailsText}\n\n`); } private createWarningEvent(event: WarningNotification): UpdateSessionEvent { - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `Warning: ${event.message}\n\n` - } - }; + return createAgentTextMessageChunk(`Warning: ${event.message}\n\n`); } private createModelReroutedEvent(event: ModelReroutedNotification): UpdateSessionEvent { + return createAgentTextThoughtChunk(`Model rerouted from ${event.fromModel} to ${event.toModel} (${event.reason}).\n\n`); + } + + private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent | null { + const goalSnapshot = this.createThreadGoalSnapshot(event); + if (this.sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) { + return null; + } + this.sessionState.currentGoal = goalSnapshot; + + return this.createCodexSessionInfoUpdate({ + goal: goalSnapshot, + }); + } + + private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent | null { + if (this.sessionState.currentGoal === null) { + return null; + } + this.sessionState.currentGoal = null; + + return this.createCodexSessionInfoUpdate({ + goal: null, + }); + } + + private createThreadGoalSnapshot(event: ThreadGoalUpdatedNotification): ThreadGoalSnapshot { return { - sessionUpdate: "agent_thought_chunk", - content: { - type: "text", - text: `Model rerouted from ${event.fromModel} to ${event.toModel} (${event.reason}).\n\n` - } + objective: event.goal.objective.trim(), + status: event.goal.status, + tokenBudget: event.goal.tokenBudget, }; } + private sameThreadGoalSnapshot( + left: ThreadGoalSnapshot | null | undefined, + right: ThreadGoalSnapshot + ): boolean { + return left !== null + && left !== undefined + && left.objective === right.objective + && left.status === right.status + && left.tokenBudget === right.tokenBudget; + } + private createReasoningDeltaEvent( event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification ): UpdateSessionEvent { this.seenReasoningDeltaItemIds.add(event.itemId); - return this.createAgentThoughtEvent(event.delta); + return this.createAgentThoughtEvent(event.delta, event.itemId); } private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent { this.seenReasoningDeltaItemIds.add(event.itemId); - return this.createAgentThoughtEvent("\n\n"); + return this.createAgentThoughtEvent("\n\n", event.itemId); } - private createAgentThoughtEvent(text: string): UpdateSessionEvent { - return { - sessionUpdate: "agent_thought_chunk", - content: { - type: "text", - text, - } - }; + private createAgentThoughtEvent(text: string, messageId: string): UpdateSessionEvent { + return createAgentTextThoughtChunk(text, messageId); } private async createItemEvent(event: ItemStartedNotification): Promise { @@ -430,11 +446,14 @@ export class CodexEventHandler { this.activeImageGenerationItems.add(event.item.id); return createImageGenerationStartUpdate(event.item); case "collabAgentToolCall": + return createCollabAgentToolCallUpdate(event.item); + case "agentMessage": + this.rememberAgentMessagePhase(event.item); + return null; case "subAgentActivity": case "sleep": case "userMessage": case "hookPrompt": - case "agentMessage": case "reasoning": case "enteredReviewMode": case "exitedReviewMode": @@ -472,7 +491,7 @@ export class CodexEventHandler { if (this.activeImageGenerationItems.delete(event.item.id)) { return createImageGenerationCompleteUpdate(event.item); } - return createImageGenerationUpdate(event.item); + return createImageGenerationUpdate(event.item, { terminalStatus: true }); case "reasoning": if (this.seenReasoningDeltaItemIds.delete(event.item.id)) { return null; @@ -480,17 +499,20 @@ export class CodexEventHandler { return this.createCompletedReasoningEvent(event.item); case "webSearch": return createWebSearchCompleteUpdate(event.item); + case "collabAgentToolCall": + return createCollabAgentToolCallCompleteUpdate(event.item); + case "agentMessage": + this.rememberAgentMessagePhase(event.item); + return null; case "exitedReviewMode": return this.createExitedReviewModeEvent(event.item); case "contextCompaction": return this.createContextCompactedEvent(); //ignored types - case "collabAgentToolCall": case "subAgentActivity": case "sleep": case "userMessage": case "hookPrompt": - case "agentMessage": case "enteredReviewMode": case "plan": return null; @@ -498,13 +520,17 @@ export class CodexEventHandler { } } + private rememberAgentMessagePhase(item: ThreadItem & { type: "agentMessage" }): void { + this.agentMessagePhases.set(item.id, item.phase); + } + private createCompletedReasoningEvent(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent | null { const parts = item.summary.length > 0 ? item.summary : item.content; const text = parts.filter(part => part.length > 0).join("\n\n"); if (text.length === 0) { return null; } - return this.createAgentThoughtEvent(text); + return this.createAgentThoughtEvent(text, item.id); } private createExitedReviewModeEvent(item: ThreadItem & { type: "exitedReviewMode" }): UpdateSessionEvent | null { @@ -512,23 +538,11 @@ export class CodexEventHandler { if (text.length === 0) { return null; } - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text, - } - }; + return createAgentTextMessageChunk(text); } private createContextCompactedEvent(): UpdateSessionEvent { - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "*Context compacted to fit the model's context window.*\n\n" - } - }; + return createAgentTextMessageChunk("*Context compacted to fit the model's context window.*\n\n"); } private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { @@ -661,17 +675,21 @@ export class CodexEventHandler { } private async createErrorEvent(params: ErrorNotification): Promise { - const error = params.error.codexErrorInfo - if (error == "unauthorized" || error == "usageLimitExceeded" || this.getHttpStatusCode(error) == 401) { - this.failure = RequestError.authRequired(); - } - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `${params.error.message}\n\n` - } + const error = params.error.codexErrorInfo; + if (error === "usageLimitExceeded") { + this.failure = RequestError.internalError( + this.createTurnErrorData(params.error), + ); + } else if (this.isAuthenticationRequiredError(error)) { + this.failure = this.sessionState.authConfigured + ? RequestError.internalError(this.createTurnErrorData(params.error)) + : RequestError.authRequired(this.createTurnErrorData(params.error), params.error.message); } + return createAgentTextMessageChunk(`${params.error.message}\n\n`); + } + + private isAuthenticationRequiredError(error: CodexErrorInfo | null): boolean { + return error === "unauthorized" || this.getHttpStatusCode(error) === 401; } private getHttpStatusCode(error: CodexErrorInfo | null): number | null { @@ -689,6 +707,27 @@ export class CodexEventHandler { return null; } + private createTurnErrorData(error: ErrorNotification["error"]): { + message: string; + codexErrorInfo?: CodexErrorInfo; + additionalDetails?: string; + } { + const data: { + message: string; + codexErrorInfo?: CodexErrorInfo; + additionalDetails?: string; + } = { + message: error.additionalDetails ?? error.message, + }; + if (error.codexErrorInfo !== null) { + data.codexErrorInfo = error.codexErrorInfo; + } + if (error.additionalDetails !== null) { + data.additionalDetails = error.additionalDetails; + } + return data; + } + private handleTokenUsageUpdated(params: ThreadTokenUsageUpdatedNotification): void { this.sessionState.lastTokenUsage = toTokenCount(params.tokenUsage.last); this.sessionState.totalTokenUsage = toTokenCount(params.tokenUsage.total); diff --git a/src/CodexJsonRpcConnection.ts b/src/CodexJsonRpcConnection.ts index 343845ba..0f79117a 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -15,7 +15,7 @@ export interface CodexConnection { export function startCodexConnection(codexPath?: string, env?: NodeJS.ProcessEnv): CodexConnection { const spawnEnv = env ?? process.env; - let codex: ChildProcessWithoutNullStreams + let codex: ChildProcessWithoutNullStreams; if (codexPath) { codex = process.platform === 'win32' ? spawn(`"${codexPath}" app-server`, { shell: true, env: spawnEnv }) diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index c098bee6..3e2fd4e7 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -9,6 +9,7 @@ import type { FuzzyFileSearchSessionUpdatedNotification } from "./app-server"; import type { + CollabAgentToolCallStatus, CommandAction, CommandExecutionStatus, DynamicToolCallStatus, @@ -32,12 +33,13 @@ import { type TerminalOutputMode, } from "./TerminalOutputMode"; -type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus; +type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus | CollabAgentToolCallStatus; type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; type GuardianApprovalReviewNotification = | ItemGuardianApprovalReviewStartedNotification | ItemGuardianApprovalReviewCompletedNotification; type WebSearchItem = ThreadItem & { type: "webSearch" }; +type CollabAgentToolCallItem = ThreadItem & { type: "collabAgentToolCall" }; type CommandExecutionItem = ThreadItem & { type: "commandExecution" }; type AcpToolCallEvent = Extract; @@ -195,21 +197,24 @@ export function createImageGenerationCompleteUpdate( return { sessionUpdate: "tool_call_update", toolCallId: item.id, - status: imageGenerationToolStatus(item.status), + status: imageGenerationTerminalStatus(item.status), content: imageGenerationContent(item), rawOutput: imageGenerationRawOutput(item), }; } export function createImageGenerationUpdate( - item: ThreadItem & { type: "imageGeneration" } + item: ThreadItem & { type: "imageGeneration" }, + options?: { terminalStatus?: boolean }, ): UpdateSessionEvent { return { sessionUpdate: "tool_call", toolCallId: item.id, kind: "other", title: "Image generation", - status: imageGenerationToolStatus(item.status), + status: options?.terminalStatus + ? imageGenerationTerminalStatus(item.status) + : imageGenerationToolStatus(item.status), content: imageGenerationContent(item), rawOutput: imageGenerationRawOutput(item), }; @@ -356,6 +361,41 @@ export function createWebSearchCompleteUpdate( }; } +export function createCollabAgentToolCallUpdate( + item: CollabAgentToolCallItem +): UpdateSessionEvent { + return { + sessionUpdate: "tool_call", + toolCallId: item.id, + kind: "other", + title: item.tool, + status: toAcpStatus(item.status), + rawInput: createCollabAgentToolCallRawInput(item), + }; +} + +export function createCollabAgentToolCallCompleteUpdate( + item: CollabAgentToolCallItem +): UpdateSessionEvent { + return { + sessionUpdate: "tool_call_update", + toolCallId: item.id, + title: item.tool, + status: toAcpStatus(item.status), + rawInput: createCollabAgentToolCallRawInput(item), + }; +} + +function createCollabAgentToolCallRawInput(item: CollabAgentToolCallItem) { + return { + prompt: item.prompt, + senderThreadId: item.senderThreadId, + receiverThreadIds: item.receiverThreadIds, + agentsStates: item.agentsStates, + status: item.status, + }; +} + export function formatWebSearchTitle(item: WebSearchItem): string { const action = item.action; if (!action) { @@ -587,6 +627,20 @@ function imageGenerationToolStatus(status: string): AcpToolCallStatus { } } +function imageGenerationTerminalStatus(status: string): AcpToolCallStatus { + switch (status) { + case "failed": + return "failed"; + case "completed": + case "generating": + case "in_progress": + case "inProgress": + case "incomplete": + default: + return "completed"; + } +} + function imageGenerationContent( item: ThreadItem & { type: "imageGeneration" } ): ToolCallContent[] { diff --git a/src/ContentChunks.ts b/src/ContentChunks.ts new file mode 100644 index 00000000..2bef82e8 --- /dev/null +++ b/src/ContentChunks.ts @@ -0,0 +1,67 @@ +import type {ContentBlock} from "@agentclientprotocol/sdk"; +import type {UpdateSessionEvent} from "./ACPSessionConnection"; + +type AcpMeta = Record; + +export function createCodexMessagePhaseMeta(phase: string | null | undefined): AcpMeta | undefined { + if (!phase) { + return undefined; + } + return { codex: { phase } }; +} + +export function createUserMessageChunk(content: ContentBlock, messageId?: string, meta?: AcpMeta): UpdateSessionEvent { + if (messageId) { + return { + sessionUpdate: "user_message_chunk", + messageId, + content, + ...(meta ? { _meta: meta } : {}), + }; + } + return { + sessionUpdate: "user_message_chunk", + content, + ...(meta ? { _meta: meta } : {}), + }; +} + +export function createAgentMessageChunk(content: ContentBlock, messageId?: string, meta?: AcpMeta): UpdateSessionEvent { + if (messageId) { + return { + sessionUpdate: "agent_message_chunk", + messageId, + content, + ...(meta ? { _meta: meta } : {}), + }; + } + return { + sessionUpdate: "agent_message_chunk", + content, + ...(meta ? { _meta: meta } : {}), + }; +} + +export function createAgentThoughtChunk(content: ContentBlock, messageId?: string, meta?: AcpMeta): UpdateSessionEvent { + if (messageId) { + return { + sessionUpdate: "agent_thought_chunk", + messageId, + content, + ...(meta ? { _meta: meta } : {}), + }; + } + return { + sessionUpdate: "agent_thought_chunk", + content, + ...(meta ? { _meta: meta } : {}), + }; +} + +export function createAgentTextMessageChunk(text: string, messageId?: string, meta?: AcpMeta): UpdateSessionEvent { + return createAgentMessageChunk({type: "text", text}, messageId, meta); +} + +export function createAgentTextThoughtChunk(text: string, messageId?: string, meta?: AcpMeta): UpdateSessionEvent { + return createAgentThoughtChunk({type: "text", text}, messageId, meta); +} diff --git a/src/ElicitationCapabilities.ts b/src/ElicitationCapabilities.ts new file mode 100644 index 00000000..51030b0d --- /dev/null +++ b/src/ElicitationCapabilities.ts @@ -0,0 +1,13 @@ +import type * as acp from "@agentclientprotocol/sdk"; + +export function clientSupportsFormElicitation( + clientCapabilities?: acp.ClientCapabilities | null +): boolean { + return clientCapabilities?.elicitation?.form != null; +} + +export function clientSupportsUrlElicitation( + clientCapabilities?: acp.ClientCapabilities | null +): boolean { + return clientCapabilities?.elicitation?.url != null; +} diff --git a/src/FastModeConfig.ts b/src/FastModeConfig.ts index 4187aaa3..8e681c24 100644 --- a/src/FastModeConfig.ts +++ b/src/FastModeConfig.ts @@ -1,8 +1,10 @@ import type {SessionConfigOption} from "@agentclientprotocol/sdk"; +import type * as acp from "@agentclientprotocol/sdk"; import type {ServiceTier} from "./app-server"; import type {Model} from "./app-server/v2"; export const FAST_MODE_CONFIG_ID = "fast-mode"; +export const FAST_MODE_CATEGORY = "model_config"; export const FAST_MODE_ON = "on"; export const FAST_MODE_OFF = "off"; @@ -16,12 +18,27 @@ export function resolveFastServiceTier(fastModeEnabled: boolean, currentModelSup return fastModeEnabled && currentModelSupportsFast ? "fast" : null; } -export function createFastModeConfigOption(fastModeEnabled: boolean): SessionConfigOption { +export function clientSupportsBooleanConfigOptions(clientCapabilities?: acp.ClientCapabilities | null): boolean { + return clientCapabilities?.session?.configOptions?.boolean != null; +} + +export function createFastModeConfigOption(fastModeEnabled: boolean, useBooleanConfigOption = false): SessionConfigOption { + if (useBooleanConfigOption) { + return { + id: FAST_MODE_CONFIG_ID, + name: "Fast mode", + description: FAST_MODE_DESCRIPTION, + category: FAST_MODE_CATEGORY, + type: "boolean", + currentValue: fastModeEnabled, + }; + } + return { id: FAST_MODE_CONFIG_ID, name: "Fast mode", description: FAST_MODE_DESCRIPTION, - category: FAST_MODE_CONFIG_ID, + category: FAST_MODE_CATEGORY, type: "select", currentValue: fastModeEnabled ? FAST_MODE_ON : FAST_MODE_OFF, options: [ diff --git a/src/ModelConfigOption.ts b/src/ModelConfigOption.ts index 00bb5153..035b59fb 100644 --- a/src/ModelConfigOption.ts +++ b/src/ModelConfigOption.ts @@ -14,6 +14,19 @@ export function findSupportedEffort( } export function createModelConfigOption(availableModels: Array, currentBaseModelId: string): SessionConfigOption { + const options: Array<{ value: string; name: string; description: string | null }> = availableModels.map(model => ({ + value: model.id, + name: model.displayName, + description: model.description, + })); + if (!availableModels.some(model => model.id === currentBaseModelId)) { + options.unshift({ + value: currentBaseModelId, + name: currentBaseModelId, + description: null, + }); + } + return { id: MODEL_CONFIG_ID, name: "Model", @@ -21,11 +34,7 @@ export function createModelConfigOption(availableModels: Array, currentBa category: "model", type: "select", currentValue: currentBaseModelId, - options: availableModels.map(model => ({ - value: model.id, - name: model.displayName, - description: model.description, - })), + options, }; } diff --git a/src/ResponseItemHistoryFallback.ts b/src/ResponseItemHistoryFallback.ts index eb6f7dad..0730455f 100644 --- a/src/ResponseItemHistoryFallback.ts +++ b/src/ResponseItemHistoryFallback.ts @@ -6,6 +6,7 @@ import { stripShellPrefix } from "./CommandUtils"; import type { CommandAction, Thread, ThreadItem } from "./app-server/v2"; import { createCommandActionEvent } from "./CodexToolCallMapper"; import { createTerminalOutputMeta, type TerminalOutputMode } from "./TerminalOutputMode"; +import { createAgentMessageChunk, createCodexMessagePhaseMeta } from "./ContentChunks"; type JsonRecord = Record; type AcpToolCallEvent = Extract; @@ -27,7 +28,7 @@ function historyFallbackUpdateKey(update: UpdateSessionEvent): string | null { case "user_message_chunk": case "agent_message_chunk": case "agent_thought_chunk": - return `${update.sessionUpdate}:${JSON.stringify(update.content)}`; + return `${update.sessionUpdate}:${update.messageId ?? ""}:${JSON.stringify(update.content)}`; case "tool_call": return `tool_call:${update.toolCallId}:start`; case "tool_call_update": @@ -234,10 +235,10 @@ function createMessageUpdates(item: JsonRecord): UpdateSessionEvent[] { return []; } - return contentBlocksFromResponseContent(item["content"]).map((content) => ({ - sessionUpdate: "agent_message_chunk", - content, - })); + const phase = stringValue(item["phase"]); + return contentBlocksFromResponseContent(item["content"]).map((content) => ( + createAgentMessageChunk(content, undefined, createCodexMessagePhaseMeta(phase)) + )); } function createEventMsgUpdates(record: JsonRecord): UpdateSessionEvent[] | null { @@ -367,15 +368,16 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | return null; } + const isExecCommand = name === "exec_command"; const args = parseFunctionArguments(item["arguments"]); - const command = name === "exec_command" ? commandFromFunctionArguments(args) : null; - const cwd = name === "exec_command" ? cwdFromFunctionArguments(args) : ""; + const command = isExecCommand ? commandFromFunctionArguments(args) : null; + const cwd = isExecCommand ? cwdFromFunctionArguments(args) : ""; const commandAction = command ? inferCommandAction(command, cwd) : null; if (commandAction) { return { update: createCommandActionEvent(toolCallId, "inProgress", cwd, commandAction), usesTerminal: false, - isExecCommand: true, + isExecCommand, }; } @@ -389,13 +391,13 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | }; if (!functionCallUsesTerminal(item)) { - return { update, usesTerminal: false, isExecCommand: false }; + return { update, usesTerminal: false, isExecCommand }; } return { update: withTerminalContent(update, toolCallId, cwd), usesTerminal: true, - isExecCommand: true, + isExecCommand, }; } @@ -975,8 +977,7 @@ function sedFileArguments(args: string[]): string[] { } function looksLikeSedRangeScript(arg: string): boolean { - return /^(\d+|\$)?(,(\d+|\$))?[pd]$/.test(arg) - || /^(\d+|\$)?(,(\d+|\$))?p$/.test(arg); + return /^(\d+|\$)?(,(\d+|\$))?[pd]$/.test(arg); } function headTailFileArguments(args: string[]): string[] { diff --git a/src/StdUtils.ts b/src/StdUtils.ts index 82e20bb2..3d9e2159 100644 --- a/src/StdUtils.ts +++ b/src/StdUtils.ts @@ -1,8 +1,6 @@ import {Readable, Writable} from "node:stream"; -import {Disposable} from "vscode-jsonrpc"; -import type {PartialMessageInfo} from "vscode-jsonrpc/lib/common/messageReader"; -import {Emitter, Message, MessageReader, MessageWriter} from "vscode-jsonrpc/node"; -import type {DataCallback} from "vscode-jsonrpc/node"; +import {Emitter} from "vscode-jsonrpc/node"; +import type {DataCallback, Disposable, Message, MessageReader, MessageWriter, PartialMessageInfo} from "vscode-jsonrpc/node"; import * as acp from "@agentclientprotocol/sdk"; //TODO ask to include proper jsonrpc field and remove @@ -70,4 +68,4 @@ export function createJsonStream(readable: Readable, writable: Writable){ const input = Writable.toWeb(writable); const output = Readable.toWeb(readable) as ReadableStream; return acp.ndJsonStream(input, output); -} \ No newline at end of file +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 80385aca..2347c23f 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1,7 +1,7 @@ // noinspection ES6RedundantAwait -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import type {CodexAuthRequest} from "../../CodexAuthMethod"; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR, type CodexAuthRequest} from "../../CodexAuthMethod"; import type * as acp from "@agentclientprotocol/sdk"; import { createCodexMockTestFixture, @@ -26,6 +26,10 @@ describe('ACP server test', { timeout: 40_000 }, () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + const ignoredFields = ["thread", "cwd", "id", "createdAt", "path", "threadId", "userAgent", "sandbox", "conversationId", "origins", "supportedReasoningEfforts", "reasoningEffort", "model", "readOnlyAccess", "approvalsReviewer"]; it('should throw error without authentication', async () => { @@ -57,10 +61,10 @@ describe('ACP server test', { timeout: 40_000 }, () => { keyFixture.clearCodexConnectionDump(); - const authRequest: CodexAuthRequest = { methodId: "api-key", _meta: { "api-key": { apiKey: "TOKEN" }}} + const authRequest: CodexAuthRequest = { methodId: "api-key", _meta: { "api-key": { apiKey: "TOKEN" }}}; await codexAcpAgent.authenticate(authRequest); const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); - expect(newSessionResponse.sessionId).toBeDefined() + expect(newSessionResponse.sessionId).toBeDefined(); const transportEvents = keyFixture.getCodexConnectionEvents([...ignoredFields, "upgrade"]); const transportMethods = transportEvents.flatMap(event => "method" in event ? [event.method] : []); @@ -87,6 +91,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { "model/list", "thread/started", "account/read", + "skills/list", ]); expect(loginRequest).toEqual({ eventType: "request", @@ -114,6 +119,90 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(logoutResponse).toEqual({type: "unauthenticated"}); }); + it('should authenticate with CODEX_API_KEY from the environment', async () => { + const envFixture = createTestFixture(); + const codexAcpAgent = envFixture.getCodexAcpAgent(); + vi.stubEnv(CODEX_API_KEY_ENV_VAR, "CODEX_ENV_TOKEN"); + vi.stubEnv(OPENAI_API_KEY_ENV_VAR, "OPENAI_ENV_TOKEN"); + + await codexAcpAgent.initialize({protocolVersion: 1}); + await envFixture.getCodexAcpClient().logout(); + envFixture.clearCodexConnectionDump(); + + await codexAcpAgent.authenticate({methodId: "api-key"}); + + const transportEvents = envFixture.getCodexConnectionEvents([]); + const loginRequest = transportEvents.find(event => + event.eventType === "request" && + "method" in event && + event.method === "account/login/start" + ); + expect(loginRequest).toEqual({ + eventType: "request", + method: "account/login/start", + params: { + type: "apiKey", + apiKey: "CODEX_ENV_TOKEN", + } + }); + await expect(codexAcpAgent.extMethod("authentication/status", {})).resolves.toEqual({type: "api-key"}); + }); + + it('should fall back to OPENAI_API_KEY from the environment', async () => { + const envFixture = createTestFixture(); + const codexAcpAgent = envFixture.getCodexAcpAgent(); + vi.stubEnv(CODEX_API_KEY_ENV_VAR, ""); + vi.stubEnv(OPENAI_API_KEY_ENV_VAR, "OPENAI_ENV_TOKEN"); + + await codexAcpAgent.initialize({protocolVersion: 1}); + await envFixture.getCodexAcpClient().logout(); + envFixture.clearCodexConnectionDump(); + + await codexAcpAgent.authenticate({methodId: "api-key"}); + + const transportEvents = envFixture.getCodexConnectionEvents([]); + const loginRequest = transportEvents.find(event => + event.eventType === "request" && + "method" in event && + event.method === "account/login/start" + ); + expect(loginRequest).toEqual({ + eventType: "request", + method: "account/login/start", + params: { + type: "apiKey", + apiKey: "OPENAI_ENV_TOKEN", + } + }); + await expect(codexAcpAgent.extMethod("authentication/status", {})).resolves.toEqual({type: "api-key"}); + }); + + it('should report a clear error when the selected API key env var is missing', async () => { + const envFixture = createTestFixture(); + const codexAcpAgent = envFixture.getCodexAcpAgent(); + vi.stubEnv(CODEX_API_KEY_ENV_VAR, ""); + vi.stubEnv(OPENAI_API_KEY_ENV_VAR, ""); + + await expect(codexAcpAgent.authenticate({methodId: "api-key"})) + .rejects.toThrow(`${CODEX_API_KEY_ENV_VAR} or ${OPENAI_API_KEY_ENV_VAR} is not set`); + }); + + it('should not start ChatGPT login when already authenticated', async () => { + const chatGptFixture = createCodexMockTestFixture(); + const codexAppServerClient = chatGptFixture.getCodexAppServerClient(); + const accountReadSpy = vi.spyOn(codexAppServerClient, "accountRead").mockResolvedValue({ + account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, + requiresOpenaiAuth: false, + }); + const accountLoginSpy = vi.spyOn(codexAppServerClient, "accountLogin"); + + await expect(chatGptFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"})) + .resolves.toEqual({}); + + expect(accountReadSpy).toHaveBeenCalledWith({refreshToken: true}); + expect(accountLoginSpy).not.toHaveBeenCalled(); + }); + it('should authenticate with a gateway', async () => { const gatewayFixture = createTestFixture(); const codexAcpAgent = gatewayFixture.getCodexAcpAgent(); @@ -149,8 +238,8 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(authenticatedResponse).toEqual({type: "gateway", name: "custom-gateway"}); const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); - expect(newSessionResponse.sessionId).toBeDefined() - }) + expect(newSessionResponse.sessionId).toBeDefined(); + }); it('should show account in /status for api key auth and hide it for gateway auth', async () => { const authFixture = createTestFixture(); @@ -365,6 +454,104 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + it('uses configured model provider when resuming sessions without an explicit provider', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAppServerClient, "configRead").mockResolvedValue({ + config: { + model_provider: "azure", + }, + } as any); + const threadResumeSpy = vi.spyOn(codexAppServerClient, "threadResume").mockResolvedValue({ + thread: {id: "thread-id"} as any, + model: "gpt-5", + reasoningEffort: "medium", + serviceTier: null, + } as any); + vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + thread: {id: "thread-id"} as any, + }); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + await codexAcpClient.resumeSession({ + sessionId: "resume-id", + cwd: "/workspace", + }); + await codexAcpClient.loadSession({ + sessionId: "load-id", + cwd: "/workspace", + mcpServers: [], + }); + + expect(threadResumeSpy.mock.calls[0]![0].modelProvider).toBe("azure"); + expect(threadResumeSpy.mock.calls[1]![0].modelProvider).toBe("azure"); + }); + + it('tracks configured model provider auth state for resumed and loaded sessions', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({ + account: null, + requiresOpenaiAuth: true, + }); + vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAppServerClient, "configRead").mockResolvedValue({ + config: { + model_provider: "azure", + }, + } as any); + const threadResumeSpy = vi.spyOn(codexAppServerClient, "threadResume").mockResolvedValue({ + thread: {id: "thread-id"} as any, + model: "gpt-5", + modelProvider: "azure", + reasoningEffort: "medium", + serviceTier: null, + } as any); + vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + thread: {id: "thread-id", turns: []} as any, + }); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + await codexAcpAgent.resumeSession({ + sessionId: "resume-id", + cwd: "/workspace", + }); + await codexAcpAgent.loadSession({ + sessionId: "load-id", + cwd: "/workspace", + mcpServers: [], + }); + + expect(threadResumeSpy.mock.calls[0]![0].modelProvider).toBe("azure"); + expect(threadResumeSpy.mock.calls[1]![0].modelProvider).toBe("azure"); + expect(getAccountSpy).not.toHaveBeenCalled(); + expect(codexAcpAgent.getSessionState("resume-id")).toMatchObject({ + account: null, + authConfigured: true, + authProvider: "azure", + }); + expect(codexAcpAgent.getSessionState("load-id")).toMatchObject({ + account: null, + authConfigured: true, + authProvider: "azure", + }); + }); + it('rejects malformed ACP additional directories', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpClient = mockFixture.getCodexAcpClient(); @@ -621,7 +808,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { return onServerNotification; } - function createTurn(id: string, status: "inProgress" | "completed") { + function createTurn(id: string, status: "inProgress" | "completed" | "interrupted") { return { id, items: [], @@ -648,6 +835,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { await new Promise((resolve) => setTimeout(resolve, 0)); } + function deferred(): {promise: Promise, resolve: (value: T) => void} { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; + } + it('should map events from dump', async () => { fixture.getCodexAppServerClient().onServerNotification = loadNotifications(); @@ -852,6 +1047,150 @@ describe('ACP server test', { timeout: 40_000 }, () => { })).resolves.toMatchObject({stopReason: "end_turn"}); }); + it('cancels an active prompt when the ACP prompt request is cancelled', async () => { + const { mockFixture, sessionState } = setupPromptFixture(); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + const turnInterruptSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async ({threadId, turnId}) => { + turnCompleted.resolve({ + threadId, + turn: createTurn(turnId, "interrupted"), + }); + }); + const controller = new AbortController(); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "long running prompt" }], + }, controller.signal); + + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + controller.abort(); + + await vi.waitFor(() => { + expect(turnInterruptSpy).toHaveBeenCalledWith({ + threadId: "session-id", + turnId: "turn-id", + }); + }); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + }); + + it('returns success when a cancelled ACP prompt request completes before interruption wins', async () => { + const { mockFixture, sessionState } = setupPromptFixture(); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + const turnInterrupt = deferred(); + const turnInterruptSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "turnInterrupt") + .mockReturnValue(turnInterrupt.promise); + const controller = new AbortController(); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "long running prompt" }], + }, controller.signal); + + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + controller.abort(); + await vi.waitFor(() => { + expect(turnInterruptSpy).toHaveBeenCalledWith({ + threadId: "session-id", + turnId: "turn-id", + }); + }); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "turn-id", + itemId: "tail-item", + delta: "tail output", + }, + }); + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(mockFixture.getAcpConnectionDump([])).toContain("tail output"); + turnInterrupt.resolve(undefined); + }); + + it('interrupts a late-started turn after the ACP prompt request is cancelled', async () => { + const { mockFixture } = setupPromptFixture(); + const turnStart = deferred<{turn: ReturnType}>(); + const turnStartCalled = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockImplementation(async () => { + turnStartCalled.resolve(); + return await turnStart.promise; + }); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + const turnInterruptSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async ({threadId, turnId}) => { + turnCompleted.resolve({ + threadId, + turn: createTurn(turnId, "interrupted"), + }); + }); + const controller = new AbortController(); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "long running prompt" }], + }, controller.signal); + + await turnStartCalled.promise; + controller.abort(); + + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + expect(turnInterruptSpy).not.toHaveBeenCalled(); + + turnStart.resolve({turn: createTurn("late-turn-id", "inProgress")}); + await vi.waitFor(() => { + expect(turnInterruptSpy).toHaveBeenCalledWith({ + threadId: "session-id", + turnId: "late-turn-id", + }); + }); + }); + + it('returns cancelled when the ACP prompt request is cancelled during startup work', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture(); + const skillsRefresh = deferred<{data: []}>(); + const listSkillsSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "listSkills") + .mockReturnValue(skillsRefresh.promise); + const controller = new AbortController(); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "long running prompt" }], + }, controller.signal); + + await vi.waitFor(() => { + expect(listSkillsSpy).toHaveBeenCalled(); + }); + + controller.abort(); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + + skillsRefresh.resolve({data: []}); + await flushAsyncWork(); + expect(turnStartSpy).not.toHaveBeenCalled(); + }); + it('should send attachments as prompt items', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); @@ -909,25 +1248,51 @@ describe('ACP server test', { timeout: 40_000 }, () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); - const listSkillsSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "listSkills"); + vi.spyOn(mockFixture.getCodexAcpClient(), "listSkills").mockResolvedValue({data: []}); // @ts-expect-error - exercising private helper - await codexAcpAgent.availableCommands.publish("session-id"); + await codexAcpAgent.availableCommands.publish(createTestSessionState({ + sessionId: "session-id", + cwd: "/workspace", + })); + + expect(mockFixture.getCodexAcpClient().listSkills).toHaveBeenCalledWith({ + cwds: ["/workspace"], + }); - expect(listSkillsSpy).not.toHaveBeenCalled(); await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/available-commands-build-in.json"); }); - it('should not expose skills as available commands', async () => { + it('should return available commands from skills list', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); - const listSkillsSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "listSkills"); + vi.spyOn(mockFixture.getCodexAcpClient(), "listSkills").mockResolvedValue({ + data: [{ + cwd: "/workspace", + skills: [{ + name: "build", + description: "Build the project", + shortDescription: "Build", + path: "/workspace", + scope: "user", + enabled: true, + }], + errors: [], + }], + }); // @ts-expect-error - exercising private helper - await codexAcpAgent.availableCommands.publish("session-id"); + await codexAcpAgent.availableCommands.publish(createTestSessionState({ + sessionId: "session-id", + cwd: "/workspace", + additionalDirectories: ["/workspace/extra"], + })); + + expect(mockFixture.getCodexAcpClient().listSkills).toHaveBeenCalledWith({ + cwds: ["/workspace", "/workspace/extra"], + }); - expect(listSkillsSpy).not.toHaveBeenCalled(); await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/available-commands-skills.json"); }); @@ -942,70 +1307,6 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/command-status.json"); }); - it('handles goal slash command locally', async () => { - const { mockFixture, turnStartSpy } = setupPromptFixture(); - const getThreadGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "getThreadGoal") - .mockResolvedValueOnce({ goal: null }) - .mockResolvedValueOnce({ goal: createThreadGoal() }) - .mockResolvedValueOnce({ goal: null }) - .mockResolvedValueOnce({ goal: createThreadGoal({ status: "paused" }) }); - const setThreadGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "setThreadGoal") - .mockResolvedValue({ goal: createThreadGoal({ objective: "Ship it" }) }); - const clearThreadGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "clearThreadGoal") - .mockResolvedValue({ cleared: false }); - - await mockFixture.getCodexAcpAgent().prompt({ - sessionId: "session-id", - prompt: [{ type: "text", text: "/goal" }], - }); - expect(mockFixture.getAcpConnectionEvents([]).at(-1)!.args[0].update.content.text) - .toBe("Usage: /goal \nNo goal is currently set."); - - await mockFixture.getCodexAcpAgent().prompt({ - sessionId: "session-id", - prompt: [{ type: "text", text: "/goal" }], - }); - expect(mockFixture.getAcpConnectionEvents([]).at(-1)!.args[0].update.content.text) - .toBe("Goal active: Ship goal support\ntokens used: 42 of 1000\ntime used: 12 seconds"); - - await mockFixture.getCodexAcpAgent().prompt({ - sessionId: "session-id", - prompt: [{ type: "text", text: "/goal Ship it" }], - }); - expect(setThreadGoalSpy).toHaveBeenCalledWith({ - threadId: "session-id", - objective: "Ship it", - status: "active", - }); - - await mockFixture.getCodexAcpAgent().prompt({ - sessionId: "session-id", - prompt: [{ type: "text", text: "/goal pause" }], - }); - expect(mockFixture.getAcpConnectionEvents([]).at(-1)!.args[0].update.content.text) - .toBe("No goal is currently set. Use `/goal ` to create one."); - - await mockFixture.getCodexAcpAgent().prompt({ - sessionId: "session-id", - prompt: [{ type: "text", text: "/goal resume" }], - }); - expect(setThreadGoalSpy).toHaveBeenCalledWith({ - threadId: "session-id", - status: "active", - }); - - await mockFixture.getCodexAcpAgent().prompt({ - sessionId: "session-id", - prompt: [{ type: "text", text: "/goal clear" }], - }); - expect(clearThreadGoalSpy).toHaveBeenCalledWith({ threadId: "session-id" }); - expect(mockFixture.getAcpConnectionEvents([]).at(-1)!.args[0].update.content.text) - .toBe("No goal to clear. This thread does not currently have a goal."); - - expect(getThreadGoalSpy).toHaveBeenCalledWith({ threadId: "session-id" }); - expect(turnStartSpy).not.toHaveBeenCalled(); - }); - it('passes skill slash commands through to Codex', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture(); @@ -1105,52 +1406,1216 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(promptResolved).toBe(true); }); - it('returns cancelled when review slash command is interrupted', async () => { + it('interrupts a late-started review slash command after the ACP prompt request is cancelled', async () => { const { mockFixture } = setupPromptFixture(); - vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart") - .mockResolvedValue(createReviewStartResponse()); - vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") - .mockResolvedValue(createReviewCompletedNotification("interrupted")); + const reviewStart = deferred(); + const reviewStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart") + .mockReturnValue(reviewStart.promise); + const reviewCompleted = deferred(); + const awaitTurnCompletedSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(reviewCompleted.promise); + const turnInterruptSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async ({threadId, turnId}) => { + reviewCompleted.resolve({ + threadId, + turn: createTurn(turnId, "interrupted"), + }); + }); + const controller = new AbortController(); - const response = await mockFixture.getCodexAcpAgent().prompt({ + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ sessionId: "session-id", prompt: [{ type: "text", text: "/review" }], + }, controller.signal); + + await vi.waitFor(() => { + expect(reviewStartSpy).toHaveBeenCalled(); + }); + + controller.abort(); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + + reviewStart.resolve(createReviewStartResponse("review-thread-id", "review-turn-id")); + + await vi.waitFor(() => { + expect(turnInterruptSpy).toHaveBeenCalledWith({ + threadId: "review-thread-id", + turnId: "review-turn-id", + }); + }); + expect(awaitTurnCompletedSpy).toHaveBeenCalledWith("review-thread-id", "review-turn-id"); + }); + + it('returns cancelled when review slash command is interrupted', async () => { + const { mockFixture } = setupPromptFixture(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart") + .mockResolvedValue(createReviewStartResponse()); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockResolvedValue(createReviewCompletedNotification("interrupted")); + + const response = await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review" }], + }); + + expect(response.stopReason).toBe("cancelled"); + }); + + it('waits for compact slash command completion', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture(); + const compactStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadCompactStart") + .mockResolvedValue({}); + + let promptResolved = false; + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/compact" }], + }).then((response) => { + promptResolved = true; + return response; + }); + + await vi.waitFor(() => { + expect(compactStartSpy).toHaveBeenCalledWith({ threadId: "session-id" }); + }); + await Promise.resolve(); + expect(promptResolved).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/compacted", + params: { threadId: "session-id", turnId: "compact-turn-id" }, + }); + + await expect(promptPromise).resolves.toEqual(expect.objectContaining({ + stopReason: "end_turn", + })); + expect(promptResolved).toBe(true); + expect(turnStartSpy).not.toHaveBeenCalled(); + expect(mockFixture.getAcpConnectionDump([])).toContain("Context compacted"); + }); + + it('handles goal slash commands through Codex app server', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture(); + const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet") + .mockResolvedValue({ + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }); + const goalClearSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalClear") + .mockResolvedValue(undefined); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }); + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal pause" }], + }); + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal resume" }], + }); + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal clear" }], + }); + + expect(goalRunSpy).toHaveBeenNthCalledWith(1, { + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, expect.any(Function)); + expect(goalRunSpy).toHaveBeenNthCalledWith(2, { + threadId: "session-id", + status: "paused", + }); + expect(goalRunSpy).toHaveBeenNthCalledWith(3, { + threadId: "session-id", + status: "active", + }, expect.any(Function)); + expect(goalClearSpy).toHaveBeenCalledWith({ threadId: "session-id" }); + expect(turnStartSpy).not.toHaveBeenCalled(); + }); + + it('waits for goal slash command turn routing', async () => { + const { mockFixture } = setupPromptFixture(); + const goal = createThreadGoal(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet") + .mockResolvedValue({ goal }); + const awaitTurnCompletedSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted"); + + let promptResolved = false; + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }).then((response) => { + promptResolved = true; + return response; + }); + + await vi.waitFor(() => { + expect(mockFixture.getCodexAppServerClient().threadGoalSet).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + await Promise.resolve(); + expect(promptResolved).toBe(false); + expect(mockFixture.getCodexAppServerClient().awaitTurnCompleted).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + mockFixture.sendServerNotification({ + method: "thread/status/changed", + params: { + threadId: "session-id", + status: { + type: "active", + activeFlags: [], + }, + }, + }); + await Promise.resolve(); + expect(promptResolved).toBe(false); + expect(mockFixture.getCodexAppServerClient().awaitTurnCompleted).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "turn/started", + params: { + threadId: "session-id", + turn: createTurn("goal-turn-id", "inProgress"), + }, + }); + await Promise.resolve(); + expect(promptResolved).toBe(false); + expect(mockFixture.getCodexAppServerClient().awaitTurnCompleted).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "I", + }, + }); + await Promise.resolve(); + expect(promptResolved).toBe(false); + + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + + await vi.waitFor(() => { + expect(promptResolved).toBe(true); + }); + await expect(promptPromise).resolves.toEqual(expect.objectContaining({ + stopReason: "end_turn", + })); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + }); + + it('does not complete no-turn goal slash command before the goal update and runtime grace are handled', async () => { + const { mockFixture } = setupPromptFixture(); + const goal = createThreadGoal({updatedAt: 1710000100}); + const threadGoalSetSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet") + .mockResolvedValue({ goal }); + let promptResolved = false; + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }).then((response) => { + promptResolved = true; + return response; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + await flushAsyncWork(); + expect(promptResolved).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + + await flushAsyncWork(); + expect(promptResolved).toBe(false); + + await expect(promptPromise).resolves.toEqual(expect.objectContaining({ + stopReason: "end_turn", + })); + expect(mockFixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ + args: [expect.objectContaining({ + update: { + sessionUpdate: "session_info_update", + _meta: { + codex: { + goal: { + objective: "Ship the migration and keep tests green", + status: "active", + tokenBudget: null, + }, + }, + }, + }, + })], + })); + }); + + it('completes goal slash command when a turn routes after the goal update', async () => { + const { mockFixture } = setupPromptFixture(); + const goal = createThreadGoal({updatedAt: 1710000150}); + const threadGoalSetSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet") + .mockResolvedValue({ goal }); + const awaitTurnCompletedSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockResolvedValue({ + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }); + let promptResolved = false; + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }).then((response) => { + promptResolved = true; + return response; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + + await flushAsyncWork(); + expect(promptResolved).toBe(false); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "late goal output", + }, + }); + await flushAsyncWork(); + expect(promptResolved).toBe(false); + + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + + await expect(promptPromise).resolves.toEqual(expect.objectContaining({ + stopReason: "end_turn", + })); + expect(promptResolved).toBe(true); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + }); + + it('waits for goal turn completion after the goal completes before streamed output finishes', async () => { + const { mockFixture } = setupPromptFixture(); + const goal = createThreadGoal({updatedAt: 1710000160}); + const completedGoal = createThreadGoal({ + status: "complete", + updatedAt: 1710000170, + tokensUsed: 42, + timeUsedSeconds: 8, + }); + vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet") + .mockResolvedValue({ goal }); + let promptResolved = false; + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal tell me a joke" }], + }).then((response) => { + promptResolved = true; + return response; + }); + + await vi.waitFor(() => { + expect(mockFixture.getCodexAppServerClient().threadGoalSet).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "tell me a joke", + status: "active", + }); + }); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + mockFixture.sendServerNotification({ + method: "thread/status/changed", + params: { + threadId: "session-id", + status: { + type: "active", + activeFlags: [], + }, + }, + }); + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + goal: completedGoal, + }, + }); + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "Why", + }, + }); + await flushAsyncWork(); + expect(promptResolved).toBe(false); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: " did the test wait?", + }, + }); + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + + await expect(promptPromise).resolves.toEqual(expect.objectContaining({ + stopReason: "end_turn", + })); + expect(promptResolved).toBe(true); + }); + + it('does not start the no-turn grace period before the goal update is handled', async () => { + vi.useFakeTimers(); + try { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({updatedAt: 1710000200}); + const threadGoalSet = deferred<{goal: ThreadGoal}>(); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockReturnValue(threadGoalSet.promise); + const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted") + .mockResolvedValue({ + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, undefined, undefined).finally(() => { + resultSettled = true; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + threadGoalSet.resolve({goal}); + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + expect(resultSettled).toBe(false); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + await Promise.resolve(); + expect(resultSettled).toBe(false); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "late goal output", + }, + }); + + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + await vi.advanceTimersByTimeAsync(0); + await expect(resultPromise).resolves.toMatchObject({ + threadId: "session-id", + turn: { + id: "goal-turn-id", + status: "completed", + }, + }); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('completes goal set when a turn routes after the goal update', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({updatedAt: 1710000200}); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({goal}); + const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted") + .mockResolvedValue({ + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, undefined, undefined).finally(() => { + resultSettled = true; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + await flushAsyncWork(); + expect(resultSettled).toBe(false); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "late goal output", + }, + }); + + await flushAsyncWork(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + await expect(resultPromise).resolves.toMatchObject({ + threadId: "session-id", + turn: { + id: "goal-turn-id", + status: "completed", + }, + }); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + }); + + it('keeps goal set pending while the thread is active before a turn routes', async () => { + vi.useFakeTimers(); + try { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({updatedAt: 1710000225}); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({goal}); + const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted") + .mockResolvedValue({ + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, undefined, undefined).finally(() => { + resultSettled = true; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + mockFixture.sendServerNotification({ + method: "thread/status/changed", + params: { + threadId: "session-id", + status: { + type: "active", + activeFlags: [], + }, + }, + }); + + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + expect(resultSettled).toBe(false); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "late goal output", + }, + }); + + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + await vi.advanceTimersByTimeAsync(0); + await expect(resultPromise).resolves.toMatchObject({ + threadId: "session-id", + turn: { + id: "goal-turn-id", + status: "completed", + }, + }); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps goal set pending after turn start until turn completion routes', async () => { + vi.useFakeTimers(); + try { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({updatedAt: 1710000235}); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({goal}); + const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted") + .mockResolvedValue({ + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }); + const onTurnStarted = vi.fn(); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, onTurnStarted).finally(() => { + resultSettled = true; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + mockFixture.sendServerNotification({ + method: "turn/started", + params: { + threadId: "session-id", + turn: createTurn("goal-turn-id", "inProgress"), + }, + }); + + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + expect(onTurnStarted).toHaveBeenCalledWith("goal-turn-id"); + expect(resultSettled).toBe(false); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "late goal output", + }, + }); + + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + await vi.advanceTimersByTimeAsync(0); + await expect(resultPromise).resolves.toMatchObject({ + threadId: "session-id", + turn: { + id: "goal-turn-id", + status: "completed", + }, + }); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('completes goal set when active thread returns idle without routing a turn', async () => { + vi.useFakeTimers(); + try { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({updatedAt: 1710000250}); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({goal}); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, undefined, undefined).finally(() => { + resultSettled = true; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + mockFixture.sendServerNotification({ + method: "thread/status/changed", + params: { + threadId: "session-id", + status: { + type: "active", + activeFlags: [], + }, + }, + }); + + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/status/changed", + params: { + threadId: "session-id", + status: { type: "idle" }, + }, + }); + + await expect(resultPromise).resolves.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('waits for paused goal update before completing goal status set', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({status: "paused", updatedAt: 1710000260}); + const threadGoalSet = deferred<{goal: ThreadGoal}>(); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockReturnValue(threadGoalSet.promise); + const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted"); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + status: "paused", + }).finally(() => { + resultSettled = true; }); - expect(response.stopReason).toBe("cancelled"); + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + status: "paused", + }); + }); + + threadGoalSet.resolve({goal}); + await flushAsyncWork(); + expect(resultSettled).toBe(false); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + + await expect(resultPromise).resolves.toBeNull(); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); }); - it('waits for compact slash command completion', async () => { + it('waits for goal cleared notification before completing goal clear', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const threadGoalClear = deferred<{cleared: boolean}>(); + const threadGoalClearSpy = vi.spyOn(codexAppServerClient, "threadGoalClear") + .mockReturnValue(threadGoalClear.promise); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalClear({ + threadId: "session-id", + }).finally(() => { + resultSettled = true; + }); + + await vi.waitFor(() => { + expect(threadGoalClearSpy).toHaveBeenCalledWith({ + threadId: "session-id", + }); + }); + + threadGoalClear.resolve({cleared: true}); + await flushAsyncWork(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/goal/cleared", + params: { + threadId: "session-id", + }, + }); + + await expect(resultPromise).resolves.toBeUndefined(); + }); + + it('interrupts a late-started goal slash command after the ACP prompt request is cancelled', async () => { + const { mockFixture } = setupPromptFixture(); + const goalCompleted = deferred(); + let startGoalTurn: () => void = () => {}; + const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet") + .mockImplementation((_params, onTurnStarted) => { + startGoalTurn = () => onTurnStarted?.("goal-turn-id"); + return goalCompleted.promise; + }); + const turnInterruptSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async ({threadId, turnId}) => { + goalCompleted.resolve({ + threadId, + turn: createTurn(turnId, "interrupted"), + }); + }); + const controller = new AbortController(); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }, controller.signal); + + await vi.waitFor(() => { + expect(goalRunSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, expect.any(Function)); + }); + + controller.abort(); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + expect(turnInterruptSpy).not.toHaveBeenCalled(); + + startGoalTurn(); + + await vi.waitFor(() => { + expect(turnInterruptSpy).toHaveBeenCalledWith({ + threadId: "session-id", + turnId: "goal-turn-id", + }); + }); + }); + + it('interrupts a goal slash command when ACP cancel arrives before the first routed turn', async () => { + const { mockFixture, sessionState } = setupPromptFixture(); + // @ts-expect-error - registering local session state for the ACP cancel path + mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal(); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({ goal }); + const turnInterruptSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "turnInterrupt") + .mockImplementation(async ({threadId, turnId}) => { + mockFixture.sendServerNotification({ + method: "turn/completed", + params: { + threadId, + turn: createTurn(turnId, "interrupted"), + }, + }); + }); + let cancelResolved = false; + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + const cancelPromise = mockFixture.getCodexAcpAgent().cancel({sessionId: "session-id"}) + .then(() => { + cancelResolved = true; + }); + await flushAsyncWork(); + expect(cancelResolved).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "goal output", + }, + }); + + await vi.waitFor(() => { + expect(turnInterruptSpy).toHaveBeenCalledWith({ + threadId: "session-id", + turnId: "goal-turn-id", + }); + }); + await expect(cancelPromise).resolves.toBeUndefined(); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + }); + + it('suppresses the first routed goal notification after cancellation marks the turn stale', async () => { + const { mockFixture } = setupPromptFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal(); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({ goal }); + const turnInterruptSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "turnInterrupt") + .mockResolvedValue(undefined); + const controller = new AbortController(); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }, controller.signal); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + + controller.abort(); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + mockFixture.clearAcpConnectionDump(); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "leaked goal output", + }, + }); + + await vi.waitFor(() => { + expect(turnInterruptSpy).toHaveBeenCalledWith({ + threadId: "session-id", + turnId: "goal-turn-id", + }); + }); + await flushAsyncWork(); + expect(mockFixture.getAcpConnectionDump([])).not.toContain("leaked goal output"); + }); + + it('does not hang when goal set starts no continuation turn', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({updatedAt: 1710000300}); + const threadGoalSetSpy = vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({ goal }); + const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted"); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, undefined, 0).finally(() => { + resultSettled = true; + }); + + await vi.waitFor(() => { + expect(threadGoalSetSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }); + }); + await flushAsyncWork(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + + await expect(resultPromise).resolves.toBeNull(); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + }); + + it('keeps goal set pending after elapsed startup time until a turn is routed', async () => { + vi.useFakeTimers(); + try { + const mockFixture = createCodexMockTestFixture(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const goal = createThreadGoal({updatedAt: 1710000400}); + vi.spyOn(codexAppServerClient, "threadGoalSet") + .mockResolvedValue({ goal }); + const awaitTurnCompletedSpy = vi.spyOn(codexAppServerClient, "awaitTurnCompleted") + .mockResolvedValue({ + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }); + let resultSettled = false; + + const resultPromise = codexAppServerClient.runGoalSet({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, undefined, undefined).finally(() => { + resultSettled = true; + }); + + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/goal/updated", + params: { + threadId: "session-id", + turnId: null, + goal, + }, + }); + await Promise.resolve(); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification({ + method: "item/agentMessage/delta", + params: { + threadId: "session-id", + turnId: "goal-turn-id", + itemId: "goal-message-id", + delta: "late goal output", + }, + }); + + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(resultSettled).toBe(false); + + mockFixture.sendServerNotification(createTurnCompletedNotification("session-id", "goal-turn-id")); + await vi.advanceTimersByTimeAsync(0); + await expect(resultPromise).resolves.toMatchObject({ + threadId: "session-id", + turn: { + id: "goal-turn-id", + status: "completed", + }, + }); + expect(awaitTurnCompletedSpy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('completes goal slash command when app server starts no continuation turn', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture(); + const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet") + .mockResolvedValue(null); + + const response = await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(goalRunSpy).toHaveBeenCalledWith({ + threadId: "session-id", + objective: "Ship the migration and keep tests green", + status: "active", + }, expect.any(Function)); + expect(turnStartSpy).not.toHaveBeenCalled(); + }); + + it('reports missing goal slash command input', async () => { + const { mockFixture } = setupPromptFixture(); + const goalSetSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet") + .mockResolvedValue({ goal: createThreadGoal() }); + const goalClearSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalClear") + .mockResolvedValue({ cleared: true }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/goal" }], + }); + + expect(goalSetSpy).not.toHaveBeenCalled(); + expect(goalClearSpy).not.toHaveBeenCalled(); + const [event] = mockFixture.getAcpConnectionEvents([]); + expect(event).toBeDefined(); + expect(event!.args[0].update.content.text).toBe( + 'Command "/goal" requires [|clear|pause|resume].' + ); + }); + it('returns cancelled promptly when non-interruptible slash command startup is cancelled', async () => { + const { mockFixture } = setupPromptFixture(); const compactStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadCompactStart") .mockResolvedValue({}); + const controller = new AbortController(); - let promptResolved = false; const promptPromise = mockFixture.getCodexAcpAgent().prompt({ sessionId: "session-id", prompt: [{ type: "text", text: "/compact" }], - }).then((response) => { - promptResolved = true; - return response; - }); + }, controller.signal); await vi.waitFor(() => { expect(compactStartSpy).toHaveBeenCalledWith({ threadId: "session-id" }); }); - await Promise.resolve(); - expect(promptResolved).toBe(false); + + controller.abort(); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); mockFixture.sendServerNotification({ method: "thread/compacted", params: { threadId: "session-id", turnId: "compact-turn-id" }, }); - - await expect(promptPromise).resolves.toEqual(expect.objectContaining({ - stopReason: "end_turn", - })); - expect(promptResolved).toBe(true); - expect(turnStartSpy).not.toHaveBeenCalled(); - expect(mockFixture.getAcpConnectionDump([])).toContain("Context compacted"); + await flushAsyncWork(); }); it('reports missing review slash command input', async () => { @@ -1183,6 +2648,176 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(fixture.getAcpConnectionDump(["sessionId"])).toMatchFileSnapshot("data/command-logout.json"); }); + it('clears active session auth state when logout command signs out', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const model = createTestModel(); + const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getModelProvider").mockReturnValue("openai"); + const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") + .mockResolvedValueOnce({ + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }) + .mockResolvedValueOnce({ + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }) + .mockResolvedValueOnce({ + account: null, + requiresOpenaiAuth: true, + }) + .mockResolvedValueOnce({ + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }); + vi.spyOn(codexAcpClient, "newSession") + .mockResolvedValueOnce({ + sessionId: "session-1", + currentModelId, + models: [model], + additionalDirectories: [], + }) + .mockResolvedValueOnce({ + sessionId: "session-2", + currentModelId, + models: [model], + additionalDirectories: [], + }); + const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); + const authenticateSpy = vi.spyOn(codexAcpClient, "authenticate").mockResolvedValue(true); + + const session1 = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + const session2 = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + expect(codexAcpAgent.getSessionState(session1.sessionId).authConfigured).toBe(true); + expect(codexAcpAgent.getSessionState(session2.sessionId).authConfigured).toBe(true); + + await codexAcpAgent.prompt({ + sessionId: session1.sessionId, + prompt: [{ type: "text", text: "/logout" }], + }); + + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(getAccountSpy).toHaveBeenCalledTimes(3); + expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({ + account: null, + authConfigured: false, + }); + expect(codexAcpAgent.getSessionState(session2.sessionId)).toMatchObject({ + account: null, + authConfigured: false, + }); + + await codexAcpAgent.authenticate({methodId: "api-key"}); + + expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"}); + expect(getAccountSpy).toHaveBeenCalledTimes(4); + expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({ + account: { type: "apiKey" }, + authConfigured: true, + }); + expect(codexAcpAgent.getSessionState(session2.sessionId)).toMatchObject({ + account: { type: "apiKey" }, + authConfigured: true, + }); + }); + + it('does not overwrite OpenAI session auth state when gateway auth succeeds', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const model = createTestModel(); + const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getModelProvider").mockReturnValue(null); + const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") + .mockResolvedValue({ + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "openai-session", + currentModelId, + models: [model], + modelProvider: "openai", + additionalDirectories: [], + }); + const authenticateSpy = vi.spyOn(codexAcpClient, "authenticate").mockResolvedValue(true); + + const session = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ + account: { type: "apiKey" }, + authConfigured: true, + authProvider: "openai", + }); + + const gatewayAuthRequest: CodexAuthRequest = { + methodId: "gateway", + _meta: { + "gateway": { + baseUrl: "https://www.example.com", + headers: { + "Custom-Auth-Header": "TOKEN", + }, + }, + }, + }; + await codexAcpAgent.authenticate(gatewayAuthRequest); + + expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest); + expect(getAccountSpy).toHaveBeenCalledTimes(1); + expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ + account: { type: "apiKey" }, + authConfigured: true, + authProvider: "openai", + }); + }); + + it('keeps custom provider sessions auth configured without account state', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const model = createTestModel(); + const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getModelProvider").mockReturnValue("custom-provider"); + const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") + .mockResolvedValue({ + account: null, + requiresOpenaiAuth: true, + }); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "custom-provider-session", + currentModelId, + models: [model], + additionalDirectories: [], + }); + const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); + + const session = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ + account: null, + authConfigured: true, + }); + + await codexAcpAgent.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "/logout" }], + }); + + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(getAccountSpy).not.toHaveBeenCalled(); + expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ + account: null, + authConfigured: true, + }); + }); + it('handles skills command', async () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); @@ -1320,6 +2955,16 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(result).toEqual(ModelId.create('5.2-codex', 'medium')); }); + it('should keep a model id that is not in the advertised catalog (custom provider)', () => { + const result = fixture.getCodexAcpClient().createModelId(mockModels, 'MiniMax-M3', 'high'); + expect(result).toEqual(ModelId.create('MiniMax-M3', 'high')); + }); + + it('should default the effort for an uncatalogued model when reasoningEffort is null', () => { + const result = fixture.getCodexAcpClient().createModelId(mockModels, 'MiniMax-M3', null); + expect(result).toEqual(ModelId.create('MiniMax-M3', 'medium')); + }); + /** * Sets up a mock fixture with turnStart/awaitTurnCompleted spied on, * and a given session state. Returns the fixture and turnStart spy. @@ -1356,11 +3001,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { return { mockFixture, sessionState, turnStartSpy }; } - function createReviewStartResponse(): ReviewStartResponse { + function createReviewStartResponse( + reviewThreadId: string = "session-id", + turnId: string = "review-turn-id", + ): ReviewStartResponse { return { - reviewThreadId: "session-id", + reviewThreadId, turn: { - id: "review-turn-id", + id: turnId, items: [], itemsView: "notLoaded", status: "inProgress", @@ -1391,13 +3039,13 @@ describe('ACP server test', { timeout: 40_000 }, () => { function createThreadGoal(overrides?: Partial): ThreadGoal { return { threadId: "session-id", - objective: "Ship goal support", + objective: "Ship the migration and keep tests green", status: "active", - tokenBudget: 1000, - tokensUsed: 42, - timeUsedSeconds: 12, + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, createdAt: 1710000000, - updatedAt: 1710000012, + updatedAt: 1710000000, ...overrides, }; } diff --git a/src/__tests__/CodexACPAgent/agent-message-events.test.ts b/src/__tests__/CodexACPAgent/agent-message-events.test.ts new file mode 100644 index 00000000..e4147d5f --- /dev/null +++ b/src/__tests__/CodexACPAgent/agent-message-events.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServerNotification } from "../../app-server"; +import type { SessionState } from "../../CodexAcpServer"; +import { AgentMode } from "../../AgentMode"; +import { + createCodexMockTestFixture, + createTestSessionState, + setupPromptAndSendNotifications, + type CodexMockTestFixture +} from "../acp-test-utils"; + +describe("CodexEventHandler - agent message events", () => { + let mockFixture: CodexMockTestFixture; + const sessionId = "test-session-id"; + + beforeEach(() => { + mockFixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + const sessionState: SessionState = createTestSessionState({ + sessionId, + currentModelId: "model-id[effort]", + agentMode: AgentMode.DEFAULT_AGENT_MODE + }); + + it("includes Codex message phase metadata on streamed agent message chunks", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "agentMessage", + id: "commentary-message", + text: "", + phase: "commentary", + memoryCitation: null, + }, + }, + }, + { + method: "item/agentMessage/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "commentary-message", + delta: "Checking the relevant event mapping.", + }, + }, + { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 10, + item: { + type: "agentMessage", + id: "final-message", + text: "", + phase: "final_answer", + memoryCitation: null, + }, + }, + }, + { + method: "item/agentMessage/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "final-message", + delta: "Yes, here is the answer.", + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/agent-message-phases.json" + ); + }); +}); diff --git a/src/__tests__/CodexACPAgent/auth-error-events.test.ts b/src/__tests__/CodexACPAgent/auth-error-events.test.ts new file mode 100644 index 00000000..f4917991 --- /dev/null +++ b/src/__tests__/CodexACPAgent/auth-error-events.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ErrorNotification, TurnCompletedNotification } from "../../app-server/v2"; +import type { SessionState } from "../../CodexAcpServer"; +import { + createCodexMockTestFixture, + createTestSessionState, +} from "../acp-test-utils"; + +const configuredAuthFailureCases: Array<{ + name: string; + turnError: ErrorNotification["error"]; + sessionOverrides?: Partial; + expectedData: unknown; +}> = [ + { + name: "rejected API key", + sessionOverrides: { + account: null, + authConfigured: true, + }, + turnError: { + message: "API key was rejected", + codexErrorInfo: "unauthorized", + additionalDetails: null, + }, + expectedData: { + message: "API key was rejected", + codexErrorInfo: "unauthorized", + }, + }, + { + name: "usage limit exceeded", + turnError: { + message: "Usage limits were exceeded", + codexErrorInfo: "usageLimitExceeded", + additionalDetails: null, + }, + expectedData: { + message: "Usage limits were exceeded", + codexErrorInfo: "usageLimitExceeded", + }, + }, + { + name: "HTTP 401", + turnError: { + message: "Provider returned 401", + codexErrorInfo: { + responseStreamDisconnected: { + httpStatusCode: 401, + }, + }, + additionalDetails: "HTTP status 401", + }, + expectedData: { + message: "HTTP status 401", + codexErrorInfo: { + responseStreamDisconnected: { + httpStatusCode: 401, + }, + }, + additionalDetails: "HTTP status 401", + }, + }, +]; + +describe("CodexEventHandler - auth error events", () => { + it("returns AuthRequired for auth errors when no auth is configured", async () => { + const error = await runPromptWithError(createTestSessionState({ + sessionId: "unauthenticated-session", + account: null, + authConfigured: false, + }), { + message: "Authentication is required", + codexErrorInfo: "unauthorized", + additionalDetails: null, + }); + + expect(error).toMatchObject({ + code: -32000, + message: "Authentication required: Authentication is required", + data: { + message: "Authentication is required", + codexErrorInfo: "unauthorized", + }, + }); + }); + + it.each(configuredAuthFailureCases)( + "returns InternalError with details for $name when auth is configured", + async ({turnError, sessionOverrides, expectedData}) => { + const error = await runPromptWithError(createTestSessionState({ + sessionId: "authenticated-session", + account: { type: "apiKey" }, + ...sessionOverrides, + }), turnError); + + expect(error).toMatchObject({ + code: -32603, + message: "Internal error", + data: expectedData, + }); + expect(error).not.toMatchObject({ + code: -32000, + }); + }, + ); +}); + +async function runPromptWithError( + sessionState: SessionState, + turnError: ErrorNotification["error"], +): Promise { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const turnCompleted = deferred(); + const turnStartSpy = vi.spyOn(codexAppServerClient, "turnStart").mockResolvedValue({ + turn: createTurn("inProgress"), + }); + vi.spyOn(codexAppServerClient, "awaitTurnCompleted").mockReturnValue(turnCompleted.promise); + vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); + + const promptPromise = codexAcpAgent.prompt({ + sessionId: sessionState.sessionId, + prompt: [{ type: "text", text: "test" }], + }); + + await vi.waitFor(() => { + expect(turnStartSpy).toHaveBeenCalled(); + }); + + mockFixture.sendServerNotification({ + method: "error", + params: { + threadId: sessionState.sessionId, + turnId: "turn-id", + willRetry: false, + error: turnError, + }, + }); + + turnCompleted.resolve({ + threadId: sessionState.sessionId, + turn: createTurn("completed"), + }); + + let caughtError: unknown; + try { + await promptPromise; + } catch (error) { + caughtError = error; + } + expect(caughtError).toBeDefined(); + return caughtError; +} + +function createTurn(status: "inProgress" | "completed") { + return { + id: "turn-id", + items: [], + itemsView: "notLoaded" as const, + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; +} + +function deferred(): {promise: Promise, resolve: (value: T) => void} { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} diff --git a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts new file mode 100644 index 00000000..9964d2d1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServerNotification } from "../../app-server"; +import type { SessionState } from "../../CodexAcpServer"; +import { AgentMode } from "../../AgentMode"; +import { + createCodexMockTestFixture, + createTestSessionState, + setupPromptAndSendNotifications, + type CodexMockTestFixture, +} from "../acp-test-utils"; + +describe("CodexEventHandler - collab agent tool call events", () => { + let mockFixture: CodexMockTestFixture; + const sessionId = "test-session-id"; + + beforeEach(() => { + mockFixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + const sessionState: SessionState = createTestSessionState({ + sessionId, + currentModelId: "model-id[effort]", + agentMode: AgentMode.DEFAULT_AGENT_MODE, + }); + + it("maps live collab agent tool calls to ACP tool call updates", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "collabAgentToolCall", + id: "call-spawn-weather", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thread-main", + receiverThreadIds: ["thread-paris"], + prompt: "Find the current weather in Paris.", + model: null, + reasoningEffort: null, + agentsStates: { + "thread-paris": { + status: "running", + message: "Checking weather", + }, + }, + }, + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "collabAgentToolCall", + id: "call-spawn-weather", + tool: "spawnAgent", + status: "completed", + senderThreadId: "thread-main", + receiverThreadIds: ["thread-paris"], + prompt: "Find the current weather in Paris.", + model: null, + reasoningEffort: null, + agentsStates: { + "thread-paris": { + status: "completed", + message: null, + }, + }, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(`${mockFixture.getAcpConnectionDump([])}\n`).toMatchFileSnapshot( + "data/collab-agent-tool-call-flow.json" + ); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/agent-message-phases.json b/src/__tests__/CodexACPAgent/data/agent-message-phases.json new file mode 100644 index 00000000..8f1a4b1e --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/agent-message-phases.json @@ -0,0 +1,42 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "commentary-message", + "content": { + "type": "text", + "text": "Checking the relevant event mapping." + }, + "_meta": { + "codex": { + "phase": "commentary" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "final-message", + "content": { + "type": "text", + "text": "Yes, here is the answer." + }, + "_meta": { + "codex": { + "phase": "final_answer" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 9a9405e1..26e78cbb 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -21,13 +21,6 @@ "description": "Display session configuration and token usage.", "input": null }, - { - "name": "goal", - "description": "Set or view the goal for a long-running task.", - "input": { - "hint": "objective | pause | resume | clear" - } - }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", @@ -54,6 +47,13 @@ "description": "Summarize conversation to avoid hitting the context limit.", "input": null }, + { + "name": "goal", + "description": "Set, pause, resume, or clear a task goal.", + "input": { + "hint": "[|clear|pause|resume]" + } + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index 9a9405e1..59987072 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -21,13 +21,6 @@ "description": "Display session configuration and token usage.", "input": null }, - { - "name": "goal", - "description": "Set or view the goal for a long-running task.", - "input": { - "hint": "objective | pause | resume | clear" - } - }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", @@ -54,10 +47,22 @@ "description": "Summarize conversation to avoid hitting the context limit.", "input": null }, + { + "name": "goal", + "description": "Set, pause, resume, or clear a task goal.", + "input": { + "hint": "[|clear|pause|resume]" + } + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", "input": null + }, + { + "name": "$build", + "description": "Build", + "input": null } ] } diff --git a/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json new file mode 100644 index 00000000..8e7ed13c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json @@ -0,0 +1,57 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-spawn-weather", + "kind": "other", + "title": "spawnAgent", + "status": "in_progress", + "rawInput": { + "prompt": "Find the current weather in Paris.", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ], + "agentsStates": { + "thread-paris": { + "status": "running", + "message": "Checking weather" + } + }, + "status": "inProgress" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-spawn-weather", + "title": "spawnAgent", + "status": "completed", + "rawInput": { + "prompt": "Find the current weather in Paris.", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ], + "agentsStates": { + "thread-paris": { + "status": "completed", + "message": null + } + }, + "status": "completed" + } + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/data/follow-up-no-duplicates.json b/src/__tests__/CodexACPAgent/data/follow-up-no-duplicates.json index efd040d7..d992b603 100644 --- a/src/__tests__/CodexACPAgent/data/follow-up-no-duplicates.json +++ b/src/__tests__/CodexACPAgent/data/follow-up-no-duplicates.json @@ -5,6 +5,7 @@ "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "He" @@ -20,6 +21,7 @@ "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "ll" @@ -35,6 +37,7 @@ "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "o!" diff --git a/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json b/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json index d20df233..73d9bf87 100644 --- a/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json +++ b/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json @@ -20,7 +20,7 @@ } ], "rawOutput": { - "status": "completed", + "status": "generating", "revisedPrompt": null, "result": "iVBORw0KGgo=" } diff --git a/src/__tests__/CodexACPAgent/data/image-generation-flow.json b/src/__tests__/CodexACPAgent/data/image-generation-flow.json index 204fe7f2..176aefdd 100644 --- a/src/__tests__/CodexACPAgent/data/image-generation-flow.json +++ b/src/__tests__/CodexACPAgent/data/image-generation-flow.json @@ -44,7 +44,7 @@ } ], "rawOutput": { - "status": "completed", + "status": "generating", "revisedPrompt": "A tiny blue square", "result": "iVBORw0KGgo=", "savedPath": "/tmp/codex/generated-blue-square.png" diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 40207f4f..454bcf7d 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -21,13 +21,6 @@ "description": "Display session configuration and token usage.", "input": null }, - { - "name": "goal", - "description": "Set or view the goal for a long-running task.", - "input": { - "hint": "objective | pause | resume | clear" - } - }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", @@ -54,6 +47,13 @@ "description": "Summarize conversation to avoid hitting the context limit.", "input": null }, + { + "name": "goal", + "description": "Set, pause, resume, or clear a task goal.", + "input": { + "hint": "[|clear|pause|resume]" + } + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", @@ -71,6 +71,7 @@ "sessionId": "session-1", "update": { "sessionUpdate": "user_message_chunk", + "messageId": "item-user-1", "content": { "type": "text", "text": "Hi" @@ -86,6 +87,7 @@ "sessionId": "session-1", "update": { "sessionUpdate": "user_message_chunk", + "messageId": "item-user-1", "content": { "type": "text", "text": "[@image](https://example.com/image.png)" @@ -101,6 +103,7 @@ "sessionId": "session-1", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "item-agent-1", "content": { "type": "text", "text": "Hello!" @@ -116,6 +119,7 @@ "sessionId": "session-1", "update": { "sessionUpdate": "agent_thought_chunk", + "messageId": "item-reason-1", "content": { "type": "text", "text": "Thinking..." diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index 704658a3..3fd4f914 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -21,13 +21,6 @@ "description": "Display session configuration and token usage.", "input": null }, - { - "name": "goal", - "description": "Set or view the goal for a long-running task.", - "input": { - "hint": "objective | pause | resume | clear" - } - }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", @@ -54,6 +47,13 @@ "description": "Summarize conversation to avoid hitting the context limit.", "input": null }, + { + "name": "goal", + "description": "Set, pause, resume, or clear a task goal.", + "input": { + "hint": "[|clear|pause|resume]" + } + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", @@ -71,6 +71,7 @@ "sessionId": "session-legacy", "update": { "sessionUpdate": "user_message_chunk", + "messageId": "item-user-1", "content": { "type": "text", "text": "List the files" @@ -86,6 +87,7 @@ "sessionId": "session-legacy", "update": { "sessionUpdate": "agent_thought_chunk", + "messageId": "item-reasoning-1", "content": { "type": "text", "text": "Need to inspect the directory." @@ -245,6 +247,7 @@ "sessionId": "session-legacy", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "item-agent-1", "content": { "type": "text", "text": "The directory contains README.md and src." diff --git a/src/__tests__/CodexACPAgent/data/multiple-sessions.json b/src/__tests__/CodexACPAgent/data/multiple-sessions.json index 895ec176..4c884302 100644 --- a/src/__tests__/CodexACPAgent/data/multiple-sessions.json +++ b/src/__tests__/CodexACPAgent/data/multiple-sessions.json @@ -5,6 +5,7 @@ "sessionId": "session-1", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "Hello-1" @@ -20,6 +21,7 @@ "sessionId": "session-2", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "Hello-2" diff --git a/src/__tests__/CodexACPAgent/data/output-acp-events.json b/src/__tests__/CodexACPAgent/data/output-acp-events.json index efd040d7..d992b603 100644 --- a/src/__tests__/CodexACPAgent/data/output-acp-events.json +++ b/src/__tests__/CodexACPAgent/data/output-acp-events.json @@ -5,6 +5,7 @@ "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "He" @@ -20,6 +21,7 @@ "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "ll" @@ -35,6 +37,7 @@ "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "string", "content": { "type": "text", "text": "o!" diff --git a/src/__tests__/CodexACPAgent/data/reasoning-completed-parts.json b/src/__tests__/CodexACPAgent/data/reasoning-completed-parts.json index 1a8241ec..0f8d7b46 100644 --- a/src/__tests__/CodexACPAgent/data/reasoning-completed-parts.json +++ b/src/__tests__/CodexACPAgent/data/reasoning-completed-parts.json @@ -5,6 +5,7 @@ "sessionId": "test-session-id", "update": { "sessionUpdate": "agent_thought_chunk", + "messageId": "reasoning-2", "content": { "type": "text", "text": "First summary\n\nSecond summary" diff --git a/src/__tests__/CodexACPAgent/data/reasoning-deltas-and-section-break.json b/src/__tests__/CodexACPAgent/data/reasoning-deltas-and-section-break.json index 43ff8453..894bff9d 100644 --- a/src/__tests__/CodexACPAgent/data/reasoning-deltas-and-section-break.json +++ b/src/__tests__/CodexACPAgent/data/reasoning-deltas-and-section-break.json @@ -5,6 +5,7 @@ "sessionId": "test-session-id", "update": { "sessionUpdate": "agent_thought_chunk", + "messageId": "reasoning-1", "content": { "type": "text", "text": "First thought" @@ -20,6 +21,7 @@ "sessionId": "test-session-id", "update": { "sessionUpdate": "agent_thought_chunk", + "messageId": "reasoning-1", "content": { "type": "text", "text": "\n\n" @@ -35,6 +37,7 @@ "sessionId": "test-session-id", "update": { "sessionUpdate": "agent_thought_chunk", + "messageId": "reasoning-1", "content": { "type": "text", "text": "Raw reasoning detail" diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json b/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json new file mode 100644 index 00000000..acf61a68 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json @@ -0,0 +1,16 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": null + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json new file mode 100644 index 00000000..c524584c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -0,0 +1,20 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": { + "objective": "First task\nSecond task", + "status": "budgetLimited", + "tokenBudget": 1000 + } + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json new file mode 100644 index 00000000..bc14e9cb --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -0,0 +1,20 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": { + "objective": "Ship the goal update", + "status": "active", + "tokenBudget": null + } + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts index c2a6fee2..51182971 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts @@ -89,7 +89,7 @@ async function editFileDirectly( sessionId, prompt: [{ type: "text", - text: `Create ${filePath} by editing files directly. Content must be exactly: ${FILE_CONTENT}. Do not use shell commands.`, + text: `Create ${filePath} by editing files directly. Content must be exactly: '${FILE_CONTENT}'. Do not use shell commands.`, }], }); if (expectSuccess) { diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts index 5b44be07..35bb5f3a 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts @@ -1,6 +1,7 @@ import * as acp from "@agentclientprotocol/sdk"; import {describe, expect} from "vitest"; import {AgentMode} from "../../../AgentMode"; +import {CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR} from "../../../CodexAuthMethod"; import {createSpawnedAgentFixture, type SpawnedAgentFixture,} from "./spawned-agent-fixture"; export { @@ -140,9 +141,9 @@ async function createSpawnedFixture( } export function requireLiveApiKey(): string { - const apiKey = process.env["CODEX_API_KEY"] ?? process.env["OPENAI_API_KEY"]; + const apiKey = process.env[CODEX_API_KEY_ENV_VAR] ?? process.env[OPENAI_API_KEY_ENV_VAR]; if (!apiKey) { - throw new Error("Live integration test requires CODEX_API_KEY or OPENAI_API_KEY."); + throw new Error(`Live integration test requires ${CODEX_API_KEY_ENV_VAR} or ${OPENAI_API_KEY_ENV_VAR}.`); } return apiKey; } diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts index 84b8ee6e..9d5284c5 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts @@ -128,4 +128,21 @@ describeE2E("E2E tests", () => { expect(text).toContain("- session-root-skill: Session root skill"); }); }); + + it("lists repo skills from the session cwd", async () => { + fixture = await createAuthenticatedFixture(); + fixture.writeSkill({ + name: "workspace-skill", + description: "Workspace skill", + body: "This skill exists only in the session workspace.", + }, path.join(fixture.workspaceDir, ".agents", "skills")); + + const session = await fixture.createSession(); + + await fixture.expectAvailableCommand(session.sessionId, "$workspace-skill"); + await fixture.expectPromptText(session.sessionId, "/skills", (text) => { + expect(text).toContain("Available skills:"); + expect(text).toContain("- workspace-skill: Workspace skill"); + }); + }); }); diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index 583f9501..32fbbdc3 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -10,7 +10,7 @@ import type {PermissionResponder} from "./permission-responders"; import type {LegacyNewSessionResponse} from "../../../AcpExtensions"; export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-5.2", "none"); -export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.3-codex", "low"); +export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.4-mini", "low"); export interface TestSkill { readonly name: string; @@ -25,6 +25,7 @@ export interface SpawnedAgentFixture { restart(): Promise; writeSkill(skill: TestSkill, rootDir?: string): void; setPermissionResponder(responder: PermissionResponder): void; + expectAvailableCommand(sessionId: string, commandName: string, timeoutMs?: number): Promise; expectPromptText( sessionId: string, promptText: string, @@ -103,6 +104,7 @@ class RuntimePaths { class RecordingClient implements acp.Client { private readonly textBySessionId = new Map(); + private readonly availableCommandsBySessionId = new Map(); private readonly permissionRequestsBySessionId = new Map(); private permissionResponder: PermissionResponder = () => ({ outcome: {outcome: "cancelled"}, @@ -123,6 +125,11 @@ class RecordingClient implements acp.Client { } async sessionUpdate(params: acp.SessionNotification): Promise { + if (params.update.sessionUpdate === "available_commands_update") { + this.availableCommandsBySessionId.set(params.sessionId, params.update.availableCommands); + return; + } + if (params.update.sessionUpdate !== "agent_message_chunk" || params.update.content.type !== "text") { return; } @@ -135,6 +142,10 @@ class RecordingClient implements acp.Client { return this.textBySessionId.get(sessionId) ?? ""; } + readAvailableCommands(sessionId: string): acp.AvailableCommand[] { + return this.availableCommandsBySessionId.get(sessionId) ?? []; + } + readPermissionRequests( sessionId: string, toolCallKind: acp.ToolKind, @@ -204,6 +215,13 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture { this.client.setPermissionResponder(responder); } + async expectAvailableCommand(sessionId: string, commandName: string, timeoutMs = 30_000): Promise { + await vi.waitFor(() => { + const commandNames = this.client.readAvailableCommands(sessionId).map(command => command.name); + expect(commandNames).toContain(commandName); + }, {timeout: timeoutMs}); + } + async expectPromptText( sessionId: string, promptText: string, diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index e796d8ba..8bd45e54 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as acp from "@agentclientprotocol/sdk"; import type { McpServerElicitationRequestParams } from '../../app-server/v2'; import { createCodexMockTestFixture, createTestSessionState, type CodexMockTestFixture } from '../acp-test-utils'; import type { SessionState } from '../../CodexAcpServer'; @@ -49,7 +50,138 @@ describe('Elicitation Events', () => { }; } + async function setupSessionWithPendingPromptAndCapabilities(clientCapabilities: acp.ClientCapabilities) { + await fixture.getCodexAcpAgent().initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities, + }); + return setupSessionWithPendingPrompt(); + } + describe('Form mode elicitation', () => { + it('should use ACP form elicitation when the client supports it', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse({ + action: 'accept', + content: { username: 'octocat' }, + _meta: { source: 'client' }, + }); + + const params: McpServerElicitationRequestParams = { + threadId: sessionId, turnId: 'turn-1', serverName: 'test-server', + mode: 'form', _meta: null, message: 'Please provide your username', + requestedSchema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] }, + }; + + const response = await fixture.sendServerRequest('mcpServer/elicitation/request', params); + expect(response).toEqual({ action: 'accept', content: { username: 'octocat' }, _meta: { source: 'client' } }); + + const [elicitationEvent] = fixture.getAcpConnectionEvents([]); + expect(elicitationEvent).toEqual({ + method: 'createElicitation', + args: [{ + sessionId, + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } }, + required: ['username'], + }, + _meta: null, + }], + }); + + completeTurn(); + await promptPromise; + }); + + it('should normalize legacy enumNames schemas for ACP form elicitation', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse({ + action: 'accept', + content: { color: 'blue' }, + }); + + const params: McpServerElicitationRequestParams = { + threadId: sessionId, turnId: 'turn-1', serverName: 'test-server', + mode: 'form', _meta: null, message: 'Pick a color', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + enum: ['red', 'blue'], + enumNames: ['Red', 'Blue'], + }, + }, + required: ['color'], + }, + }; + + await fixture.sendServerRequest('mcpServer/elicitation/request', params); + + const [elicitationEvent] = fixture.getAcpConnectionEvents([]); + expect(elicitationEvent?.args[0].requestedSchema.properties.color).toEqual({ + type: 'string', + oneOf: [ + { const: 'red', title: 'Red' }, + { const: 'blue', title: 'Blue' }, + ], + }); + + completeTurn(); + await promptPromise; + }); + + it('should map custom ACP elicitation actions to cancel', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse({ + action: 'defer', + _meta: { source: 'client' }, + }); + + const params: McpServerElicitationRequestParams = { + threadId: sessionId, turnId: 'turn-1', serverName: 'test-server', + mode: 'form', _meta: null, message: 'Please provide your username', + requestedSchema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] }, + }; + + const response = await fixture.sendServerRequest('mcpServer/elicitation/request', params); + expect(response).toEqual({ action: 'cancel', content: null, _meta: null }); + + completeTurn(); + await promptPromise; + }); + + it('should not treat malformed ACP elicitation accept responses as accepted', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse({ + action: 'accept', + content: { username: { nested: 'invalid' } }, + } as acp.CreateElicitationResponse); + + const params: McpServerElicitationRequestParams = { + threadId: sessionId, turnId: 'turn-1', serverName: 'test-server', + mode: 'form', _meta: null, message: 'Please provide your username', + requestedSchema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] }, + }; + + const response = await fixture.sendServerRequest('mcpServer/elicitation/request', params); + expect(response).toEqual({ action: 'cancel', content: null, _meta: null }); + + completeTurn(); + await promptPromise; + }); + it('should map accept to accept', async () => { const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); fixture.setPermissionResponse({ outcome: { outcome: 'selected', optionId: 'accept' } }); @@ -131,6 +263,78 @@ describe('Elicitation Events', () => { }); describe('MCP tool call approval elicitation', () => { + it('should use ACP form elicitation for MCP tool approval when supported', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse({ + action: 'accept', + content: { persist: 'always' }, + _meta: { source: 'client' }, + }); + + fixture.sendServerNotification({ + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + item: { + type: "mcpToolCall", + id: "call-id", + server: "tool-server", + tool: "tool-name", + status: "inProgress", + arguments: { argument: "example" }, + appContext: null, + pluginId: null, + result: null, + error: null, + durationMs: null, + }, + }, + }); + await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); + fixture.clearAcpConnectionDump(); + + const params: McpServerElicitationRequestParams = { + threadId: sessionId, turnId: 'turn-1', serverName: 'tool-server', + mode: 'form', + _meta: { codex_approval_kind: 'mcp_tool_call', persist: ['session', 'always'] }, + message: 'Allow tool call?', + requestedSchema: { type: 'object', properties: {} }, + }; + + const response = await fixture.sendServerRequest('mcpServer/elicitation/request', params); + expect(response).toEqual({ action: 'accept', content: null, _meta: { source: 'client', persist: 'always' } }); + + const events = fixture.getAcpConnectionEvents(['_meta']); + expect(events[0]).toMatchObject({ + method: 'createElicitation', + args: [{ + sessionId, + toolCallId: 'call-id', + mode: 'form', + message: 'Allow tool call?', + }], + }); + expect(events[0]!.args[0].requestedSchema.properties.persist.oneOf).toEqual([ + { const: 'once', title: 'Allow once' }, + { const: 'session', title: 'Allow for this session' }, + { const: 'always', title: "Allow and don't ask again" }, + ]); + expect(events[1]).toEqual({ + method: 'sessionUpdate', + args: [{ + sessionId, + update: { sessionUpdate: 'tool_call_update', toolCallId: 'call-id', status: 'in_progress' }, + }], + }); + + completeTurn(); + await promptPromise; + }); + it('should show Allow/session/always/Decline options when all persist values advertised', async () => { const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); fixture.setPermissionResponse({ outcome: { outcome: 'selected', optionId: McpApprovalOptionId.AllowOnce } }); @@ -373,6 +577,53 @@ describe('Elicitation Events', () => { }); describe('URL mode elicitation', () => { + it('should use ACP URL elicitation when the client supports it', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { url: {} }, + }); + fixture.setElicitationResponse({ action: 'accept', _meta: { source: 'client' } }); + + const params: McpServerElicitationRequestParams = { + threadId: sessionId, turnId: 'turn-1', serverName: 'auth-server', + mode: 'url', _meta: null, message: 'Please authorize access', + url: 'https://example.com/authorize', elicitationId: 'elicit-123', + }; + + const response = await fixture.sendServerRequest('mcpServer/elicitation/request', params); + expect(response).toEqual({ action: 'accept', content: null, _meta: { source: 'client' } }); + + const [elicitationEvent] = fixture.getAcpConnectionEvents([]); + expect(elicitationEvent).toEqual({ + method: 'createElicitation', + args: [{ + sessionId, + mode: 'url', + message: 'Please authorize access', + url: 'https://example.com/authorize', + elicitationId: 'elicit-123', + _meta: null, + }], + }); + + fixture.sendServerNotification({ + method: 'serverRequest/resolved', + params: { + threadId: sessionId, + requestId: 'request-1', + }, + }); + await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); + + const [, completeElicitationEvent] = fixture.getAcpConnectionEvents([]); + expect(completeElicitationEvent).toEqual({ + method: 'completeElicitation', + args: [{ elicitationId: 'elicit-123' }], + }); + + completeTurn(); + await promptPromise; + }); + it('should map accept to accept for URL mode', async () => { const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); fixture.setPermissionResponse({ outcome: { outcome: 'selected', optionId: 'accept' } }); diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index 70716c8a..07d42444 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -12,11 +12,21 @@ import { FAST_MODE_OFF, FAST_MODE_ON, } from "../../FastModeConfig"; +import {MODEL_CONFIG_ID} from "../../ModelConfigOption"; describe("Fast mode session config", () => { + const booleanConfigCapabilities: acp.ClientCapabilities = { + session: { + configOptions: { + boolean: {}, + }, + }, + }; + async function createSession( currentServiceTier: "fast" | "flex" | null = null, - clientInfo: acp.Implementation | null = null + clientInfo: acp.Implementation | null = null, + clientCapabilities?: acp.ClientCapabilities, ) { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); @@ -25,13 +35,14 @@ describe("Fast mode session config", () => { id: "fast-model", additionalSpeedTiers: ["fast"], }); + const slowModel = createTestModel({id: "slow-model"}); vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", currentModelId: "fast-model[medium]", - models: [fastModel], + models: [fastModel, slowModel], currentServiceTier, additionalDirectories: [], }); @@ -39,6 +50,7 @@ describe("Fast mode session config", () => { await codexAcpAgent.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientInfo, + ...(clientCapabilities ? {clientCapabilities} : {}), }); const response = await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); @@ -61,6 +73,31 @@ describe("Fast mode session config", () => { expect(response.configOptions).toContainEqual(createFastModeConfigOption(false)); }); + it("returns the Fast mode config option as a boolean when the client supports it", async () => { + const {response} = await createSession(null, null, booleanConfigCapabilities); + + expect(response.configOptions).toContainEqual(createFastModeConfigOption(false, true)); + const option = response.configOptions?.find(option => option.id === FAST_MODE_CONFIG_ID); + expect(option).toMatchObject({ + id: FAST_MODE_CONFIG_ID, + type: "boolean", + currentValue: false, + }); + expect(option).not.toHaveProperty("options"); + }); + + it("keeps the Fast mode select option when boolean support is explicitly absent", async () => { + const {response} = await createSession(null, null, { + session: { + configOptions: { + boolean: null, + }, + }, + }); + + expect(response.configOptions).toContainEqual(createFastModeConfigOption(false)); + }); + it("initializes Fast mode as On when the app-server session tier is fast", async () => { const {response, codexAcpAgent} = await createSession("fast"); @@ -137,6 +174,28 @@ describe("Fast mode session config", () => { expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(false); }); + it("toggles Fast mode through boolean session config options", async () => { + const {codexAcpAgent} = await createSession(null, null, booleanConfigCapabilities); + + const onResponse = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: FAST_MODE_CONFIG_ID, + type: "boolean", + value: true, + }); + expect(onResponse.configOptions).toContainEqual(createFastModeConfigOption(true, true)); + expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(true); + + const offResponse = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: FAST_MODE_CONFIG_ID, + type: "boolean", + value: false, + }); + expect(offResponse.configOptions).toContainEqual(createFastModeConfigOption(false, true)); + expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(false); + }); + it("rejects unknown Fast mode config ids and values", async () => { const {codexAcpAgent} = await createSession(); @@ -183,6 +242,24 @@ describe("Fast mode session config", () => { })); }); + it("removes the Fast mode config option when switching to a non-fast model via session_config", async () => { + const {codexAcpAgent} = await createSession("fast"); + + const fastResponse = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: MODEL_CONFIG_ID, + value: "fast-model", + }); + expect(fastResponse.configOptions?.some(o => o.id === FAST_MODE_CONFIG_ID)).toBe(true); + + const slowResponse = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: MODEL_CONFIG_ID, + value: "slow-model", + }); + expect(slowResponse.configOptions?.some(o => o.id === FAST_MODE_CONFIG_ID)).toBe(false); + }); + it("keeps Fast mode selected across model switches but stops applying it for non-fast models", async () => { const {codexAcpAgent, codexAcpClient, fixture} = await createSession("fast"); const slowModel = createTestModel({id: "slow-model"}); diff --git a/src/__tests__/CodexACPAgent/image-events.test.ts b/src/__tests__/CodexACPAgent/image-events.test.ts index bb7718aa..8eb8102e 100644 --- a/src/__tests__/CodexACPAgent/image-events.test.ts +++ b/src/__tests__/CodexACPAgent/image-events.test.ts @@ -45,7 +45,7 @@ describe("CodexEventHandler - image events", () => { item: { type: "imageGeneration", id: "image-generation-1", - status: "completed", + status: "generating", revisedPrompt: "A tiny blue square", result: "iVBORw0KGgo=", savedPath: "/tmp/codex/generated-blue-square.png", @@ -71,7 +71,7 @@ describe("CodexEventHandler - image events", () => { item: { type: "imageGeneration", id: "image-generation-completed-only", - status: "completed", + status: "generating", revisedPrompt: null, result: "iVBORw0KGgo=", }, diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index da4cc231..0bff8791 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -83,4 +83,46 @@ describe('CodexACPAgent - initialize', () => { }) ])); }); + + it('should retain the fork experimental app-server capability configuration', async () => { + await agent.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + elicitation: { form: {}, url: {} }, + }, + }); + + expect(mockCodexConnection.sendRequest).toHaveBeenCalledWith("initialize", expect.objectContaining({ + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, + })); + }); + + it('should advertise API key auth with the legacy metadata method', () => { + expect(getCodexAuthMethods()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "api-key", + _meta: { + "api-key": { + provider: "openai", + }, + }, + }), + ])); + expect(getCodexAuthMethods()).not.toEqual(expect.arrayContaining([ + expect.objectContaining({type: "env_var"}), + expect.objectContaining({id: "codex-api-key"}), + expect.objectContaining({id: "openai-api-key"}), + ])); + }); + + it('should not advertise ChatGPT auth when browser auth is disabled', () => { + const methodIds = getCodexAuthMethods(undefined, {NO_BROWSER: "1"} as NodeJS.ProcessEnv) + .map((method) => method.id); + + expect(methodIds).not.toContain("chat-gpt"); + expect(methodIds).toEqual(expect.arrayContaining(["api-key"])); + }); }); diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index 2b2b4e7e..36529576 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -81,11 +81,6 @@ describe("CodexACPAgent - list sessions", () => { "vscode", "exec", "appServer", - "subAgent", - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "subAgentOther", "unknown", ], })); diff --git a/src/__tests__/CodexACPAgent/new-session-logout.test.ts b/src/__tests__/CodexACPAgent/new-session-logout.test.ts index 36a6f492..c8603f13 100644 --- a/src/__tests__/CodexACPAgent/new-session-logout.test.ts +++ b/src/__tests__/CodexACPAgent/new-session-logout.test.ts @@ -1,5 +1,6 @@ import {describe, expect, it, vi} from "vitest"; -import {createCodexMockTestFixture} from "../acp-test-utils"; +import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; +import {ModelId} from "../../ModelId"; describe("New session logout handling", () => { it("logs out when newSession fails with an error containing log out", async () => { @@ -39,4 +40,61 @@ describe("New session logout handling", () => { }); expect(logoutSpy).toHaveBeenCalledOnce(); }); + + it("refreshes OpenAI sessions when newSession error forces logout", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const model = createTestModel(); + const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") + .mockResolvedValueOnce({ + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }) + .mockResolvedValueOnce({ + account: null, + requiresOpenaiAuth: true, + }); + + const errorMessage = `Internal error: "failed to reload config: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again."`; + vi.spyOn(codexAcpClient, "newSession") + .mockResolvedValueOnce({ + sessionId: "openai-session", + currentModelId, + models: [model], + modelProvider: "openai", + additionalDirectories: [], + }) + .mockResolvedValueOnce({ + sessionId: "custom-provider-session", + currentModelId, + models: [model], + modelProvider: "custom-provider", + additionalDirectories: [], + }) + .mockRejectedValueOnce(new Error(errorMessage)); + const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); + + const openAiSession = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + const customProviderSession = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + + await expect(codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []})) + .rejects.toMatchObject({ + data: expect.stringContaining("You have been logged out. Please try again."), + }); + + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(getAccountSpy).toHaveBeenCalledTimes(2); + expect(codexAcpAgent.getSessionState(openAiSession.sessionId)).toMatchObject({ + account: null, + authConfigured: false, + }); + expect(codexAcpAgent.getSessionState(customProviderSession.sessionId)).toMatchObject({ + account: null, + authConfigured: true, + authProvider: "custom-provider", + }); + }); }); diff --git a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts index f481088e..aaeef097 100644 --- a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts +++ b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts @@ -19,6 +19,18 @@ describe("ResponseItemHistoryFallback", () => { ]); }); + it("does not recover function calls when all parsed tool call ids already exist", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + functionCall("call-existing-a", "rg \"ExistingA\" src"), + functionCallOutput("call-existing-a", "Chunk ID: existing-a\nProcess exited with code 0\nOutput:\nsrc/a.ts\n"), + functionCall("call-existing-b", "rg \"ExistingB\" src"), + functionCallOutput("call-existing-b", "Chunk ID: existing-b\nProcess exited with code 0\nOutput:\nsrc/b.ts\n"), + ]), "terminal_output", new Set(["call-existing-a", "call-existing-b"])); + + expect(toolCallIds(updates)).toEqual([]); + expect(toolCallUpdateStatuses(updates)).toEqual([]); + }); + it("does not duplicate adjacent reasoning from event and response item records", () => { const updates = parseResponseItemHistoryFallback(jsonl([ { @@ -43,6 +55,26 @@ describe("ResponseItemHistoryFallback", () => { expect(thoughtTexts(updates)).toEqual(["Need to inspect the directory."]); }); + it("preserves assistant message phase metadata from response items", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + { + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Final answer text." }], + phase: "final_answer", + }, + }, + functionCall("call-missing", "ls"), + functionCallOutput("call-missing", "Chunk ID: missing\nProcess exited with code 0\nOutput:\nREADME.md\n"), + ]), "terminal_output"); + + expect(agentMessageMetas(updates)).toEqual([ + { codex: { phase: "final_answer" } }, + ]); + }); + it("marks exec command outputs without exit footers failed when they report command errors", () => { const updates = parseResponseItemHistoryFallback(jsonl([ functionCall("call-read-failed", "cat missing.txt"), @@ -53,6 +85,17 @@ describe("ResponseItemHistoryFallback", () => { { toolCallId: "call-read-failed", status: "failed" }, ]); }); + + it("marks exec command outputs without exit footers completed when they do not report errors", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + functionCall("call-read-ok", "cat existing.txt"), + functionCallOutput("call-read-ok", "existing file contents\n"), + ]), "terminal_output"); + + expect(toolCallUpdateStatuses(updates)).toEqual([ + { toolCallId: "call-read-ok", status: "completed" }, + ]); + }); }); function jsonl(records: unknown[]): string { @@ -110,3 +153,11 @@ function thoughtTexts(updates: UpdateSessionEvent[] | null): string[] { )) .flatMap((update) => update.content.type === "text" ? [update.content.text] : []); } + +function agentMessageMetas(updates: UpdateSessionEvent[] | null): unknown[] { + return (updates ?? []) + .filter((update): update is Extract => ( + update.sessionUpdate === "agent_message_chunk" + )) + .map((update) => update._meta); +} diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index 28c86f5e..a5cfd49e 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -20,6 +20,7 @@ function buildModels(): {fast: Model; slow: Model} { description: "Frontier", supportedReasoningEfforts: [lowEffort, mediumEffort, highEffort], defaultReasoningEffort: "medium", + additionalSpeedTiers: ["fast"], }); const slow = createTestModel({ id: "slow-model", @@ -91,6 +92,35 @@ describe("Session config options", () => { ); }); + it("shows the current uncataloged model as its own selectable option", async () => { + const {fast, slow} = buildModels(); + const {codexAcpAgent, response} = await createSession("custom-model[high]", [fast, slow]); + + const ids = response.configOptions?.map(o => o.id); + expect(ids).toEqual([MODE_CONFIG_ID, MODEL_CONFIG_ID, PLAN_MODE_CONFIG_ID]); + + const modelOption = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); + expect(modelOption).toMatchObject({ + category: "model", + currentValue: "custom-model", + type: "select", + options: [ + {value: "custom-model", name: "custom-model", description: null}, + {value: "fast-model", name: "Fast model", description: "Frontier"}, + {value: "slow-model", name: "Slow model", description: "Strong"}, + ], + }); + expect(response.configOptions?.some(o => o.id === REASONING_EFFORT_CONFIG_ID)).toBe(false); + + await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: MODEL_CONFIG_ID, + value: "custom-model", + }); + + expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("custom-model[high]"); + }); + it("keeps the legacy models list as combined model/effort entries", async () => { const {fast, slow} = buildModels(); const {response} = await createSession("fast-model[medium]", [fast, slow]); diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index 134f49f2..fc690994 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -18,13 +18,7 @@ describe("CodexEventHandler - thread goal events", () => { vi.clearAllMocks(); }); - const sessionState: SessionState = createTestSessionState({ - sessionId, - currentModelId: "model-id[effort]", - agentMode: AgentMode.DEFAULT_AGENT_MODE, - }); - - it("should send thread goal updates using the app-server notification method", async () => { + it("should send thread goal updates as session metadata", async () => { const goalUpdatedNotification: ServerNotification = { method: "thread/goal/updated", params: { @@ -43,20 +37,14 @@ describe("CodexEventHandler - thread goal events", () => { }, }; - await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]); + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [goalUpdatedNotification]); - expect(mockFixture.getAcpConnectionEvents([])).toEqual([ - { - method: "notify", - args: [ - "thread/goal/updated", - goalUpdatedNotification.params, - ], - }, - ]); + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/thread-goal-updated.json" + ); }); - it("should keep multiline thread goal updates out of transcript", async () => { + it("should trim multiline thread goal objectives in session metadata", async () => { const goalUpdatedNotification: ServerNotification = { method: "thread/goal/updated", params: { @@ -75,22 +63,137 @@ describe("CodexEventHandler - thread goal events", () => { }, }; - await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]); + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [goalUpdatedNotification]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/thread-goal-updated-multiline.json" + ); + }); + + it("should send thread goal cleared as session metadata", async () => { + const goalClearedNotification: ServerNotification = { + method: "thread/goal/cleared", + params: { + threadId: sessionId, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [goalClearedNotification]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/thread-goal-cleared.json" + ); + }); + + it("should suppress duplicate thread goal updates", async () => { + const goalUpdatedNotification: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: "turn-1", + goal: { + threadId: sessionId, + objective: "Ship the goal update", + status: "active", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 12, + createdAt: 1710000000, + updatedAt: 1710000012, + }, + }, + }; + const duplicateGoalUpdatedNotification: ServerNotification = { + method: "thread/goal/updated", + params: { + ...goalUpdatedNotification.params, + goal: { + ...goalUpdatedNotification.params.goal, + tokensUsed: 84, + timeUsedSeconds: 24, + updatedAt: 1710000024, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [ + goalUpdatedNotification, + duplicateGoalUpdatedNotification, + ]); const events = mockFixture.getAcpConnectionEvents([]); - expect(events.filter(event => event.method === "sessionUpdate")).toEqual([]); - expect(events).toEqual([ + expect(events).toHaveLength(1); + expect(events[0]!.args[0].update).toEqual({ + sessionUpdate: "session_info_update", + _meta: { + codex: { + goal: { + objective: "Ship the goal update", + status: "active", + tokenBudget: null, + }, + }, + }, + }); + }); + + it("should not append completed goal updates to preceding agent text", async () => { + const goalCompletedNotification: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: "turn-1", + goal: { + threadId: sessionId, + objective: "tell me a joke", + status: "complete", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 12, + createdAt: 1710000000, + updatedAt: 1710000012, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [ { - method: "notify", - args: [ - "thread/goal/updated", - goalUpdatedNotification.params, - ], + method: "item/agentMessage/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "message-1", + delta: "Because they kept losing interest in `any`.", + }, }, + goalCompletedNotification, ]); + + const events = mockFixture.getAcpConnectionEvents([]); + expect(events).toHaveLength(2); + expect(events[0]!.args[0].update).toEqual({ + sessionUpdate: "agent_message_chunk", + messageId: "message-1", + content: { + type: "text", + text: "Because they kept losing interest in `any`.", + }, + }); + expect(events[1]!.args[0].update).toEqual({ + sessionUpdate: "session_info_update", + _meta: { + codex: { + goal: { + objective: "tell me a joke", + status: "complete", + tokenBudget: null, + }, + }, + }, + }); }); - it("should send thread goal cleared using the app-server notification method", async () => { + it("should suppress duplicate thread goal cleared notifications", async () => { const goalClearedNotification: ServerNotification = { method: "thread/goal/cleared", params: { @@ -98,16 +201,28 @@ describe("CodexEventHandler - thread goal events", () => { }, }; - await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalClearedNotification]); + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [ + goalClearedNotification, + goalClearedNotification, + ]); - expect(mockFixture.getAcpConnectionEvents([])).toEqual([ - { - method: "notify", - args: [ - "thread/goal/cleared", - goalClearedNotification.params, - ], + const events = mockFixture.getAcpConnectionEvents([]); + expect(events).toHaveLength(1); + expect(events[0]!.args[0].update).toEqual({ + sessionUpdate: "session_info_update", + _meta: { + codex: { + goal: null, + }, }, - ]); + }); }); + + function createSessionState(): SessionState { + return createTestSessionState({ + sessionId, + currentModelId: "model-id[effort]", + agentMode: AgentMode.DEFAULT_AGENT_MODE, + }); + } }); diff --git a/src/__tests__/CodexACPAgent/thread-goal-ext-methods.test.ts b/src/__tests__/CodexACPAgent/thread-goal-ext-methods.test.ts deleted file mode 100644 index 4f7d515b..00000000 --- a/src/__tests__/CodexACPAgent/thread-goal-ext-methods.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {describe, expect, it, vi} from "vitest"; -import {createCodexMockTestFixture} from "../acp-test-utils"; -import { - THREAD_GOAL_CLEAR_METHOD, - THREAD_GOAL_GET_METHOD, - THREAD_GOAL_SET_METHOD, -} from "../../AcpExtensions"; -import type {ThreadGoal} from "../../app-server/v2"; - -describe("Thread goal ext methods", () => { - const threadId = "thread-id"; - const goal: ThreadGoal = { - threadId, - objective: "Ship goal support", - status: "active", - tokenBudget: 1000, - tokensUsed: 42, - timeUsedSeconds: 12, - createdAt: 1710000000, - updatedAt: 1710000012, - }; - - it("gets the thread goal with the app-server response shape", async () => { - const fixture = createCodexMockTestFixture(); - const threadGoalGet = vi.spyOn(fixture.getCodexAppServerClient(), "threadGoalGet") - .mockResolvedValue({goal}); - - const response = await fixture.getCodexAcpAgent().extMethod(THREAD_GOAL_GET_METHOD, { - threadId, - }); - - expect(threadGoalGet).toHaveBeenCalledWith({threadId}); - expect(response).toEqual({goal}); - }); - - it("sets the thread goal and returns the app-server response", async () => { - const fixture = createCodexMockTestFixture(); - const threadGoalSet = vi.spyOn(fixture.getCodexAppServerClient(), "threadGoalSet") - .mockResolvedValue({goal}); - - const response = await fixture.getCodexAcpAgent().extMethod(THREAD_GOAL_SET_METHOD, { - threadId, - objective: "Ship goal support", - status: "paused", - tokenBudget: 1000, - }); - - expect(threadGoalSet).toHaveBeenCalledWith({ - threadId, - objective: "Ship goal support", - status: "paused", - tokenBudget: 1000, - }); - expect(response).toEqual({goal}); - }); - - it("clears the thread goal with the app-server response shape", async () => { - const fixture = createCodexMockTestFixture(); - const threadGoalClear = vi.spyOn(fixture.getCodexAppServerClient(), "threadGoalClear") - .mockResolvedValue({cleared: true}); - - const response = await fixture.getCodexAcpAgent().extMethod(THREAD_GOAL_CLEAR_METHOD, { - threadId, - }); - - expect(threadGoalClear).toHaveBeenCalledWith({threadId}); - expect(response).toEqual({cleared: true}); - }); - - it("rejects invalid thread goal params", async () => { - const fixture = createCodexMockTestFixture(); - - await expect(fixture.getCodexAcpAgent().extMethod(THREAD_GOAL_SET_METHOD, { - threadId, - status: "not-a-status", - })).rejects.toThrow(); - }); -}); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 8919b882..aaaee80d 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -1,5 +1,5 @@ import * as acp from "@agentclientprotocol/sdk"; -import type {McpServerStdio, RequestPermissionResponse} from "@agentclientprotocol/sdk"; +import type {CreateElicitationResponse, McpServerStdio, RequestPermissionResponse} from "@agentclientprotocol/sdk"; import {CodexAcpClient} from '../CodexAcpClient'; import {CodexAppServerClient, type CodexConnectionEvent} from '../CodexAppServerClient'; import {startCodexConnection} from "../CodexJsonRpcConnection"; @@ -42,6 +42,12 @@ function normalizeAcpConnectionEvent(event: MethodCallEvent): MethodCallEvent { if (event.method === "request" && event.args[0] === acp.methods.client.session.requestPermission) { return {method: "requestPermission", args: [event.args[1]]}; } + if (event.method === "request" && event.args[0] === acp.methods.client.elicitation.create) { + return {method: "createElicitation", args: [event.args[1]]}; + } + if (event.method === "notify" && event.args[0] === acp.methods.client.elicitation.complete) { + return {method: "completeElicitation", args: [event.args[1]]}; + } if (event.method === "notify" && event.args[0] === acp.methods.client.session.update) { return {method: "sessionUpdate", args: [event.args[1]]}; } @@ -238,6 +244,7 @@ export interface CodexMockTestFixture extends TestFixture { sendServerNotification(notification: ServerNotification | Record): void, sendServerRequest(method: string, params: unknown): Promise, setPermissionResponse(response: RequestPermissionResponse): void, + setElicitationResponse(response: CreateElicitationResponse | Promise): void, } /** @@ -255,6 +262,9 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { const permissionState: { response: RequestPermissionResponse } = { response: { outcome: { outcome: 'cancelled' } } }; + const elicitationState: { response: CreateElicitationResponse | Promise } = { + response: { action: 'cancel' } + }; const mockCodexConnection = { sendRequest: () => Promise.resolve(undefined), @@ -276,6 +286,9 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { if (args[0] === acp.methods.client.session.requestPermission) { return permissionState.response; } + if (args[0] === acp.methods.client.elicitation.create) { + return elicitationState.response; + } return { mock: "Mocked return" }; }); returnValues.set('requestPermission', () => permissionState.response); @@ -313,6 +326,9 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { setPermissionResponse(response: RequestPermissionResponse): void { permissionState.response = response; }, + setElicitationResponse(response: CreateElicitationResponse | Promise): void { + elicitationState.response = response; + }, }; } @@ -357,6 +373,8 @@ export function createTestSessionState(overrides?: Partial): Sessi modelContextWindow: null, rateLimits: null, account: null, + authConfigured: overrides?.account !== undefined ? overrides.account !== null : false, + authProvider: null, cwd: "/test/cwd", additionalDirectories: [], sessionId: "session-id", diff --git a/src/app-server/AuthMode.ts b/src/app-server/AuthMode.ts index 77bc037e..b2488761 100644 --- a/src/app-server/AuthMode.ts +++ b/src/app-server/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "headers" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey"; diff --git a/src/app-server/ImageGenerationItem.ts b/src/app-server/ImageGenerationItem.ts index 0edb7c22..26cd6285 100644 --- a/src/app-server/ImageGenerationItem.ts +++ b/src/app-server/ImageGenerationItem.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "./AbsolutePathBuf"; -export type ImageGenerationItem = { id: string, status: string, revised_prompt?: string, result: string, saved_path?: string, }; +export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, }; diff --git a/src/app-server/LegacyAppPathString.ts b/src/app-server/LegacyAppPathString.ts index 04e465b8..e39784a8 100644 --- a/src/app-server/LegacyAppPathString.ts +++ b/src/app-server/LegacyAppPathString.ts @@ -11,10 +11,10 @@ * * When converting from [`PathUri`], "native" refers to the supplied * [`PathConvention`], which may be foreign to the operating system running - * this process. The inner string is private so path-producing code must convert - * from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing - * the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8 - * lossily because this API value is serialized as a JSON string. + * this process. The inner string is private so path-producing code must use a + * path conversion method instead of bypassing the intended conversion + * boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API + * value is serialized as a JSON string. * * Deserialization accepts any UTF-8 string without interpreting or validating * it. That unrestricted construction path is intentionally available only to diff --git a/src/app-server/MultiAgentMode.ts b/src/app-server/MultiAgentMode.ts index b59be94f..7784a6f5 100644 --- a/src/app-server/MultiAgentMode.ts +++ b/src/app-server/MultiAgentMode.ts @@ -3,9 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Controls whether the model receives multi-agent delegation instructions and, - * when it does, whether it should only spawn sub-agents after an explicit user - * request or may delegate proactively when doing so would help. `none` leaves - * the multi-agent tools available without injecting delegation instructions. + * Controls the effective multi-agent delegation instructions for a turn. `custom` means the + * configured mode hint defines the policy instead of a built-in policy. */ -export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive"; +export type MultiAgentMode = { "custom": string } | "explicitRequestOnly" | "proactive"; diff --git a/src/app-server/ResponseItem.ts b/src/app-server/ResponseItem.ts index 074e11a1..769b1b08 100644 --- a/src/app-server/ResponseItem.ts +++ b/src/app-server/ResponseItem.ts @@ -20,4 +20,4 @@ id?: string, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/src/app-server/ThreadId.ts b/src/app-server/ThreadId.ts index bfb3b4b4..801ffb35 100644 --- a/src/app-server/ThreadId.ts +++ b/src/app-server/ThreadId.ts @@ -2,4 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Identifier for a Codex thread. + * + * Codex-generated thread IDs are UUIDv7, and some use cases rely on that. + */ export type ThreadId = string; diff --git a/src/app-server/WebSearchItem.ts b/src/app-server/WebSearchItem.ts index 46b14065..bc1e6d54 100644 --- a/src/app-server/WebSearchItem.ts +++ b/src/app-server/WebSearchItem.ts @@ -1,6 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { WebSearchAction } from "./WebSearchAction"; +import type { WebSearchAction } from "./v2/WebSearchAction"; -export type WebSearchItem = { id: string, query: string, action: WebSearchAction, }; +export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null, }; diff --git a/src/app-server/v2/AppInfo.ts b/src/app-server/v2/AppInfo.ts index ef1f54aa..7145ce9a 100644 --- a/src/app-server/v2/AppInfo.ts +++ b/src/app-server/v2/AppInfo.ts @@ -7,7 +7,7 @@ import type { AppMetadata } from "./AppMetadata"; /** * EXPERIMENTAL - app metadata returned by app-list APIs. */ -export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, branding: AppBranding | null, appMetadata: AppMetadata | null, labels: { [key in string]?: string } | null, installUrl: string | null, isAccessible: boolean, +export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, iconAssets: { [key in string]?: string } | null, iconDarkAssets: { [key in string]?: string } | null, distributionChannel: string | null, branding: AppBranding | null, appMetadata: AppMetadata | null, labels: { [key in string]?: string } | null, installUrl: string | null, isAccessible: boolean, /** * Whether this app is enabled in config.toml. * Example: diff --git a/src/app-server/v2/AppToolApproval.ts b/src/app-server/v2/AppToolApproval.ts index e92cd8e2..6704ef0f 100644 --- a/src/app-server/v2/AppToolApproval.ts +++ b/src/app-server/v2/AppToolApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AppToolApproval = "auto" | "prompt" | "approve"; +export type AppToolApproval = "auto" | "prompt" | "writes" | "approve"; diff --git a/src/app-server/v2/AskForApproval.ts b/src/app-server/v2/AskForApproval.ts index 8d41214e..1d605501 100644 --- a/src/app-server/v2/AskForApproval.ts +++ b/src/app-server/v2/AskForApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "granular": { sandbox_approval: boolean, rules: boolean, skill_approval: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never"; +export type AskForApproval = "untrusted" | "on-request" | { "granular": { sandbox_approval: boolean, rules: boolean, skill_approval: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never"; diff --git a/src/app-server/v2/CapabilityRootLocation.ts b/src/app-server/v2/CapabilityRootLocation.ts index 6266101e..6c2ac908 100644 --- a/src/app-server/v2/CapabilityRootLocation.ts +++ b/src/app-server/v2/CapabilityRootLocation.ts @@ -5,4 +5,8 @@ /** * Location used to resolve a selected capability root. */ -export type CapabilityRootLocation = { "type": "environment", environmentId: string, path: string, }; +export type CapabilityRootLocation = { "type": "environment", environmentId: string, +/** + * Absolute path for the root in the selected environment. + */ +path: string, }; diff --git a/src/app-server/v2/CodexErrorInfo.ts b/src/app-server/v2/CodexErrorInfo.ts index 6e975abf..ec50328e 100644 --- a/src/app-server/v2/CodexErrorInfo.ts +++ b/src/app-server/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/src/app-server/v2/ConfigRequirements.ts b/src/app-server/v2/ConfigRequirements.ts index 3eb9a65a..e2fd573a 100644 --- a/src/app-server/v2/ConfigRequirements.ts +++ b/src/app-server/v2/ConfigRequirements.ts @@ -4,8 +4,9 @@ import type { WebSearchMode } from "../WebSearchMode"; import type { AskForApproval } from "./AskForApproval"; import type { ComputerUseRequirements } from "./ComputerUseRequirements"; +import type { ModelsRequirements } from "./ModelsRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, models: ModelsRequirements | null}; diff --git a/src/app-server/v2/ConsumeAccountRateLimitResetCreditParams.ts b/src/app-server/v2/ConsumeAccountRateLimitResetCreditParams.ts index c3cef64f..f1c5bf35 100644 --- a/src/app-server/v2/ConsumeAccountRateLimitResetCreditParams.ts +++ b/src/app-server/v2/ConsumeAccountRateLimitResetCreditParams.ts @@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = { * Identifies one logical reset attempt. A UUID is recommended; reuse the same value when * retrying that attempt. */ -idempotencyKey: string, }; +idempotencyKey: string, +/** + * Opaque reset-credit identifier to redeem. When omitted, the backend selects the next + * available credit. + */ +creditId?: string | null, }; diff --git a/src/app-server/v2/LoginAccountParams.ts b/src/app-server/v2/LoginAccountParams.ts index e6f1e2ed..1606f167 100644 --- a/src/app-server/v2/LoginAccountParams.ts +++ b/src/app-server/v2/LoginAccountParams.ts @@ -1,8 +1,9 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LoginAppBrand } from "./LoginAppBrand"; -export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", +export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, useHostedLoginSuccessPage?: boolean, appBrand?: LoginAppBrand | null, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", /** * Access token (JWT) supplied by the client. * This token is used for backend API requests and email extraction. diff --git a/src/app-server/v2/LoginAppBrand.ts b/src/app-server/v2/LoginAppBrand.ts new file mode 100644 index 00000000..c06d12ff --- /dev/null +++ b/src/app-server/v2/LoginAppBrand.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 LoginAppBrand = "codex" | "chatgpt"; diff --git a/src/app-server/v2/McpServerOauthLoginCompletedNotification.ts b/src/app-server/v2/McpServerOauthLoginCompletedNotification.ts index 592860ae..cfa66030 100644 --- a/src/app-server/v2/McpServerOauthLoginCompletedNotification.ts +++ b/src/app-server/v2/McpServerOauthLoginCompletedNotification.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type McpServerOauthLoginCompletedNotification = { name: string, success: boolean, error?: string, }; +export type McpServerOauthLoginCompletedNotification = { name: string, threadId: string | null, success: boolean, error?: string, }; diff --git a/src/app-server/v2/McpServerOauthLoginParams.ts b/src/app-server/v2/McpServerOauthLoginParams.ts index a61c3046..ff088b8d 100644 --- a/src/app-server/v2/McpServerOauthLoginParams.ts +++ b/src/app-server/v2/McpServerOauthLoginParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type McpServerOauthLoginParams = { name: string, scopes?: Array | null, timeoutSecs?: bigint | null, }; +export type McpServerOauthLoginParams = { name: string, threadId?: string | null, scopes?: Array | null, timeoutSecs?: bigint | null, }; diff --git a/src/app-server/v2/McpServerStartupFailureReason.ts b/src/app-server/v2/McpServerStartupFailureReason.ts new file mode 100644 index 00000000..0373e544 --- /dev/null +++ b/src/app-server/v2/McpServerStartupFailureReason.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 McpServerStartupFailureReason = "reauthenticationRequired"; diff --git a/src/app-server/v2/McpServerStatusUpdatedNotification.ts b/src/app-server/v2/McpServerStatusUpdatedNotification.ts index b5c0a905..fd192f22 100644 --- a/src/app-server/v2/McpServerStatusUpdatedNotification.ts +++ b/src/app-server/v2/McpServerStatusUpdatedNotification.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpServerStartupFailureReason } from "./McpServerStartupFailureReason"; import type { McpServerStartupState } from "./McpServerStartupState"; -export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, }; +export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, failureReason: McpServerStartupFailureReason | null, }; diff --git a/src/app-server/v2/McpToolCallAppContext.ts b/src/app-server/v2/McpToolCallAppContext.ts index 0a834342..e4bc5a11 100644 --- a/src/app-server/v2/McpToolCallAppContext.ts +++ b/src/app-server/v2/McpToolCallAppContext.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, }; +export type McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, appName: string | null, templateId: string | null, actionName: string | null, }; diff --git a/src/app-server/v2/MigrationDetails.ts b/src/app-server/v2/MigrationDetails.ts index 4fe87eab..21f77984 100644 --- a/src/app-server/v2/MigrationDetails.ts +++ b/src/app-server/v2/MigrationDetails.ts @@ -6,6 +6,7 @@ import type { HookMigration } from "./HookMigration"; import type { McpServerMigration } from "./McpServerMigration"; import type { PluginsMigration } from "./PluginsMigration"; import type { SessionMigration } from "./SessionMigration"; +import type { SkillMigration } from "./SkillMigration"; import type { SubagentMigration } from "./SubagentMigration"; -export type MigrationDetails = { plugins: Array, sessions: Array, mcpServers: Array, hooks: Array, subagents: Array, commands: Array, }; +export type MigrationDetails = { plugins: Array, skills: Array, sessions: Array, mcpServers: Array, hooks: Array, subagents: Array, commands: Array, }; diff --git a/src/app-server/v2/ModelSafetyBufferingUpdatedNotification.ts b/src/app-server/v2/ModelSafetyBufferingUpdatedNotification.ts index 484f8c61..5abc9f3b 100644 --- a/src/app-server/v2/ModelSafetyBufferingUpdatedNotification.ts +++ b/src/app-server/v2/ModelSafetyBufferingUpdatedNotification.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ModelSafetyBufferingUpdatedNotification = { threadId: string, turnId: string, model: string, useCases: Array, reasons: Array, }; +export type ModelSafetyBufferingUpdatedNotification = { threadId: string, turnId: string, model: string, useCases: Array, reasons: Array, showBufferingUi: boolean, fasterModel: string | null, }; diff --git a/src/app-server/v2/ModelsRequirements.ts b/src/app-server/v2/ModelsRequirements.ts new file mode 100644 index 00000000..9041fff8 --- /dev/null +++ b/src/app-server/v2/ModelsRequirements.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NewThreadModelDefaults } from "./NewThreadModelDefaults"; + +export type ModelsRequirements = { newThread: NewThreadModelDefaults | null, }; diff --git a/src/app-server/v2/NewThreadModelDefaults.ts b/src/app-server/v2/NewThreadModelDefaults.ts new file mode 100644 index 00000000..fed8b25e --- /dev/null +++ b/src/app-server/v2/NewThreadModelDefaults.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReasoningEffort } from "../ReasoningEffort"; + +export type NewThreadModelDefaults = { model: string | null, modelReasoningEffort: ReasoningEffort | null, serviceTier: string | null, }; diff --git a/src/app-server/v2/PluginInstallPolicySource.ts b/src/app-server/v2/PluginInstallPolicySource.ts new file mode 100644 index 00000000..caa39628 --- /dev/null +++ b/src/app-server/v2/PluginInstallPolicySource.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 PluginInstallPolicySource = "WORKSPACE_SETTING" | "IMPLICIT_CANONICAL_APP"; diff --git a/src/app-server/v2/PluginInterface.ts b/src/app-server/v2/PluginInterface.ts index 4e97ee66..1e57d497 100644 --- a/src/app-server/v2/PluginInterface.ts +++ b/src/app-server/v2/PluginInterface.ts @@ -21,10 +21,18 @@ composerIconUrl: string | null, * Local logo path, resolved from the installed plugin package. */ logo: AbsolutePathBuf | null, +/** + * Local dark-mode logo path, resolved from the installed plugin package. + */ +logoDark: AbsolutePathBuf | null, /** * Remote logo URL from the plugin catalog. */ logoUrl: string | null, +/** + * Remote dark-mode logo URL from the plugin catalog. + */ +logoUrlDark: string | null, /** * Local screenshot paths, resolved from the installed plugin package. */ diff --git a/src/app-server/v2/PluginSource.ts b/src/app-server/v2/PluginSource.ts index f6e86719..c7ba3bcf 100644 --- a/src/app-server/v2/PluginSource.ts +++ b/src/app-server/v2/PluginSource.ts @@ -3,4 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" }; +export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "npm", package: string, +/** + * Optional npm version or version range. + */ +version: string | null, +/** + * Optional HTTPS registry URL. Authentication stays in the user's npm config. + */ +registry: string | null, } | { "type": "remote" }; diff --git a/src/app-server/v2/PluginSummary.ts b/src/app-server/v2/PluginSummary.ts index 268349cb..10fd5aa2 100644 --- a/src/app-server/v2/PluginSummary.ts +++ b/src/app-server/v2/PluginSummary.ts @@ -4,6 +4,7 @@ import type { PluginAuthPolicy } from "./PluginAuthPolicy"; import type { PluginAvailability } from "./PluginAvailability"; import type { PluginInstallPolicy } from "./PluginInstallPolicy"; +import type { PluginInstallPolicySource } from "./PluginInstallPolicySource"; import type { PluginInterface } from "./PluginInterface"; import type { PluginShareContext } from "./PluginShareContext"; import type { PluginSource } from "./PluginSource"; @@ -13,6 +14,10 @@ export type PluginSummary = { id: string, * Backend remote plugin identifier when available. */ remotePluginId: string | null, +/** + * Version advertised by the remote marketplace backend when available. + */ +version: string | null, /** * Version of the locally materialized plugin package when available. */ @@ -20,7 +25,7 @@ localVersion: string | null, name: string, /** * Remote sharing context associated with this plugin when available. */ -shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, +shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, installPolicySource: PluginInstallPolicySource | null, authPolicy: PluginAuthPolicy, /** * Availability state for installing and using the plugin. */ diff --git a/src/app-server/v2/RateLimitResetCredit.ts b/src/app-server/v2/RateLimitResetCredit.ts new file mode 100644 index 00000000..514c1558 --- /dev/null +++ b/src/app-server/v2/RateLimitResetCredit.ts @@ -0,0 +1,27 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus"; +import type { RateLimitResetType } from "./RateLimitResetType"; + +export type RateLimitResetCredit = { +/** + * Opaque backend identifier for this reset credit. + */ +id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus, +/** + * Unix timestamp in seconds when the credit was granted. + */ +grantedAt: number, +/** + * Unix timestamp in seconds when the credit expires, or `null` if it does not expire. + */ +expiresAt: number | null, +/** + * Backend-provided display title for this credit, or `null` when unavailable. + */ +title: string | null, +/** + * Backend-provided display description for this credit, or `null` when unavailable. + */ +description: string | null, }; diff --git a/src/app-server/v2/RateLimitResetCreditStatus.ts b/src/app-server/v2/RateLimitResetCreditStatus.ts new file mode 100644 index 00000000..fa15861b --- /dev/null +++ b/src/app-server/v2/RateLimitResetCreditStatus.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 RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown"; diff --git a/src/app-server/v2/RateLimitResetCreditsSummary.ts b/src/app-server/v2/RateLimitResetCreditsSummary.ts index e42dd8a7..46a8eee2 100644 --- a/src/app-server/v2/RateLimitResetCreditsSummary.ts +++ b/src/app-server/v2/RateLimitResetCreditsSummary.ts @@ -1,5 +1,14 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RateLimitResetCredit } from "./RateLimitResetCredit"; -export type RateLimitResetCreditsSummary = { availableCount: bigint, }; +export type RateLimitResetCreditsSummary = { availableCount: bigint, +/** + * Detail rows for available reset credits, when the backend provides them. + * + * `null` means only `availableCount` is known, while an empty array means details were fetched + * and no available credits were returned. The backend may cap this list, so its length can be + * less than `availableCount`. + */ +credits: Array | null, }; diff --git a/src/app-server/v2/RateLimitResetType.ts b/src/app-server/v2/RateLimitResetType.ts new file mode 100644 index 00000000..718145bf --- /dev/null +++ b/src/app-server/v2/RateLimitResetType.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 RateLimitResetType = "codexRateLimits" | "unknown"; diff --git a/src/app-server/v2/SkillMigration.ts b/src/app-server/v2/SkillMigration.ts new file mode 100644 index 00000000..0555ffc8 --- /dev/null +++ b/src/app-server/v2/SkillMigration.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 SkillMigration = { name: string, }; diff --git a/src/app-server/v2/Thread.ts b/src/app-server/v2/Thread.ts index 1c288ccb..49e2411f 100644 --- a/src/app-server/v2/Thread.ts +++ b/src/app-server/v2/Thread.ts @@ -8,87 +8,70 @@ import type { ThreadSource } from "./ThreadSource"; import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; -export type Thread = { id: string, -/** +export type Thread = {/** + * Identifier for this thread. Codex-generated thread IDs are UUIDv7. + */ +id: string, /** * Session id shared by threads that belong to the same session tree. */ -sessionId: string, -/** +sessionId: string, /** * Source thread id when this thread was created by forking another thread. */ -forkedFromId: string | null, -/** +forkedFromId: string | null, /** * The ID of the parent thread. This will only be set if this thread is a subagent. */ -parentThreadId: string | null, -/** +parentThreadId: string | null, /** * Usually the first user message in the thread, if available. */ -preview: string, -/** +preview: string, /** * Whether the thread is ephemeral and should not be materialized on disk. */ -ephemeral: boolean, -/** +ephemeral: boolean, /** * Model provider used for this thread (for example, 'openai'). */ -modelProvider: string, -/** +modelProvider: string, /** * Unix timestamp (in seconds) when the thread was created. */ -createdAt: number, -/** +createdAt: number, /** * Unix timestamp (in seconds) when the thread was last updated. */ -updatedAt: number, -/** +updatedAt: number, /** * Unix timestamp (in seconds) used for thread recency ordering. */ -recencyAt: number | null, -/** +recencyAt: number | null, /** * Current runtime status for the thread. */ -status: ThreadStatus, -/** +status: ThreadStatus, /** * [UNSTABLE] Path to the thread on disk. */ -path: string | null, -/** +path: string | null, /** * Working directory captured for the thread. */ -cwd: AbsolutePathBuf, -/** +cwd: AbsolutePathBuf, /** * Version of the CLI that created the thread. */ -cliVersion: string, -/** +cliVersion: string, /** * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ -source: SessionSource, -/** +source: SessionSource, /** * Optional analytics source classification for this thread. */ -threadSource: ThreadSource | null, -/** +threadSource: ThreadSource | null, /** * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. */ -agentNickname: string | null, -/** +agentNickname: string | null, /** * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. */ -agentRole: string | null, -/** +agentRole: string | null, /** * Optional Git metadata captured when the thread was created. */ -gitInfo: GitInfo | null, -/** +gitInfo: GitInfo | null, /** * Optional user-facing thread title. */ -name: string | null, -/** +name: string | null, /** * Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` * (when `includeTurns` is true) responses. * For all other responses and notifications returning a Thread, * the turns field will be an empty list. */ -turns: Array, }; +turns: Array}; diff --git a/src/app-server/v2/ThreadExtra.ts b/src/app-server/v2/ThreadExtra.ts new file mode 100644 index 00000000..aa35e45f --- /dev/null +++ b/src/app-server/v2/ThreadExtra.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Extra app-server data for a thread. + */ +export type ThreadExtra = Record; diff --git a/src/app-server/v2/ThreadForkParams.ts b/src/app-server/v2/ThreadForkParams.ts index c5109b2c..3ace4d44 100644 --- a/src/app-server/v2/ThreadForkParams.ts +++ b/src/app-server/v2/ThreadForkParams.ts @@ -18,6 +18,12 @@ import type { ThreadSource } from "./ThreadSource"; * Prefer using thread_id whenever possible. */ export type ThreadForkParams = {threadId: string, /** + * Optional last turn id to fork through, inclusive. + * + * When specified, turns after `last_turn_id` are omitted from the fork. + * The referenced turn cannot be in progress. + */ +lastTurnId?: string | null, /** * Configuration overrides for the forked thread, if any. */ model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** diff --git a/src/app-server/v2/ThreadHistoryMode.ts b/src/app-server/v2/ThreadHistoryMode.ts new file mode 100644 index 00000000..db0f2d82 --- /dev/null +++ b/src/app-server/v2/ThreadHistoryMode.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 ThreadHistoryMode = "legacy" | "paginated"; diff --git a/src/app-server/v2/ThreadItem.ts b/src/app-server/v2/ThreadItem.ts index eb8c6486..a4453855 100644 --- a/src/app-server/v2/ThreadItem.ts +++ b/src/app-server/v2/ThreadItem.ts @@ -1,10 +1,11 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { ImageGenerationItem } from "../ImageGenerationItem"; import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { WebSearchItem } from "../WebSearchItem"; import type { JsonValue } from "../serde_json/JsonValue"; import type { CollabAgentState } from "./CollabAgentState"; import type { CollabAgentTool } from "./CollabAgentTool"; @@ -24,7 +25,6 @@ import type { MemoryCitation } from "./MemoryCitation"; import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { SubAgentActivityKind } from "./SubAgentActivityKind"; import type { UserInput } from "./UserInput"; -import type { WebSearchAction } from "./WebSearchAction"; export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, 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, /** @@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/src/app-server/v2/ThreadRollbackParams.ts b/src/app-server/v2/ThreadRollbackParams.ts index 1c938e3b..af416d18 100644 --- a/src/app-server/v2/ThreadRollbackParams.ts +++ b/src/app-server/v2/ThreadRollbackParams.ts @@ -2,6 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * DEPRECATED: `thread/rollback` will be removed soon. + */ export type ThreadRollbackParams = { threadId: string, /** * The number of turns to drop from the end of the thread. Must be >= 1. diff --git a/src/app-server/v2/Turn.ts b/src/app-server/v2/Turn.ts index 6505ec34..b8680256 100644 --- a/src/app-server/v2/Turn.ts +++ b/src/app-server/v2/Turn.ts @@ -6,7 +6,11 @@ import type { TurnError } from "./TurnError"; import type { TurnItemsView } from "./TurnItemsView"; import type { TurnStatus } from "./TurnStatus"; -export type Turn = { id: string, +export type Turn = { +/** + * Identifier for this turn. Codex-generated turn IDs are UUIDv7. + */ +id: string, /** * Thread items currently included in this turn payload. */ diff --git a/src/app-server/v2/index.ts b/src/app-server/v2/index.ts index bc31a10a..ab1d684c 100644 --- a/src/app-server/v2/index.ts +++ b/src/app-server/v2/index.ts @@ -216,6 +216,7 @@ export type { ListMcpServersParams } from "./ListMcpServersParams"; export type { ListMcpServersResponse } from "./ListMcpServersResponse"; export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; +export type { LoginAppBrand } from "./LoginAppBrand"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; export type { ManagedHooksRequirements } from "./ManagedHooksRequirements"; export type { MarketplaceAddParams } from "./MarketplaceAddParams"; @@ -261,6 +262,7 @@ export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthL export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams"; export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse"; export type { McpServerRefreshResponse } from "./McpServerRefreshResponse"; +export type { McpServerStartupFailureReason } from "./McpServerStartupFailureReason"; export type { McpServerStartupState } from "./McpServerStartupState"; export type { McpServerStatus } from "./McpServerStatus"; export type { McpServerStatusDetail } from "./McpServerStatusDetail"; @@ -289,6 +291,7 @@ export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { ModelVerification } from "./ModelVerification"; export type { ModelVerificationNotification } from "./ModelVerificationNotification"; +export type { ModelsRequirements } from "./ModelsRequirements"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; @@ -297,6 +300,7 @@ export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; +export type { NewThreadModelDefaults } from "./NewThreadModelDefaults"; export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; @@ -319,6 +323,7 @@ export type { PluginDetail } from "./PluginDetail"; export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; +export type { PluginInstallPolicySource } from "./PluginInstallPolicySource"; export type { PluginInstallResponse } from "./PluginInstallResponse"; export type { PluginInstalledParams } from "./PluginInstalledParams"; export type { PluginInstalledResponse } from "./PluginInstalledResponse"; @@ -362,7 +367,10 @@ export type { ProcessTerminalSize } from "./ProcessTerminalSize"; export type { ProductSurface } from "./ProductSurface"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitReachedType } from "./RateLimitReachedType"; +export type { RateLimitResetCredit } from "./RateLimitResetCredit"; +export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus"; export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary"; +export type { RateLimitResetType } from "./RateLimitResetType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; @@ -399,6 +407,7 @@ export type { SkillDependencies } from "./SkillDependencies"; export type { SkillErrorInfo } from "./SkillErrorInfo"; export type { SkillInterface } from "./SkillInterface"; export type { SkillMetadata } from "./SkillMetadata"; +export type { SkillMigration } from "./SkillMigration"; export type { SkillRequestApprovalParams } from "./SkillRequestApprovalParams"; export type { SkillRequestApprovalResponse } from "./SkillRequestApprovalResponse"; export type { SkillScope } from "./SkillScope"; @@ -440,6 +449,7 @@ export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadDeleteParams } from "./ThreadDeleteParams"; export type { ThreadDeleteResponse } from "./ThreadDeleteResponse"; export type { ThreadDeletedNotification } from "./ThreadDeletedNotification"; +export type { ThreadExtra } from "./ThreadExtra"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; export type { ThreadGoal } from "./ThreadGoal"; @@ -452,6 +462,7 @@ export type { ThreadGoalSetParams } from "./ThreadGoalSetParams"; export type { ThreadGoalSetResponse } from "./ThreadGoalSetResponse"; export type { ThreadGoalStatus } from "./ThreadGoalStatus"; export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification"; +export type { ThreadHistoryMode } from "./ThreadHistoryMode"; export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams"; export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse"; export type { ThreadItem } from "./ThreadItem"; diff --git a/src/index.ts b/src/index.ts index 2ddc214e..5f8a83dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,12 +12,7 @@ import packageJson from "../package.json"; import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; -import { - LEGACY_SET_SESSION_MODEL_METHOD, - THREAD_GOAL_CLEAR_METHOD, - THREAD_GOAL_GET_METHOD, - THREAD_GOAL_SET_METHOD, -} from "./AcpExtensions"; +import {LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -29,17 +24,6 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); -const threadGoalParamsParser = z.object({ - threadId: z.string(), -}).passthrough(); - -const threadGoalSetParamsParser = z.object({ - threadId: z.string(), - objective: z.string().nullable().optional(), - status: z.enum(["active", "paused", "blocked", "usageLimited", "budgetLimited", "complete"]).nullable().optional(), - tokenBudget: z.number().nullable().optional(), -}).passthrough(); - if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -92,7 +76,7 @@ function startAcpServer() { stderr = (stderr + data.toString()).slice(-maxStderrTailChars); }); - process.stdin.on("close", (chunk: Buffer) => { + process.stdin.on("close", () => { codexConnection.process.stdin.end(); // Kill the codex process if it doesn't exit naturally setTimeout(() => { @@ -140,13 +124,10 @@ function startAcpServer() { .onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)) .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params)) .onRequest(acp.methods.agent.logout, (ctx) => getAgent().logout(ctx.params)) - .onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params)) + .onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal)) .onNotification(acp.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params)) .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) - .onRequest(THREAD_GOAL_GET_METHOD, threadGoalParamsParser, (ctx) => getAgent().extMethod(THREAD_GOAL_GET_METHOD, ctx.params)) - .onRequest(THREAD_GOAL_SET_METHOD, threadGoalSetParamsParser, (ctx) => getAgent().extMethod(THREAD_GOAL_SET_METHOD, ctx.params)) - .onRequest(THREAD_GOAL_CLEAR_METHOD, threadGoalParamsParser, (ctx) => getAgent().extMethod(THREAD_GOAL_CLEAR_METHOD, ctx.params)) .connect(acpJsonStream); } diff --git a/tsconfig.json b/tsconfig.json index 1d21f510..39a4fd81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "module": "ES2022", "moduleResolution": "Bundler", "target": "ES2022", + "types": ["node"], "sourceMap": true, "declaration": true,