diff --git a/.github/test-baseline.txt b/.github/test-baseline.txt deleted file mode 100644 index 7bf1b94bdd..0000000000 --- a/.github/test-baseline.txt +++ /dev/null @@ -1,36 +0,0 @@ -# Temporary file-level baseline for suites that fail when run in isolation. -# Any failure outside this list fails CI. Remove entries as their suites are fixed. -src/commands/knowledge/knowledge.test.ts -src/components/CostThresholdDialog.test.ts -src/components/PromptInput/PromptInputQueuedCommands.test.tsx -src/hooks/useApiKeyVerification.test.tsx -src/integrations/discoveryService.test.ts -src/services/api/client.test.ts -src/services/api/openaiShim.diagnostics.test.ts -src/services/api/withRetry.test.ts -src/services/oauth/purchaseFlow.ui.test.tsx -src/services/tips/sponsoredTips.test.ts -src/services/tips/tipScheduler.test.ts -src/tools/AgentTool/loadAgentsDir.test.ts -src/tools/WebFetchTool/domainCheck.test.ts -src/utils/conversationArc.perf.test.ts -src/utils/conversationArc.test.ts -src/utils/conversationRecovery.test.ts -src/utils/fastMode.test.ts -src/utils/geminiAuth.test.ts -src/utils/knowledgeGraph.test.ts -src/utils/model/agent.test.ts -src/utils/model/model.github.test.ts -src/utils/model/model.openai-shim-providers.test.ts -src/utils/model/modelOptions.github.test.ts -src/utils/model/modelStrings.github.test.ts -src/utils/model/providers.test.ts -src/utils/providerFlag.test.ts -src/utils/providerProfile.test.ts -src/utils/providerProfiles.test.ts -src/utils/storage/SQLiteMasterpiece.test.ts -src/utils/storage/SQLiteProvider.test.ts -tests/sdk/query-lifecycle.test.ts -tests/sdk/sdk-preserved-segment.test.ts -tests/sdk/sdk-v2-lifecycle.test.ts -tests/sdk/session-functions.test.ts diff --git a/.github/workflows/cli-quality.yml b/.github/workflows/cli-quality.yml new file mode 100644 index 0000000000..a98dd49c7a --- /dev/null +++ b/.github/workflows/cli-quality.yml @@ -0,0 +1,104 @@ +name: CLI Quality Gate + +on: + workflow_call: + inputs: + ref: + type: string + default: '' + +permissions: + contents: read + +jobs: + suite: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ inputs.ref }} + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version-file: .bun-version + - run: bun install --frozen-lockfile + - run: bun scripts/check-cli-quality.ts --suite + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: cli-unit-evidence + path: .artifacts/test-results + if-no-files-found: error + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: tested-npm-package + path: | + .artifacts/package/*.tgz + .artifacts/package/sha256.txt + .artifacts/package/package-info.json + if-no-files-found: error + + terminal: + needs: suite + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-latest] + node: [22, 24] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ inputs.ref }} + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: ${{ matrix.node }} + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version-file: .bun-version + - run: bun install --frozen-lockfile + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: tested-npm-package + path: .artifacts/package + - run: bun scripts/check-cli-quality.ts --terminal + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: cli-terminal-${{ matrix.os }}-node${{ matrix.node }} + path: .artifacts/pty + if-no-files-found: error + + python: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ inputs.ref }} + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: '3.12' + - run: python -m pip install -r python/requirements.txt + - run: python -m pytest -q python/tests + + web: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ inputs.ref }} + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version-file: .bun-version + - run: bun install --cwd web --frozen-lockfile + - run: bun run --cwd web typecheck + - run: bun run --cwd web build diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index a524ffab67..a012fd9718 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -10,12 +10,22 @@ permissions: contents: read jobs: + cli-quality: + uses: ./.github/workflows/cli-quality.yml + # Keep this check name stable for existing branch-protection rules. smoke-and-tests: name: smoke-and-tests + needs: cli-quality + if: ${{ always() }} runs-on: blacksmith-4vcpu-ubuntu-2404 steps: + - name: Require the complete quality gate + env: + QUALITY_RESULT: ${{ needs.cli-quality.result }} + run: test "$QUALITY_RESULT" = success + - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28e83c9407..60bea699a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,45 +73,15 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run release-critical tests - run: | - bun run desktop:test - bun test --max-concurrency=1 \ - src/Tool.test.ts \ - src/__tests__/bugfixes-behavioral.test.ts \ - src/commands/effort/effort.verboo.test.ts \ - src/commands/logout/logoutState.test.ts \ - src/components/FreeTokenActivation.test.tsx \ - src/services/api/boundedResponseBody.test.ts \ - src/services/api/codexShim.test.ts \ - src/services/api/openaiProtocolReliability.test.ts \ - src/services/api/openaiErrorClassification.test.ts \ - src/services/api/openaiShim.test.ts \ - src/services/api/verbooCheckout.test.ts \ - src/services/api/verbooModels.test.ts \ - src/services/oauth/purchaseFlow.test.ts \ - src/services/oauth/cliEntitlement.test.ts \ - src/services/oauth/freeTokenActivation.test.ts \ - src/services/oauth/pastDueFlow.test.tsx \ - src/services/oauth/subscriptionAccess.test.ts \ - src/services/oauth/verbooStartupAuth.test.ts \ - src/utils/auth.refresh.test.ts \ - src/utils/messages.toolNameNormalization.test.ts - - - name: Smoke test - run: bun run smoke - - - name: Build release artifacts - run: bun run build - - - name: Verify npm package contents - run: | - node dist/cli.mjs --internal-protocol-self-test - npm pack --dry-run + cli-quality: + needs: verify + uses: ./.github/workflows/cli-quality.yml + with: + ref: ${{ needs.verify.outputs.tag }} publish-npm: name: Publish npm package - needs: verify + needs: [verify, cli-quality] if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} # npm provenance only supports GitHub-hosted runners. runs-on: ubuntu-latest @@ -134,17 +104,14 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - - name: Set up Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: Download the tested npm artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - bun-version-file: .bun-version + name: tested-npm-package + path: .artifacts/package - - name: Install and build - run: | - bun install --frozen-lockfile - bun run build - node dist/cli.mjs --internal-protocol-self-test - npm pack --dry-run + - name: Verify tested artifact checksum + run: node scripts/prepare-cli-package.mjs --verify-only - name: Clear token auth for trusted publishing run: | @@ -152,7 +119,8 @@ jobs: echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV" - name: Publish to npm - run: npm publish --access public --provenance + working-directory: .artifacts/package + run: npm publish ./verboo-code-*.tgz --ignore-scripts --access public --provenance - name: Release summary env: @@ -167,7 +135,7 @@ jobs: docker: name: Build and push Docker image - needs: verify + needs: [verify, cli-quality] if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: @@ -223,7 +191,7 @@ jobs: desktop-cli-artifacts: name: Build desktop CLI (${{ matrix.target }}) - needs: verify + needs: [verify, cli-quality] if: ${{ github.repository == 'verbeux-ai/code' }} runs-on: ${{ matrix.runner }} strategy: @@ -281,6 +249,7 @@ jobs: name: Sign and publish desktop CLI assets needs: - verify + - cli-quality - desktop-cli-artifacts if: ${{ github.repository == 'verbeux-ai/code' }} runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index 72528abf4b..8b932b2b6b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ package-lock.json coverage/ agent.log plan/ + +.artifacts/ diff --git a/README.md b/README.md index 8498c572fe..4a5139fdb6 100644 --- a/README.md +++ b/README.md @@ -194,9 +194,21 @@ Verboo Code uses Bun's built-in test runner for unit tests. Run the full unit suite: ```bash -bun test +bun run test:isolated ``` +Run the release quality gate with the Bun version pinned in `.bun-version` and +Node 22 or 24: + +```bash +bun run test:quality +``` + +This builds and installs the npm package in an independent consumer directory, +runs every isolated test file, then exercises the installed CLI in real PTYs +against a local HTTP/SSE fixture. See [CLI quality checks](docs/cli-quality.md) +for the test matrix, artifacts and release requirements. + Generate unit test coverage: ```bash diff --git a/bun.lock b/bun.lock index 71a774ec5e..1f44bc2377 100644 --- a/bun.lock +++ b/bun.lock @@ -90,9 +90,14 @@ "zod": "3.25.76", }, "devDependencies": { + "@mswjs/interceptors": "0.42.4", "@types/bun": "1.3.11", "@types/node": "25.5.0", "@types/react": "19.2.14", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/headless": "6.0.0", + "node-gyp": "11.4.2", + "node-pty": "1.1.0", "tsx": "^4.21.0", "typescript": "5.9.3", }, @@ -317,6 +322,10 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], "@mendable/firecrawl-js": ["@mendable/firecrawl-js@4.23.0", "", { "dependencies": { "axios": "1.15.2", "firecrawl": "4.16.0", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-xpA5dX3viZTgZMQIz4xdS8KvaY7KaXP8IQ8kEjs1CpUIlWHkXU3vcngrGLB1b44BCQULaHb+HepubmZSjF74ig=="], @@ -327,8 +336,16 @@ "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.42.4", "", { "dependencies": { "@open-draft/until": "^3.0.1", "@types/debug": "^4.1.13", "debug": "^4.4.3", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "rettime": "^0.11.11" } }, "sha512-cPUjmo3alefjtpYVKrvXsRiGzzfjlwCrIdg751/sy9Q55ZI/7lvyARIsuyVO2v2KljMHfxyW1QCmxI9bTgLYkg=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], + + "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + + "@open-draft/until": ["@open-draft/until@3.0.1", "", {}, "sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], @@ -363,6 +380,8 @@ "@orama/plugin-data-persistence": ["@orama/plugin-data-persistence@3.1.18", "", { "dependencies": { "@msgpack/msgpack": "^3.1.2", "@orama/orama": "3.1.18", "dpack": "^0.6.22", "seqproto": "^0.2.3" } }, "sha512-pfBbpK96VRW/7IkdMHn2HaW3/+4k2C9Uwyup0IONNuz5bG3L1orCNFZPBmu+zcokOU2YH+IAVuQz6MlvqOe3iw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -477,10 +496,14 @@ "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], "@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -511,6 +534,12 @@ "@vscode/ripgrep-win32-x64": ["@vscode/ripgrep-win32-x64@1.18.0", "", { "os": "win32", "cpu": "x64" }, "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg=="], + "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="], + + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], + + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -531,6 +560,8 @@ "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], @@ -541,12 +572,16 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -557,6 +592,8 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], @@ -613,6 +650,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -621,8 +660,12 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -647,6 +690,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="], @@ -661,6 +706,8 @@ "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], @@ -673,12 +720,16 @@ "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -701,6 +752,8 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], @@ -725,8 +778,12 @@ "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -735,6 +792,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -745,6 +804,8 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -753,7 +814,9 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -785,6 +848,8 @@ "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -797,6 +862,22 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -805,8 +886,16 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-gyp": ["node-gyp@11.4.2", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-3gD+6zsrLQH7DyYOUIutaauuXrcyxeTPyQuZQCQoNPZMHMMS5m4y0xclNpvYzoK3VNzuyxT6eF4mkIL4WSZ1eQ=="], + + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + + "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -817,6 +906,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], @@ -825,6 +916,8 @@ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], @@ -839,6 +932,8 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -849,6 +944,10 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "protobufjs": ["protobufjs@7.5.8", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA=="], @@ -883,6 +982,8 @@ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -923,14 +1024,26 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], @@ -939,10 +1052,14 @@ "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -971,6 +1088,10 @@ "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], + + "unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], @@ -989,12 +1110,14 @@ "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], @@ -1005,6 +1128,8 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1293,6 +1418,12 @@ "@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="], @@ -1391,6 +1522,8 @@ "@smithy/util-utf8/@smithy/core": ["@smithy/core@3.24.1", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g=="], + "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], @@ -1401,20 +1534,48 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "needle/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "xss/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1599,6 +1760,8 @@ "@aws-sdk/token-providers/@aws-sdk/nested-clients/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="], @@ -1715,8 +1878,16 @@ "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1725,6 +1896,14 @@ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], diff --git a/docs/cli-quality.md b/docs/cli-quality.md new file mode 100644 index 0000000000..1e5e617cc9 --- /dev/null +++ b/docs/cli-quality.md @@ -0,0 +1,78 @@ +# CLI quality checks + +The shared `.github/workflows/cli-quality.yml` workflow is required by PR checks +and every release publication job. No test-failure baseline is accepted. +The existing `smoke-and-tests` check explicitly fails when this gate fails or is +cancelled; it cannot become a successful skipped check after a dependency fails. + +## Agent consumption and completion + +- `totalTokens`, `tokenCount`, `usage.total_tokens` and cost accounting contain + confirmed consumption only. Estimates never enter billing fields. +- Optional `tokenUsage` / SDK `usage.token_usage` carries confirmed and estimated + portions separately, plus `pending`, `estimated` or `reported` state. +- Before output arrives, show `Awaiting response`. During unreported streaming, + show `~N tokens`. Complete official usage replaces the approximation, including + an explicitly reported zero. Missing or partial usage remains approximate. +- Count input, output, cache reads and cache creation once per response attempt. + Split assistant records, repeated cumulative usage and replayed tool calls must + not duplicate consumption. Failed attempts and partial output remain visible. +- Progress updates are coalesced at 100 ms, with a final flush on all exit paths. + SDK output wakes independently of the parent query yielding another message. +- Metadata writes are atomic and serialized. An execution identifier prevents a + late callback from overwriting a resumed agent's metadata or task state. +- Display success, provider failure, user interruption and execution-budget + limits distinctly. Closing task details must not cancel running agents. + +## Automated coverage + +| Layer | Checks | +| --- | --- | +| Isolated suites | Every tracked or new non-ignored `.test.ts`, `.test.tsx`, `.test.js`, `.test.mjs` file, including SDK, extension and desktop contracts | +| Runtime contracts | Late/partial/zero usage, retries, split messages, replay, background progress, cancellation, stale executions, persisted metadata | +| Stress | Seeded 10,000-event streams, 1/2/8/20 agents, bounded SDK progress queues with start and final events preserved | +| Rendered terminal | Compare changing Ink output against a Unicode-aware VT screen, with bounded group height and input retained | +| Installed CLI | Real npm tarball, actual agent/query/auth/parser paths, local HTTP/SSE server, PTY input and resize; SDK streaming JSON through ordinary stdin/stdout pipes | +| Terminal matrix | Linux, macOS and Windows; Node 22 and 24; 40×12, 80×24 and 120×40; 1/2/8/20 agents; normal and fullscreen | +| Interaction/error cases | Missing/partial/explicit-zero usage, background completion, task menu/detail, draft preservation, resize, Esc, provider failure, configured maxTurns, streaming JSON | +| Other components | Python tests, web typecheck/build, existing native desktop checks | + +The terminal fixture uses a fresh project and configuration, synthetic OAuth, +strict empty MCP configuration, disabled plugin installation and no provider +credentials. Only the HTTP destination is redirected; the production CLI bundle +is installed unchanged. Unexpected external HTTP attempts fail the fixture. +Use of real inference services is not required. + +The scoped `tsconfig.agent-contracts.json` checks the new accounting, schema, +layout and runner contracts in strict mode. Repository-wide TypeScript debt from +the incomplete source type snapshot is a separate migration; this scoped check +does not claim that the global typecheck passes. + +## Running and reviewing + +```bash +bun install --frozen-lockfile +bun run test:quality +``` + +`CLI_TEST_NODE` can select a specific Node executable. The command checks that +the executing Bun matches `.bun-version`. `bun run test:isolated -- path/to/file.test.ts` +selects individual suites; a directory filter must end in `/`. Unknown filters +fail rather than silently running no tests. + +Each isolated suite has its own process, configuration and temporary directory, +with a default 180-second deadline and process-tree cleanup. JSON and JUnit +results are saved in `.artifacts/test-results`. PTY captures, screen frames, +fixture requests and debug logs are saved in `.artifacts/pty` and uploaded on +both success and failure. These generated directories are git-ignored. +The temporary consumer installation is outside the repository; its location is +recorded in `.artifacts/package/consumer-path.txt` so it can be inspected or removed. + +The suite job builds and packs once. All six terminal jobs install that same +tarball. Publication downloads it, verifies its SHA-256 checksum and publishes +the tarball with lifecycle scripts disabled, without rebuilding it. Docker and +desktop publication also depend on the complete gate; native desktop builds +retain their platform checks and mandatory Minisign signing. + +Passing a local run validates that host only. The Linux/macOS/Windows matrix +must finish successfully in CI before release publication is enabled. diff --git a/package.json b/package.json index b9be73b98c..1a456a61d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@verboo/code", - "version": "0.15.20", + "version": "0.15.21", "description": "Verboo Code — coding agent for the Verboo platform", "type": "module", "bin": { @@ -73,7 +73,10 @@ "doctor:report": "bun run scripts/system-check.ts --out reports/doctor-runtime.json", "hardening:check": "bun run smoke && bun run doctor:runtime", "hardening:strict": "bun run typecheck && bun run hardening:check", - "prepack": "npm run build" + "prepack": "npm run build", + "test:quality": "bun scripts/check-cli-quality.ts", + "test:pty": "node --test --test-concurrency=1 scripts/e2e/cli.e2e.mjs", + "typecheck:agents": "tsc --project tsconfig.agent-contracts.json" }, "dependencies": { "@alcalzone/ansi-tokenize": "0.3.0", @@ -162,9 +165,14 @@ "zod": "3.25.76" }, "devDependencies": { + "@mswjs/interceptors": "0.42.4", "@types/bun": "1.3.11", "@types/node": "25.5.0", "@types/react": "19.2.14", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/headless": "6.0.0", + "node-gyp": "11.4.2", + "node-pty": "1.1.0", "tsx": "^4.21.0", "typescript": "5.9.3" }, diff --git a/scripts/check-cli-quality.ts b/scripts/check-cli-quality.ts new file mode 100644 index 0000000000..65d5c3dc12 --- /dev/null +++ b/scripts/check-cli-quality.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' + +const pinnedBun = readFileSync('.bun-version', 'utf8').trim() +if (Bun.version !== pinnedBun) throw new Error(`Quality checks require Bun ${pinnedBun}; got ${Bun.version}`) +const node = process.env.CLI_TEST_NODE || 'node' +function run(command: string[]) { + const result = Bun.spawnSync(command, { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env: process.env }) + if (result.exitCode !== 0) process.exit(result.exitCode || 1) +} +const terminalOnly = process.argv.includes('--terminal') +if (!terminalOnly) { + run([process.execPath, 'scripts/build.ts']) + run([node, 'node_modules/typescript/bin/tsc', '--project', 'tsconfig.agent-contracts.json']) + run([process.execPath, 'scripts/run-tests-isolated.ts']) + run([node, 'dist/cli.mjs', '--version']) + run([node, 'dist/cli.mjs', '--internal-protocol-self-test']) + run([node, 'scripts/prepare-cli-package.mjs', '--pack-only']) +} +if (!process.argv.includes('--suite')) { + run([node, 'scripts/setup-pty.mjs']) + run([node, 'scripts/prepare-cli-package.mjs', '--install-only']) + run([node, '--test', '--test-name-pattern=installed CLI: 1 agents, 80x24, fullscreen=false', 'scripts/e2e/cli.e2e.mjs']) + run([node, '--test', '--test-concurrency=1', 'scripts/e2e/cli.e2e.mjs']) +} diff --git a/scripts/cli-quality-workflow.test.ts b/scripts/cli-quality-workflow.test.ts new file mode 100644 index 0000000000..7581ca704f --- /dev/null +++ b/scripts/cli-quality-workflow.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from 'bun:test' +import { readFileSync, existsSync } from 'node:fs' +import { parse } from 'yaml' + +test('PRs and every publication surface require the shared quality gate', () => { + const pr = parse(readFileSync('.github/workflows/pr-checks.yml', 'utf8')) + const release = parse(readFileSync('.github/workflows/release.yml', 'utf8')) + expect(pr.jobs['cli-quality'].uses).toBe('./.github/workflows/cli-quality.yml') + expect(pr.jobs['smoke-and-tests'].needs).toBe('cli-quality') + expect(pr.jobs['smoke-and-tests'].if).toBe('${{ always() }}') + expect(pr.jobs['smoke-and-tests'].steps[0].env.QUALITY_RESULT).toBe('${{ needs.cli-quality.result }}') + expect(pr.jobs['smoke-and-tests'].steps[0].run).toBe('test "$QUALITY_RESULT" = success') + expect(release.jobs['cli-quality'].uses).toBe(pr.jobs['cli-quality'].uses) + expect(release.jobs['cli-quality'].with.ref).toBe('${{ needs.verify.outputs.tag }}') + for (const job of ['publish-npm', 'docker', 'desktop-cli-artifacts', 'publish-desktop-cli']) { + expect(release.jobs[job].needs).toContain('cli-quality') + } + const npm = release.jobs['publish-npm'].steps + expect(npm.some(step => step.with?.name === 'tested-npm-package')).toBe(true) + expect(npm.some(step => step.run?.includes('--verify-only'))).toBe(true) + const publish = npm.find(step => step.name === 'Publish to npm') + expect(publish.run).toContain('*.tgz --ignore-scripts') + expect(npm.some(step => /bun run build|npm run build|npm pack/.test(step.run ?? ''))).toBe(false) + expect(existsSync('.github/test-baseline.txt')).toBe(false) +}) + +test('the release package is exercised on Linux, macOS and Windows with both supported Node lines', () => { + const workflow = parse(readFileSync('.github/workflows/cli-quality.yml', 'utf8')) + expect(workflow.jobs.terminal.strategy.matrix).toEqual({ os: ['ubuntu-24.04', 'macos-15', 'windows-latest'], node: [22, 24] }) + expect(workflow.jobs.terminal.strategy['fail-fast']).toBe(false) + expect(workflow.jobs.terminal.needs).toBe('suite') + for (const job of Object.values(workflow.jobs) as Array<{ steps: Array<{ 'continue-on-error'?: boolean }> }>) { + expect(job.steps.some(step => step['continue-on-error'])).toBe(false) + } + expect(workflow.jobs.python).toBeDefined() + expect(workflow.jobs.web).toBeDefined() +}) diff --git a/scripts/e2e/cli.e2e.mjs b/scripts/e2e/cli.e2e.mjs new file mode 100644 index 0000000000..333241e67c --- /dev/null +++ b/scripts/e2e/cli.e2e.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { startCli } from './terminal.mjs' +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' + +after(() => { + // Let normal native/IPC cleanup finish, but fail promptly if a PTY worker + // keeps the test process alive after all cases and artifacts have completed. + setTimeout(() => { + console.error('PTY suite leaked active resources:', process.getActiveResourcesInfo()) + process.exit(1) + }, 10_000).unref() +}) + +async function metadata(cli) { + const root = join(cli.dir, 'projects') + const files = await readdir(root, { recursive: true }) + return Promise.all(files.filter(file => file.endsWith('.meta.json')).map(async file => JSON.parse(await readFile(join(root, file), 'utf8')))) +} +const estimated = cli => cli.frames.some(frame => /~[1-9][\d.,k]* tokens/.test(frame.text)) +const completed = cli => cli.frames.some(frame => frame.text.includes('E2E_COMPLETE')) + +for (const fullscreen of [false, true]) for (const [columns, rows] of [[40, 12], [80, 24], [120, 40]]) for (const agents of [1, 2, 8, 20]) { + test(`installed CLI: ${agents} agents, ${columns}x${rows}, fullscreen=${fullscreen}`, { timeout: 60_000 }, async () => { + const cli = await startCli({ columns, rows, fullscreen, routerOptions: { agents }, args: ['E2E_PARENT: delegate to the fixture agents.'] }) + try { + await cli.waitFor(() => estimated(cli)) + await cli.waitFor(() => completed(cli)) + assert.deepEqual(cli.router.unexpected, []) + assert.ok(cli.router.requests.length >= agents + 2) + assert.ok(cli.frames.some(frame => frame.text.includes('144 tokens')), 'final usage must appear on screen') + const records = await metadata(cli) + assert.equal(records.length, agents) + for (const record of records) assert.deepEqual(record.tokenUsage, { confirmed: 144, estimated: 0, state: 'reported', inputTokens: 115, outputTokens: 24, cacheReadTokens: 5, cacheCreationTokens: 0 }) + // VT permits cursorX === columns while a right-margin autowrap is pending. + assert.ok(cli.frames.every(frame => frame.cursorY < frame.rows && frame.cursorX <= frame.columns)) + } finally { await cli.stop() } + }) +} + +for (const [name, options, state, confirmed] of [ + ['missing usage', { omitUsage: true }, 'estimated', 0], + ['partial usage', { partialUsage: true }, 'estimated', 120], + ['explicit zero', { zeroUsage: true }, 'reported', 0], +]) test(`installed CLI retains ${name} accurately after completion`, { timeout: 60_000 }, async () => { + const cli = await startCli({ routerOptions: options, args: ['E2E_PARENT: delegate to fixture agents.'] }) + try { + await cli.waitFor(() => completed(cli)) + const records = await metadata(cli) + assert.equal(records.length, 2) + for (const record of records) { + assert.equal(record.tokenUsage.state, state) + assert.equal(record.tokenUsage.confirmed, confirmed) + assert.equal(record.tokenUsage.estimated > 0, state === 'estimated') + } + } finally { await cli.stop() } +}) + +test('resize and Esc preserve a typed draft while stopping active agents', { timeout: 60_000 }, async () => { + const cli = await startCli({ fullscreen: true, routerOptions: { agents: 8, stall: true }, args: ['E2E_PARENT: delegate to fixture agents.'] }) + try { + await cli.waitFor(() => estimated(cli)) + cli.write('draft-preserved') + await cli.waitFor(() => cli.screen().includes('draft-preserved')) + for (const [cols, rows] of [[40, 12], [120, 40], [80, 24]]) { + cli.resize(cols, rows) + await cli.waitFor(() => cli.frames.at(-1)?.columns === cols && cli.screen().includes('draft-preserved')) + } + cli.write('\x1b') + await cli.waitFor(() => /Interrupted|Stopped|interrupted|stopped/.test(cli.screen())) + await cli.waitFor(() => cli.screen().includes('Stopped · Worker')) + assert.ok(cli.screen().includes('draft-preserved')) + await cli.waitFor(() => cli.router.activeAgents.size === 0) + await delay(250) + const requests = cli.router.requests.length + await delay(250) + assert.equal(cli.router.requests.length, requests, 'cancelled agents must not restart requests') + } finally { await cli.stop() } +}) + +test('background agents keep reporting usage after the parent tool returns', { timeout: 60_000 }, async () => { + const cli = await startCli({ routerOptions: { background: true }, args: ['E2E_PARENT: delegate to fixture agents in the background.'] }) + try { + await cli.waitFor(() => estimated(cli)) + await cli.waitFor(() => completed(cli) && cli.frames.some(frame => frame.text.includes('144 tokens'))) + await cli.waitFor(async () => { + const records = await metadata(cli) + return records.length === 2 && records.every(record => record.tokenUsage?.confirmed === 144) + }) + const records = await metadata(cli) + assert.equal(records.length, 2) + for (const record of records) assert.equal(record.tokenUsage.confirmed, 144) + } finally { await cli.stop() } +}) + +test('background task menu remains usable while agents stream', { timeout: 60_000 }, async () => { + const cli = await startCli({ fullscreen: true, routerOptions: { background: true, stall: true }, args: ['E2E_PARENT: delegate to fixture agents in the background.'] }) + try { + await cli.waitFor(() => estimated(cli) && completed(cli)) + cli.write('\x1b[1;2B') // Shift+Down opens background task management. + await cli.waitFor(() => cli.screen().includes('Background tasks')) + assert.ok(cli.screen().includes('2 active agents')) + cli.write('\r') + await cli.waitFor(() => cli.screen().includes('Prompt') && /~[1-9][\d.,k]* tokens/.test(cli.screen())) + cli.write('\x1b') + await cli.waitFor(() => !cli.screen().includes('Prompt')) + cli.write('menu-draft-preserved') + await cli.waitFor(() => cli.screen().includes('menu-draft-preserved')) + assert.equal(cli.router.activeAgents.size, 2, 'closing the detail dialog must not stop agents') + } finally { await cli.stop() } +}) + +test('installed streaming JSON emits confirmed agent usage and optional estimates', { timeout: 60_000 }, async () => { + const cli = await startCli({ usePty: false, args: ['--print', '--verbose', '--output-format', 'stream-json', 'E2E_PARENT: delegate to fixture agents.'] }) + try { + await cli.waitFor(() => Boolean(cli.exited)) + assert.equal(cli.exited.exitCode, 0) + const events = cli.raw.split(/\r?\n/).filter(line => line.trim()).map(line => JSON.parse(line)) + const progress = events.filter(event => event.subtype === 'task_progress') + assert.ok(progress.some(event => event.usage.token_usage?.state === 'estimated')) + const finished = events.filter(event => event.subtype === 'task_notification' && event.status === 'completed') + assert.equal(finished.length, 2) + for (const event of finished) { + assert.equal(event.usage.total_tokens, 144) + assert.equal(event.usage.token_usage.confirmed, 144) + assert.equal(event.usage.token_usage.estimated, 0) + } + } finally { await cli.stop() } +}) + +test('provider failures are not displayed as successful agent completions', { timeout: 60_000 }, async () => { + const cli = await startCli({ columns: 40, rows: 12, routerOptions: { childFailure: true }, args: ['E2E_PARENT: delegate to fixture agents.'] }) + try { + await cli.waitFor(() => completed(cli)) + assert.ok(cli.frames.some(frame => frame.text.includes('Failed · Work'))) + assert.ok(!cli.frames.some(frame => frame.text.includes('Done · Work'))) + } finally { await cli.stop() } +}) + +test('an agent hitting max_turns reports its limit and confirmed usage', { timeout: 60_000 }, async () => { + const cli = await startCli({ routerOptions: { childToolLoop: true }, args: ['E2E_PARENT: delegate to fixture agents.'] }) + try { + await cli.waitFor(() => completed(cli)) + assert.ok(cli.frames.some(frame => frame.text.includes('Turn limit reached'))) + assert.ok(!cli.frames.some(frame => /└\s+Done/.test(frame.text))) + const records = await metadata(cli) + assert.equal(records.length, 2) + for (const record of records) assert.equal(record.tokenUsage.confirmed, 144) + } finally { await cli.stop() } +}) diff --git a/scripts/e2e/fake-router.mjs b/scripts/e2e/fake-router.mjs new file mode 100644 index 0000000000..8a13df0c0e --- /dev/null +++ b/scripts/e2e/fake-router.mjs @@ -0,0 +1,62 @@ +import { createServer } from 'node:http' +import { setTimeout as delay } from 'node:timers/promises' + +export async function createFakeRouter({ agents = 2, omitUsage = false, zeroUsage = false, partialUsage = false, stall = false, background = false, childFailure = false, childToolLoop = false } = {}) { + const requests = [] + const unexpected = [] + let sequence = 0 + const activeAgents = new Set() + const server = createServer(async (req, res) => { + const path = new URL(req.url, 'http://fixture').pathname + const json = value => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)) } + if (path === '/') return json({ ok: true }) + if (path === '/api/claude_code/settings') return json({ settings: {} }) + if (path === '/api/plugins/marketplace.json') return json({ name: 'fixture', owner: { name: 'Fixture' }, plugins: [] }) + if (path.endsWith('/models')) return json({ data: [{ id: 'fixture-model', object: 'model' }], agent_model_roles: { explore: 'fixture-model', balanced: 'fixture-model', powerful: 'fixture-model' } }) + if (path === '/api/me') return json({ data: { id: '11111111-1111-4111-8111-111111111111', email: 'fixture@example.test', name: 'Fixture', confirmed: true } }) + if (path === '/api/me/subscriptions') return json({ data: [{ id: '11111111-1111-4111-8111-111111111111', groupId: '22222222-2222-4222-8222-222222222222', status: 'active', currentPeriodEnd: '2099-01-01T00:00:00Z' }] }) + if (path === '/api/me/terms/status') return json({ data: { configured: false, mustAccept: false, pendingReacceptance: false } }) + if (path !== '/router/v1/chat/completions') { unexpected.push(path); res.statusCode = 404; return json({ error: { message: `Unknown fixture endpoint: ${path}` } }) } + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + const body = JSON.parse(Buffer.concat(chunks).toString()) + requests.push(body) + const id = `fixture-${++sequence}` + const prompt = body.messages.filter(message => message.role === 'user').map(message => JSON.stringify(message.content)).join('\n') + const isChild = prompt.includes('AGENT_FIXTURE_') + if (isChild && childFailure) { + res.statusCode = 400 + return json({ error: { type: 'invalid_request_error', message: 'Fixture child request rejected' } }) + } + if (isChild) { activeAgents.add(id); res.once('close', () => activeAgents.delete(id)) } + const hasResult = body.messages.some(message => message.role === 'tool') + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }) + const emit = (delta, finish_reason = null, usage) => res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', model: 'fixture-model', choices: [{ index: 0, delta, finish_reason }], ...(usage && { usage }) })}\n\n`) + emit({ role: 'assistant' }) + if (isChild && childToolLoop) { + emit({ tool_calls: [{ index: 0, id: `read-${sequence}`, type: 'function', function: { name: 'Read', arguments: JSON.stringify({ file_path: 'README.md' }) } }] }) + emit({}, 'tool_calls', { prompt_tokens: 120, completion_tokens: 24 }) + } else if (!isChild && !hasResult && body.tools?.length) { + const name = body.tools.find(tool => ['Agent', 'Task'].includes(tool.function.name))?.function.name + if (!name) { unexpected.push('Agent tool missing from actual query'); res.end(); return } + for (let index = 0; index < agents; index++) { + const args = JSON.stringify({ description: `Worker ${index} 日本語 🚀`, subagent_type: childToolLoop ? 'fixture-limited' : 'general-purpose', prompt: `AGENT_FIXTURE_${index}: inspect the fixture and report.`, ...(background && { run_in_background: true }) }) + emit({ tool_calls: [{ index, id: `call-${index}`, type: 'function', function: { name, arguments: args.slice(0, 25) } }] }) + emit({ tool_calls: [{ index, function: { arguments: args.slice(25) } }] }) + } + emit({}, 'tool_calls', { prompt_tokens: 120, completion_tokens: 24 }) + } else { + const content = !body.tools?.length ? '{"title":"Fixture agent test"}' : isChild ? `Worker report 日本語 🚀 ${'reading fixture safely. '.repeat(12)}` : 'E2E_COMPLETE' + for (let index = 0; index < content.length; index += 16) { + if (res.destroyed) break + emit({ content: content.slice(index, index + 16) }) + await delay(isChild ? 100 : 5) + } + if (stall && isChild) return + emit({}, 'stop', omitUsage ? undefined : zeroUsage ? { prompt_tokens: 0, completion_tokens: 0 } : partialUsage ? { prompt_tokens: 120 } : { prompt_tokens: 120, completion_tokens: 24, prompt_tokens_details: { cached_tokens: 5 } }) + } + res.end('data: [DONE]\n\n') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return { origin: `http://127.0.0.1:${server.address().port}`, requests, unexpected, activeAgents, async close() { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)) } } +} diff --git a/scripts/e2e/terminal.mjs b/scripts/e2e/terminal.mjs new file mode 100644 index 0000000000..dfe2dc6779 --- /dev/null +++ b/scripts/e2e/terminal.mjs @@ -0,0 +1,115 @@ +import pty from 'node-pty' +import xterm from '@xterm/headless' +import unicode11 from '@xterm/addon-unicode11' +import { mkdir, mkdtemp, writeFile, readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { setTimeout as delay } from 'node:timers/promises' +import { spawn, spawnSync } from 'node:child_process' +import { once } from 'node:events' +import { createFakeRouter } from './fake-router.mjs' + +export async function startCli({ columns = 80, rows = 24, fullscreen = false, usePty = true, args = [], routerOptions } = {}) { + const consumer = (await readFile(resolve('.artifacts/package/consumer-path.txt'), 'utf8')).trim() + const root = resolve('.artifacts/pty') + await mkdir(root, { recursive: true }) + const dir = await mkdtemp(join(root, `${process.platform}-${process.versions.node}-${columns}x${rows}-`)) + const config = join(dir, 'config') + const project = join(dir, 'project') + for (const path of [config, project, join(dir, 'tmp')]) await mkdir(path) + if (routerOptions?.childToolLoop) { + await mkdir(join(config, 'agents')) + await writeFile(join(config, 'agents/fixture-limited.md'), '---\nname: fixture-limited\ndescription: Deterministic bounded fixture agent\nmaxTurns: 1\ntools: Read\n---\nInspect README.md and report.\n') + } + const git = spawnSync('git', ['init', '-q', project], { encoding: 'utf8' }) + if (git.status !== 0) throw new Error(git.stderr || 'Could not isolate fixture project') + await writeFile(join(project, 'README.md'), 'A deterministic CLI test fixture. No external services or user files.\n') + const projectConfigKey = project.replaceAll('\\', '/') + await writeFile(join(config, '.config.json'), JSON.stringify({ theme: 'dark', hasCompletedOnboarding: true, bypassPermissionsModeAccepted: true, projects: { [projectConfigKey]: { hasTrustDialogAccepted: true } } })) + const router = await createFakeRouter(routerOptions) + await writeFile(join(dir, 'unexpected-network.log'), '') + // Preserve OS runtime directories required by PowerShell/.NET, while keeping + // provider credentials and user CLI configuration out of the fixture. + const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined && /^(PATH|SystemRoot|SystemDrive|WINDIR|COMSPEC|PATHEXT|USERPROFILE|USERNAME|USERDOMAIN|HOMEDRIVE|HOMEPATH|APPDATA|LOCALAPPDATA|ProgramData|ProgramFiles(?:\(x86\))?|ProgramW6432|CommonProgramFiles(?:\(x86\))?|CommonProgramW6432|PSModulePath|PROCESSOR_ARCHITECTURE|SHELL)$/i.test(key))) + const inheritedPath = Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] || '' + for (const key of Object.keys(env)) if (key.toUpperCase() === 'PATH') delete env[key] + Object.assign(env, { + PATH: `${dirname(process.execPath)}${process.platform === 'win32' ? ';' : ':'}${inheritedPath}`, + TERM: 'xterm-256color', LANG: 'en_US.UTF-8', FORCE_COLOR: '1', + VERBOO_DISABLE_PLUGINS: '1', VERBOO_CONFIG_DIR: config, VERBOO_PROJECTS_DIR: join(dir, 'projects'), + TMPDIR: join(dir, 'tmp'), TMP: join(dir, 'tmp'), TEMP: join(dir, 'tmp'), + CLAUDE_CODE_OAUTH_TOKEN: 'fixture-session', CLAUDE_CODE_NO_FLICKER: fullscreen ? '1' : '0', + DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_TELEMETRY: '1', + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1', + CLI_E2E_ORIGIN: router.origin, CLI_E2E_NETWORK_LOG: join(dir, 'unexpected-network.log'), + CLI_E2E_PROCESS_LOG: join(dir, 'startup-processes.jsonl'), + }) + const terminal = new xterm.Terminal({ cols: columns, rows, allowProposedApi: true, scrollback: 5000 }) + terminal.loadAddon(new unicode11.Unicode11Addon()) + terminal.unicode.activeVersion = '11' + let child + let stderr = '' + const cliArgs = ['--import', pathToFileURL(resolve('scripts/e2e/transport-preload.mjs')).href, join(consumer, 'node_modules/@verboo/code/bin/verboo'), '--model', 'fixture-model', '--dangerously-skip-permissions', '--setting-sources', 'user', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--debug-file', join(dir, 'debug.log'), ...args] + try { + if (usePty) { + child = pty.spawn(process.execPath, cliArgs, { cwd: project, env, cols: columns, rows, name: 'xterm-256color' }) + } else { + // Machine-readable output uses pipes in SDK clients. ConPTY transforms + // long JSON lines into screen redraws, so it cannot validate NDJSON bytes. + const processChild = spawn(process.execPath, cliArgs, { cwd: project, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) + processChild.stdout.setEncoding('utf8') + processChild.stderr.setEncoding('utf8') + processChild.stderr.on('data', data => { stderr += data }) + await once(processChild, 'spawn') + // The prompt is supplied in args; signal EOF instead of leaving the CLI + // to wait for a producer that will never write to stdin. + processChild.stdin.end() + child = { + write() { throw new Error('Piped fixture stdin is closed; supply the prompt in args') }, + kill() { processChild.kill() }, + resize() { throw new Error('A piped CLI has no terminal to resize') }, + onData(callback) { processChild.stdout.on('data', callback) }, + // close runs after stdout/stderr have drained, unlike exit. + onExit(callback) { processChild.once('close', (exitCode, signal) => callback({ exitCode, signal })) }, + } + } + } catch (error) { terminal.dispose(); await router.close(); throw error } + let raw = '' + let exited + const frames = [] + const screen = () => Array.from({ length: terminal.rows }, (_, y) => terminal.buffer.active.getLine(terminal.buffer.active.viewportY + y)?.translateToString(true) || '').join('\n') + terminal.onData(data => { if (!exited) child.write(data) }) + child.onData(data => { + raw += data + terminal.write(data, () => frames.push({ at: Date.now(), columns: terminal.cols, rows: terminal.rows, cursorX: terminal.buffer.active.cursorX, cursorY: terminal.buffer.active.cursorY, text: screen() })) + }) + const exit = new Promise(resolve => child.onExit(value => { exited = value; resolve(value) })) + return { + dir, router, frames, screen, get raw() { return raw }, get exited() { return exited }, exit, + write(data) { child.write(data) }, + resize(cols, rows) { terminal.resize(cols, rows); child.resize(cols, rows) }, + async waitFor(predicate, timeout = 20_000) { + const start = Date.now() + while (!await predicate()) { + if (exited || Date.now() - start > timeout) throw new Error(`CLI condition failed (${JSON.stringify(exited)}); artifacts: ${dir}\n${screen()}`) + await delay(25) + } + }, + async stop() { + // ConPTY retains its output worker even after the child exits naturally. + // Release the terminal on Windows as well as stopping live children. + if (!exited || (usePty && process.platform === 'win32')) child.kill() + if (!exited) await Promise.race([exit, delay(3000, undefined, { ref: false })]) + await delay(25) + await writeFile(join(dir, 'terminal.ansi'), raw) + await writeFile(join(dir, 'stderr.log'), stderr) + await writeFile(join(dir, 'frames.json'), JSON.stringify(frames, null, 2)) + await writeFile(join(dir, 'requests.json'), JSON.stringify(router.requests, null, 2)) + await writeFile(join(dir, 'screen.txt'), screen()) + terminal.dispose() + await router.close() + const unexpected = await readFile(join(dir, 'unexpected-network.log'), 'utf8') + if (unexpected || router.unexpected.length) throw new Error(`Unexpected network traffic; artifacts: ${dir}\n${unexpected}${router.unexpected.join('\n')}`) + }, + } +} diff --git a/scripts/e2e/transport-preload.mjs b/scripts/e2e/transport-preload.mjs new file mode 100644 index 0000000000..a7ea929591 --- /dev/null +++ b/scripts/e2e/transport-preload.mjs @@ -0,0 +1,54 @@ +// Test-only Node preload. The installed package, parser, auth and query loop stay real. +import { HttpRequestInterceptor } from '@mswjs/interceptors/http' +import { appendFileSync } from 'node:fs' +import childProcess from 'node:child_process' +import { syncBuiltinESMExports } from 'node:module' + +// Trace executable names and timings only; never record arguments or input, +// which may contain credentials. This also locates synchronous startup stalls. +const processLog = process.env.CLI_E2E_PROCESS_LOG +if (processLog) { + const spawnSync = childProcess.spawnSync + childProcess.spawnSync = (...args) => { + const started = Date.now() + appendFileSync(processLog, `${JSON.stringify({ event: 'spawnSync', file: args[0], at: started })}\n`) + try { return spawnSync(...args) } + finally { appendFileSync(processLog, `${JSON.stringify({ event: 'spawnSync:end', file: args[0], durationMs: Date.now() - started })}\n`) } + } + const spawn = childProcess.spawn + childProcess.spawn = (...args) => { + const child = spawn(...args) + const started = Date.now() + appendFileSync(processLog, `${JSON.stringify({ event: 'spawn', file: args[0], pid: child.pid, at: started })}\n`) + child.once('exit', code => appendFileSync(processLog, `${JSON.stringify({ event: 'spawn:exit', file: args[0], pid: child.pid, code, durationMs: Date.now() - started })}\n`)) + return child + } + syncBuiltinESMExports() +} + +const origin = process.env.CLI_E2E_ORIGIN +if (!origin?.startsWith('http://127.0.0.1:')) throw new Error('Missing loopback fixture origin') +// Redirect fetch at its URL boundary so its real response stream and abort +// signal reach the local socket. Mock response piping loses post-header aborts. +const realFetch = globalThis.fetch +globalThis.fetch = (input, init) => { + const request = new Request(input, init) + const url = new URL(request.url) + return realFetch(url.hostname === 'code.verboo.ai' + ? new Request(`${origin}${url.pathname}${url.search}`, request) + : request) +} +const interceptor = new HttpRequestInterceptor() +interceptor.apply() +interceptor.on('request', async ({ request, controller }) => { + const url = new URL(request.url) + if (url.origin === origin) return + if (url.hostname !== 'code.verboo.ai') { + appendFileSync(process.env.CLI_E2E_NETWORK_LOG, `${request.method} ${url.origin}${url.pathname}\n`) + controller.respondWith(new Response('Unexpected external request in CLI fixture', { status: 503 })) + return + } + const body = ['GET', 'HEAD'].includes(request.method) ? undefined : await request.clone().arrayBuffer() + const response = await fetch(`${origin}${url.pathname}${url.search}`, { method: request.method, body, signal: request.signal, headers: { 'Content-Type': request.headers.get('Content-Type') || 'application/json' } }) + controller.respondWith(response) +}) diff --git a/scripts/prepare-cli-package.mjs b/scripts/prepare-cli-package.mjs new file mode 100644 index 0000000000..06e4f7801c --- /dev/null +++ b/scripts/prepare-cli-package.mjs @@ -0,0 +1,49 @@ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve, join } from 'node:path' +import { tmpdir } from 'node:os' + +// Build once before this script. Neither packing nor installing may rebuild it. +const output = resolve('.artifacts/package') +mkdirSync(output, { recursive: true }) +function npm(args, cwd) { + const windowsNpm = join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js') + const directWindowsNpm = process.platform === 'win32' && existsSync(windowsNpm) + const command = directWindowsNpm ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm' + const installing = args[0] === 'install' + const evidence = resolve('.artifacts/pty/package-setup.json') + const started = Date.now() + if (installing) { + mkdirSync(dirname(evidence), { recursive: true }) + writeFileSync(evidence, JSON.stringify({ status: 'installing', started, cwd })) + console.log('Installing the tested package in an independent consumer directory...') + } + // Invoke npm directly on Windows so a timeout terminates npm itself, not + // only its cmd.exe wrapper. Dependency extraction on hosted Windows disks + // can exceed five minutes even with install scripts disabled. + const result = spawnSync(command, directWindowsNpm ? [windowsNpm, ...args] : args, { cwd, encoding: 'utf8', shell: process.platform === 'win32' && !directWindowsNpm, timeout: installing ? 600_000 : 300_000 }) + if (installing) writeFileSync(evidence, JSON.stringify({ status: result.status, error: result.error?.message, durationMs: Date.now() - started, stdout: result.stdout, stderr: result.stderr }, null, 2)) + if (result.error || result.status !== 0) throw new Error(result.error?.message || result.stderr || result.stdout) + return result.stdout +} +if (!process.argv.includes('--install-only') && !process.argv.includes('--verify-only')) { + const [pack] = JSON.parse(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', output])) + writeFileSync(join(output, 'sha256.txt'), `${createHash('sha256').update(readFileSync(join(output, pack.filename))).digest('hex')} ${pack.filename}\n`) + writeFileSync(join(output, 'package-info.json'), JSON.stringify({ tarball: pack.filename, files: pack.files.map(file => file.path) }, null, 2)) +} +const info = JSON.parse(readFileSync(join(output, 'package-info.json'), 'utf8')) +if (!/^verboo-code-[\w.+-]+\.tgz$/.test(info.tarball)) throw new Error('Invalid package artifact name') +const tarball = join(output, info.tarball) +const checksum = createHash('sha256').update(readFileSync(tarball)).digest('hex') +if (readFileSync(join(output, 'sha256.txt'), 'utf8') !== `${checksum} ${info.tarball}\n`) throw new Error('Tested package checksum mismatch') +if (!process.argv.includes('--pack-only') && !process.argv.includes('--verify-only')) { + // Outside the repository so missing package dependencies cannot resolve from + // the development node_modules via Node's ancestor-directory lookup. + const consumer = mkdtempSync(join(process.env.RUNNER_TEMP || tmpdir(), 'verboo-cli-consumer-')) + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ name: 'verboo-consumer-fixture', private: true, type: 'module' })) + npm(['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--omit=dev', tarball], consumer) + writeFileSync(join(output, 'consumer-path.txt'), `${consumer}\n`) + console.log(`Installed ${info.tarball} in an independent consumer directory`) +} +console.log(`Verified SHA-256: ${checksum}`) diff --git a/scripts/run-tests-isolated.test.ts b/scripts/run-tests-isolated.test.ts new file mode 100644 index 0000000000..7c15cb81e8 --- /dev/null +++ b/scripts/run-tests-isolated.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { listTestFiles, selectTestFiles, runTestFile, testEnvironment } from './run-tests-isolated.js' + +test('discovers tracked and untracked suites, including .mjs, without accepting ignored fixtures', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'runner-discovery-')) + try { + expect(Bun.spawnSync(['git', 'init', cwd]).exitCode).toBe(0) + await writeFile(join(cwd, '.gitignore'), 'ignored/\n') + await mkdir(join(cwd, 'ignored')) + for (const file of ['tracked.test.ts', 'new.test.mjs', 'new.test.tsx', 'ignored/fixture.test.ts']) await writeFile(join(cwd, file), '') + expect(Bun.spawnSync(['git', '-C', cwd, 'add', 'tracked.test.ts']).exitCode).toBe(0) + expect(listTestFiles(cwd)).toEqual(['new.test.mjs', 'new.test.tsx', 'tracked.test.ts']) + expect(selectTestFiles(listTestFiles(cwd), ['new.test.mjs'])).toEqual(['new.test.mjs']) + expect(() => selectTestFiles(listTestFiles(cwd), ['misspelled.test.ts'])).toThrow('No test files') + } finally { await rm(cwd, { recursive: true, force: true }) } +}) + +test('a failed assertion fails the suite and configuration cannot leak to another process', async () => { + const dir = await mkdtemp(join(tmpdir(), 'runner-failure-')) + try { + const file = join(dir, 'failure.test.ts') + await writeFile(file, `import { test, expect } from 'bun:test'; test('intentional failure', () => { expect(1).toBe(2) })`) + const result = await runTestFile(file) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('intentional failure') + expect(testEnvironment(join(dir, 'one')).VERBOO_CONFIG_DIR).not.toBe(testEnvironment(join(dir, 'two')).VERBOO_CONFIG_DIR) + expect(testEnvironment(dir).ANTHROPIC_API_KEY).toBeUndefined() + } finally { await rm(dir, { recursive: true, force: true }) } +}) + +test('a stalled suite is terminated at its deadline', async () => { + const dir = await mkdtemp(join(tmpdir(), 'runner-timeout-')) + const previous = process.env.TEST_FILE_TIMEOUT_MS + try { + const file = join(dir, 'stall.test.ts') + await writeFile(file, `import { test } from 'bun:test'; test('stall', async () => { await new Promise(() => {}); }, 60_000)`) + process.env.TEST_FILE_TIMEOUT_MS = '500' + const result = await runTestFile(file) + expect(result.timedOut).toBe(true) + expect(result.exitCode).toBe(124) + expect(result.durationMs).toBeLessThan(3000) + } finally { + if (previous === undefined) delete process.env.TEST_FILE_TIMEOUT_MS + else process.env.TEST_FILE_TIMEOUT_MS = previous + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/scripts/run-tests-isolated.ts b/scripts/run-tests-isolated.ts index 9779a0df0a..cbc8c2301e 100644 --- a/scripts/run-tests-isolated.ts +++ b/scripts/run-tests-isolated.ts @@ -1,168 +1,97 @@ -/** - * Run each tracked test file in its own Bun process. - * - * Bun module mocks are process-global and mock.restore() does not fully undo - * every module replacement between files. Process isolation prevents one test - * file from changing the imports or environment observed by later suites. - */ - -type TestResult = { - file: string - exitCode: number - stdout: string - stderr: string +/** Each suite owns its process, configuration and session files. Every failure fails CI. */ +import { spawn } from 'node:child_process' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export type TestResult = { file: string; exitCode: number; stdout: string; stderr: string; durationMs: number; timedOut: boolean } + +export function listTestFiles(cwd = process.cwd()): string[] { + const result = Bun.spawnSync({ cmd: ['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], cwd, stdout: 'pipe', stderr: 'pipe' }) + if (result.exitCode !== 0) throw new Error(`Could not list tests: ${result.stderr}`) + return [...new Set(result.stdout.toString().split('\0').filter(file => /\.test\.(?:[cm]?[jt]s|[jt]sx)$/.test(file)))].sort() } -function listTrackedTestFiles(): string[] { - const result = Bun.spawnSync({ - cmd: [ - 'git', - 'ls-files', - '--', - ':(glob)**/*.test.ts', - ':(glob)**/*.test.tsx', - ':(glob)**/*.test.js', - ':(glob)**/*.test.jsx', - ], - stdout: 'pipe', - stderr: 'pipe', - }) - - if (result.exitCode !== 0) { - throw new Error( - `Could not list test files: ${result.stderr.toString().trim()}`, - ) - } - - return result.stdout - .toString() - .split('\n') - .map(file => file.trim()) - .filter(Boolean) - .sort() -} - -function selectTestFiles(files: string[]): string[] { - const filters = process.argv.slice(2) - if (filters.length === 0) return files - - const selected = files.filter(file => - filters.some(filter => - filter.endsWith('/') ? file.startsWith(filter) : file === filter, - ), - ) - if (selected.length === 0) { - throw new Error(`No tracked test files matched: ${filters.join(', ')}`) - } +export function selectTestFiles(files: string[], filters: string[]): string[] { + const selected = filters.length ? files.filter(file => filters.some(filter => filter.endsWith('/') ? file.startsWith(filter) : file === filter)) : files + if (!selected.length) throw new Error(`No test files matched: ${filters.join(', ')}`) return selected } -function getConcurrency(): number { - const parsed = Number.parseInt(process.env.TEST_ISOLATION_CONCURRENCY ?? '', 10) - return Number.isFinite(parsed) && parsed > 0 ? parsed : 4 +function positiveInteger(value: string | undefined, fallback: number): number { + const n = Number(value) + return Number.isSafeInteger(n) && n > 0 ? n : fallback } -async function loadBaseline(files: string[]): Promise> { - const baselineFile = Bun.file('.github/test-baseline.txt') - if (!(await baselineFile.exists())) return new Set() - - const baseline = new Set( - (await baselineFile.text()) - .split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')), - ) - const trackedFiles = new Set(files) - const unknownEntries = [...baseline].filter(file => !trackedFiles.has(file)) - if (unknownEntries.length > 0) { - throw new Error( - `Test baseline contains unknown files:\n${unknownEntries.join('\n')}`, - ) +export function testEnvironment(dir: string): NodeJS.ProcessEnv { + const env = { ...process.env } + for (const key of Object.keys(env)) { + if (/^(?:VERBOO|CLAUDE|ANTHROPIC|OPENAI|CODEX|GEMINI|GOOGLE|GITHUB|COPILOT|OLLAMA|MISTRAL|MINIMAX|MOONSHOT|DEEPSEEK|AWS|AZURE|BEDROCK|VERTEX)_/.test(key)) delete env[key] } - return baseline + return { ...env, VERBOO_CONFIG_DIR: join(dir, 'config'), VERBOO_PROJECTS_DIR: join(dir, 'projects'), TMPDIR: dir, TMP: dir, TEMP: dir } } -async function runTestFile(file: string): Promise { - const process = Bun.spawn({ - cmd: [ - Bun.env.BUN_EXEC_PATH || 'bun', - 'test', - '--max-concurrency=1', - '--only-failures', - file, - ], - stdout: 'pipe', - stderr: 'pipe', - env: { ...Bun.env }, +export async function runTestFile(file: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'verboo-test-')) + const start = Date.now() + const limit = positiveInteger(process.env.TEST_FILE_TIMEOUT_MS, 180_000) + let timedOut = false + let stdout = '' + let stderr = '' + const child = spawn(process.env.BUN_EXEC_PATH || process.execPath, ['test', '--max-concurrency=1', '--only-failures', file], { + env: testEnvironment(dir), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', }) - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(process.stdout).text(), - new Response(process.stderr).text(), - process.exited, - ]) - - return { file, exitCode, stdout, stderr } -} - -const allTrackedTestFiles = listTrackedTestFiles() -const files = selectTestFiles(allTrackedTestFiles) -const baseline = await loadBaseline(allTrackedTestFiles) -const unexpectedFailures: TestResult[] = [] -const baselineFailures: TestResult[] = [] -const baselineImprovements: string[] = [] -let nextIndex = 0 -let completed = 0 - -async function worker(): Promise { - while (true) { - const index = nextIndex++ - const file = files[index] - if (!file) return - - const result = await runTestFile(file) - completed++ - if (result.exitCode === 0) { - if (baseline.has(file)) baselineImprovements.push(file) - process.stdout.write(`[${completed}/${files.length}] PASS ${file}\n`) - } else if (baseline.has(file)) { - baselineFailures.push(result) - process.stdout.write( - `[${completed}/${files.length}] BASELINE ${file}\n`, - ) + const stop = () => { + if (!child.pid) return + if (process.platform === 'win32') { + Bun.spawnSync(['taskkill', '/pid', String(child.pid), '/T', '/F']) } else { - unexpectedFailures.push(result) - process.stdout.write(`[${completed}/${files.length}] FAIL ${file}\n`) + try { process.kill(-child.pid, 'SIGKILL') } catch { /* already exited */ } } } + child.stdout.on('data', chunk => { stdout += chunk }) + child.stderr.on('data', chunk => { stderr += chunk }) + const timer = setTimeout(() => { timedOut = true; stop() }, limit) + try { + const exitCode = await new Promise(resolve => { + child.once('error', error => { stderr += String(error); resolve(1) }) + child.once('exit', () => stop()) + child.once('close', code => resolve(code ?? 1)) + }) + return { file, exitCode: timedOut ? 124 : exitCode, stdout, stderr, durationMs: Date.now() - start, timedOut } + } finally { + clearTimeout(timer) + stop() + await rm(dir, { recursive: true, force: true }) + } } -await Promise.all( - Array.from( - { length: Math.min(getConcurrency(), Math.max(files.length, 1)) }, - () => worker(), - ), -) - -for (const failure of unexpectedFailures) { - process.stderr.write(`\n===== ${failure.file} =====\n`) - process.stderr.write(failure.stdout) - process.stderr.write(failure.stderr) +function xml(value: string): string { + return value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) } -if (baselineImprovements.length > 0) { - process.stdout.write( - `\nBaseline files now passing (remove after confirming in CI):\n${baselineImprovements.join('\n')}\n`, - ) +export async function main(): Promise { + const files = selectTestFiles(listTestFiles(), process.argv.slice(2)) + const results: TestResult[] = [] + let next = 0 + const worker = async () => { + for (;;) { + const file = files[next++] + if (!file) return + const result = await runTestFile(file) + results.push(result) + process.stdout.write(`[${results.length}/${files.length}] ${result.exitCode ? 'FAIL' : 'PASS'} ${file}\n`) + } + } + await Promise.all(Array.from({ length: Math.min(files.length, positiveInteger(process.env.TEST_ISOLATION_CONCURRENCY, 4)) }, worker)) + const failures = results.filter(r => r.exitCode !== 0) + const reportDir = process.env.TEST_REPORT_DIR || '.artifacts/test-results' + await mkdir(reportDir, { recursive: true }) + await writeFile(join(reportDir, 'results.json'), JSON.stringify(results, null, 2)) + await writeFile(join(reportDir, 'junit.xml'), `${results.map(r => `${r.exitCode ? `${xml(r.stdout + r.stderr)}` : ''}`).join('')}`) + for (const failure of failures) process.stderr.write(`\n${failure.file}\n${failure.stdout}${failure.stderr}`) + process.stdout.write(`\n${results.length - failures.length}/${files.length} test files passed; ${failures.length} failed.\n`) + if (failures.length) process.exitCode = 1 } -if (unexpectedFailures.length > 0) { - process.stderr.write( - `\n${unexpectedFailures.length} unexpected test files failed; ${baselineFailures.length} known baseline files also failed.\n`, - ) - process.exitCode = 1 -} else { - process.stdout.write( - `\nNo new test-file regressions. ${baselineFailures.length} known baseline files still fail.\n`, - ) -} +if (import.meta.main) await main() diff --git a/scripts/setup-pty.mjs b/scripts/setup-pty.mjs new file mode 100644 index 0000000000..b8c09b7b62 --- /dev/null +++ b/scripts/setup-pty.mjs @@ -0,0 +1,25 @@ +import { chmodSync, existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { spawnSync } from 'node:child_process' + +const require = createRequire(import.meta.url) +const root = dirname(require.resolve('node-pty/package.json')) +function run(script, args = []) { + const result = spawnSync(process.execPath, [script, ...args], { cwd: root, stdio: 'inherit', timeout: 180_000 }) + if (result.error || result.status !== 0) throw new Error(result.error?.message || 'node-pty native setup failed') +} +// Run only native installation, not the dependency's unpublished development +// test/TypeScript toolchain. Linux needs compilation; macOS/Windows ship N-API builds. +if (!existsSync(join(root, 'prebuilds', `${process.platform}-${process.arch}`))) { + run(require.resolve('node-gyp/bin/node-gyp.js'), ['rebuild']) +} +run(join(root, 'scripts/post-install.js')) +// node-pty 1.1.0 ships the macOS spawn helper without its executable bit. +if (process.platform !== 'win32') { + for (const folder of ['build/Release', `prebuilds/${process.platform}-${process.arch}`]) { + const helper = join(root, folder, 'spawn-helper') + if (existsSync(helper)) chmodSync(helper, 0o755) + } +} +require('node-pty') diff --git a/src/Tool.ts b/src/Tool.ts index e72086bbe2..ef27432ddb 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -788,6 +788,8 @@ export type Tool< options: { shouldAnimate: boolean tools: Tools + terminalSize?: { columns: number; rows: number } + activeGroupCount?: number }, ): React.ReactNode | null } diff --git a/src/cli/print.ts b/src/cli/print.ts index 46781bca55..be07656c0a 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -1,3 +1,4 @@ +import { parseAgentUsageMetadata } from '../utils/agentUsageSchema.js' // biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered import { feature } from 'bun:bundle' import { readFile, stat } from 'fs/promises' @@ -352,7 +353,7 @@ import { unassignTeammateTasks } from '../utils/tasks.js' import { getRunningTasks } from '../utils/task/framework.js' import { isBackgroundTask } from '../tasks/types.js' import { stopTask } from '../tasks/stopTask.js' -import { drainSdkEvents } from '../utils/sdkEventQueue.js' +import { drainSdkEvents, subscribeSdkEvents } from '../utils/sdkEventQueue.js' import { initializeGrowthBook } from '../services/analytics/growthbook.js' import { errorMessage, toError } from '../utils/errors.js' import { sleep } from '../utils/sleep.js' @@ -1922,6 +1923,9 @@ function runHeadlessStreaming( // queue re-checks at the bottom of run(). const isMainThread = (cmd: QueuedCommand) => cmd.agentId === undefined + const unsubscribeSdkEvents = subscribeSdkEvents(() => { + for (const event of drainSdkEvents()) output.enqueue(event) + }) try { let command: QueuedCommand | undefined let waitingForAgents = false @@ -2051,6 +2055,7 @@ function runHeadlessStreaming( /([\s\S]*?)<\/usage>/, ) const usageContent = usageMatch?.[1] ?? '' + const tokenUsage = parseAgentUsageMetadata(usageContent.match(/([\s\S]*?)<\/token_usage>/)?.[1]) const totalTokensMatch = usageContent.match( /(\d+)<\/total_tokens>/, ) @@ -2081,6 +2086,7 @@ function runHeadlessStreaming( totalTokensMatch && toolUsesMatch ? { total_tokens: parseInt(totalTokensMatch[1]!, 10), + ...(tokenUsage && { token_usage: tokenUsage }), tool_uses: parseInt(toolUsesMatch[1]!, 10), duration_ms: durationMsMatch ? parseInt(durationMsMatch[1]!, 10) @@ -2466,6 +2472,7 @@ function runHeadlessStreaming( gracefulShutdownSync(1) return } finally { + unsubscribeSdkEvents() runPhase = 'finally_flush' // Flush pending internal events before going idle await structuredIO.flushInternalEvents() diff --git a/src/components/AgentProgressLine.tsx b/src/components/AgentProgressLine.tsx index a69d1bb87a..09e682ff0c 100644 --- a/src/components/AgentProgressLine.tsx +++ b/src/components/AgentProgressLine.tsx @@ -1,134 +1,35 @@ -import { c as _c } from "react-compiler-runtime"; -import { Box, Text } from '../ink.js'; -import { formatNumber } from '../utils/format.js'; -import type { Theme } from '../utils/theme.js'; +import React from 'react' +import { Box, Text } from '../ink.js' +import { formatNumber } from '../utils/format.js' +import { agentUsageDisplay, type AgentTokenUsage } from '../utils/agentUsage.js' +import type { Theme } from '../utils/theme.js' +import { agentStatusLabel, type AgentDisplayStatus } from './agentPresentation.js' + type Props = { - agentType: string; - description?: string; - name?: string; - descriptionColor?: keyof Theme; - taskDescription?: string; - toolUseCount: number; - tokens: number | null; - color?: keyof Theme; - isLast: boolean; - isResolved: boolean; - isError: boolean; - isAsync?: boolean; - shouldAnimate: boolean; - lastToolInfo?: string | null; - hideType?: boolean; -}; -export function AgentProgressLine(t0: Props) { - const $ = _c(32); - const { - agentType, - description, - name, - descriptionColor, - taskDescription, - toolUseCount, - tokens, - color, - isLast, - isResolved, - isAsync: t1, - lastToolInfo, - hideType: t2 - } = t0; - const isAsync = t1 === undefined ? false : t1; - const hideType = t2 === undefined ? false : t2; - const treeChar = isLast ? "\u2514\u2500" : "\u251C\u2500"; - const isBackgrounded = isAsync && isResolved; - let t3; - if ($[0] !== isBackgrounded || $[1] !== isResolved || $[2] !== lastToolInfo || $[3] !== taskDescription) { - t3 = () => { - if (!isResolved) { - return lastToolInfo || "Initializing\u2026"; - } - if (isBackgrounded) { - return taskDescription ?? "Running in the background"; - } - return "Done"; - }; - $[0] = isBackgrounded; - $[1] = isResolved; - $[2] = lastToolInfo; - $[3] = taskDescription; - $[4] = t3; - } else { - t3 = $[4]; - } - const getStatusText = t3; - let t4; - if ($[5] !== treeChar) { - t4 = {treeChar} ; - $[5] = treeChar; - $[6] = t4; - } else { - t4 = $[6]; - } - const t5 = !isResolved; - let t6; - if ($[7] !== agentType || $[8] !== color || $[9] !== description || $[10] !== descriptionColor || $[11] !== hideType || $[12] !== name) { - t6 = hideType ? <>{name ?? description ?? agentType}{name && description && : {description}} : <>{agentType}{description && <>{" ("}{description}{")"}}; - $[7] = agentType; - $[8] = color; - $[9] = description; - $[10] = descriptionColor; - $[11] = hideType; - $[12] = name; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== isBackgrounded || $[15] !== tokens || $[16] !== toolUseCount) { - t7 = !isBackgrounded && <>{" \xB7 "}{toolUseCount} tool {toolUseCount === 1 ? "use" : "uses"}{tokens !== null && <> · {formatNumber(tokens)} tokens}; - $[14] = isBackgrounded; - $[15] = tokens; - $[16] = toolUseCount; - $[17] = t7; - } else { - t7 = $[17]; - } - let t8; - if ($[18] !== t5 || $[19] !== t6 || $[20] !== t7) { - t8 = {t6}{t7}; - $[18] = t5; - $[19] = t6; - $[20] = t7; - $[21] = t8; - } else { - t8 = $[21]; - } - let t9; - if ($[22] !== t4 || $[23] !== t8) { - t9 = {t4}{t8}; - $[22] = t4; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - let t10; - if ($[25] !== getStatusText || $[26] !== isBackgrounded || $[27] !== isLast) { - t10 = !isBackgrounded && {isLast ? " \u2514 " : "\u2502 \u2514 "}{getStatusText()}; - $[25] = getStatusText; - $[26] = isBackgrounded; - $[27] = isLast; - $[28] = t10; - } else { - t10 = $[28]; - } - let t11; - if ($[29] !== t10 || $[30] !== t9) { - t11 = {t9}{t10}; - $[29] = t10; - $[30] = t9; - $[31] = t11; - } else { - t11 = $[31]; - } - return t11; + agentType: string; description?: string; name?: string; descriptionColor?: keyof Theme; + taskDescription?: string; toolUseCount: number; tokens: number | null; tokenUsage?: AgentTokenUsage; + color?: keyof Theme; isLast: boolean; isResolved: boolean; isError: boolean; isAsync?: boolean; + shouldAnimate: boolean; lastToolInfo?: string | null; hideType?: boolean; + compact?: boolean; width?: number; status?: AgentDisplayStatus; +} + +export function AgentProgressLine(props: Props) { + const status = props.status ?? (props.isError ? 'failed' : props.isAsync && props.isResolved ? 'backgrounded' : props.isResolved ? 'completed' : 'running') + const label = agentStatusLabel(status, props.lastToolInfo) + const title = props.hideType ? props.name ?? props.description ?? props.agentType : `${props.agentType}${props.description ? ` (${props.description})` : ''}` + const usage = agentUsageDisplay(props.tokenUsage, props.tokens, formatNumber) + return + + {props.isLast ? '└─ ' : '├─ '} + + + {props.compact && `${label} · `}{title} + + + {` · ${props.compact ? '' : `${props.toolUseCount} tool ${props.toolUseCount === 1 ? 'use' : 'uses'} · `}${usage}`} + + {!props.compact && + {props.isLast ? ' └ ' : '│ └ '}{label} + } + } diff --git a/src/components/CostThresholdDialog.test.ts b/src/components/CostThresholdDialog.test.ts index 6c8520e8d7..89265778bd 100644 --- a/src/components/CostThresholdDialog.test.ts +++ b/src/components/CostThresholdDialog.test.ts @@ -4,7 +4,7 @@ import { getCostThresholdProviderLabelForProvider } from './CostThresholdProvide test('getCostThresholdProviderLabel uses the active provider category for first-party sessions', () => { expect(getCostThresholdProviderLabelForProvider('firstParty')).toBe( - 'Anthropic API', + 'Verboo API', ) }) diff --git a/src/components/PromptInput/ContextUsageRow.tsx b/src/components/PromptInput/ContextUsageRow.tsx new file mode 100644 index 0000000000..a229fb7354 --- /dev/null +++ b/src/components/PromptInput/ContextUsageRow.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Box, Text } from '../../ink.js' + +/** Keep status fields on one row; only the model name may shrink. */ +export function ContextUsageRow({ provider, model, columns, pct, input, window, rate, generating }: { + provider: string; model: string; columns: number; pct: number; + input: string; window: string; rate: number; generating: boolean; +}) { + const contextColor = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : undefined + return + {provider} + {model} + · context {pct}% + {columns >= 70 && · {input} / {window}} + {columns >= 80 && rate > 0 && · {rate} tok/s} + +} diff --git a/src/components/PromptInput/PromptInputFooter.tsx b/src/components/PromptInput/PromptInputFooter.tsx index dca57b787f..022c4e617f 100644 --- a/src/components/PromptInput/PromptInputFooter.tsx +++ b/src/components/PromptInput/PromptInputFooter.tsx @@ -32,6 +32,7 @@ import { Notifications } from './Notifications.js'; import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'; import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'; import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'; +import { ContextUsageRow } from './ContextUsageRow.js'; /** * ContextWindowDisplay with memo to prevent re-renders on every keystroke. @@ -42,6 +43,7 @@ function ContextWindowDisplayInner({ messages, permissionMode }: { permissionMode: PermissionMode; }): React.ReactNode { const mainLoopModel = useMainLoopModel(); + const { columns } = useTerminalSize(); const exceeds200k = useMemo(() => doesMostRecentAssistantMessageExceed200k(messages), [messages]); const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel, exceeds200kTokens: exceeds200k }); const activeModel = getActiveModelIdentity(runtimeModel); @@ -50,26 +52,11 @@ function ContextWindowDisplayInner({ messages, permissionMode }: { const contextTokens = useMemo(() => tokenCountWithEstimation(messages), [messages]); const pct = useMemo(() => Math.min(100, Math.max(0, Math.round((contextTokens / windowSize) * 100))), [contextTokens, windowSize]); - const contextColor = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : undefined; const rateValue = Math.round(avgRate10s); - const showTokenRate = rateValue > 0; - const rateColor = isGenerating ? 'success' : undefined; const inputK = formatNumber(contextTokens); const windowK = formatNumber(windowSize); - return ( - - {activeModel.provider} - {activeModel.model} - · - context - {pct}% - · - {inputK} / {windowK} - {showTokenRate && ·} - {showTokenRate && {rateValue} tok/s} - - ); + return ; } export const ContextWindowDisplay = React.memo(ContextWindowDisplayInner, (prevProps, nextProps) => { diff --git a/src/components/PromptInput/PromptInputQueuedCommands.test.tsx b/src/components/PromptInput/PromptInputQueuedCommands.test.tsx index 50cf44032b..4801b14270 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.test.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.test.tsx @@ -2,6 +2,8 @@ import React from 'react' import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' import { renderToString } from '../../utils/staticRender.js' +const actualAppState = { ...await import('src/state/AppState.js') } + describe('PromptInputQueuedCommands', () => { beforeEach(() => { mock.module('../../hooks/useCommandQueue.js', () => ({ @@ -14,6 +16,7 @@ describe('PromptInputQueuedCommands', () => { })) mock.module('src/state/AppState.js', () => ({ + ...actualAppState, useAppState: ( selector: (state: { viewingAgentTaskId?: string; isBriefOnly: boolean }) => unknown, ) => selector({ viewingAgentTaskId: undefined, isBriefOnly: false }), diff --git a/src/components/agentPresentation.test.tsx b/src/components/agentPresentation.test.tsx new file mode 100644 index 0000000000..230e6e45e2 --- /dev/null +++ b/src/components/agentPresentation.test.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { expect, test } from 'bun:test' +import { PassThrough } from 'node:stream' +import { createRoot, Box, Text } from '../ink.js' +import type { FrameEvent } from '../ink/frame.js' +import { renderToString } from '../utils/staticRender.js' +import { agentGroupLayout, agentStatusLabel } from './agentPresentation.js' +import { renderGroupedAgentToolUse, renderToolUseProgressMessage } from '../tools/AgentTool/UI.js' +import { AgentProgressLine } from './AgentProgressLine.js' +import { emptyAgentUsage } from '../utils/agentUsage.js' +import xterm from '@xterm/headless' +import unicode11 from '@xterm/addon-unicode11' +import { ContextUsageRow } from './PromptInput/ContextUsageRow.js' + +function group(count: number, tokens: number) { + return Array.from({ length: count }, (_, index) => ({ + param: { id: `agent-${index}`, type: 'tool_use' as const, name: 'Agent', input: { description: `Worker ${index} 日本語 🚀 ${'long '.repeat(20)}`, subagent_type: 'general-purpose', prompt: 'fixture' } }, + isResolved: false, isError: false, isInProgress: true, + progressMessages: [{ type: 'progress', uuid: `usage-${index}`, data: { type: 'agent_usage', agentId: `agent-${index}`, tokenCount: 0, toolUseCount: 1, tokenUsage: { ...emptyAgentUsage(), state: 'estimated', estimated: tokens } } }], + })) +} + +for (const [columns, rows] of [[40, 12], [80, 24], [120, 40]]) { + for (const count of [1, 2, 8, 20]) test(`${count} agents fit ${columns}x${rows}, retain the prompt and never overflow a frame`, async () => { + const stdout = new PassThrough() as PassThrough & { columns: number; rows: number; isTTY: boolean } + Object.assign(stdout, { columns, rows, isTTY: true }) + const stdin = Object.assign(new PassThrough(), { isTTY: true, setRawMode() {}, ref() {}, unref() {} }) + const frames: FrameEvent[] = [] + const terminal = new xterm.Terminal({ cols: columns, rows, allowProposedApi: true }) + terminal.loadAddon(new unicode11.Unicode11Addon()) + terminal.unicode.activeVersion = '11' + stdout.on('data', data => terminal.write(data.toString())) + const root = await createRoot({ stdout: stdout as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, patchConsole: false, onFrame: frame => frames.push(frame) }) + const layout = agentGroupLayout(columns!, rows!, count) + const view = (tokens: number, resolved = false) => {renderGroupedAgentToolUse(group(count, tokens).map(agent => ({ ...agent, isResolved: resolved, isInProgress: !resolved })) as never, { tools: [], shouldAnimate: false, terminalSize: { columns: columns!, rows: rows! } })}❯ draft preserved + try { + const rendered = await renderToString(view(123), columns) + expect(rendered).toContain('draft preserved') + expect(rendered).toContain('~123 tokens') + if (layout.hidden) expect(rendered).toContain(`+${layout.hidden} agents`) + expect(rendered.split('\n').length).toBeLessThanOrEqual(rows!) + for (let frame = 0; frame < 8; frame++) { + const content = view(frame + 100, frame === 7) + root.render(content) + await Bun.sleep(25) + await new Promise(resolve => terminal.write('', resolve)) + const screen = Array.from({ length: rows! }, (_, y) => (terminal.buffer.active.getLine(terminal.buffer.active.viewportY + y)?.translateToString(true) ?? '').trimEnd()).join('\n').trim() + expect(screen).toBe((await renderToString(content, columns)).split('\n').map(line => line.trimEnd()).join('\n').trim()) + } + expect(frames.length).toBeGreaterThan(0) + expect(frames.flatMap(frame => frame.flickers)).toEqual([]) + } finally { root.unmount(); stdin.end(); stdout.end(); terminal.dispose() } + }) +} + +test('groups share the terminal budget, including a one-line allocation', () => { + for (const groups of [1, 2, 4]) { + const layout = agentGroupLayout(40, 12, 20, groups) + expect(layout.height * groups).toBeLessThanOrEqual(4) + expect(layout.visible).toBeGreaterThanOrEqual(0) + } +}) + +test.each(['failed', 'killed', 'timeout', 'max_turns', 'max_tool_calls'] as const)('%s is never presented as Done', async status => { + const output = await renderToString(, 80) + expect(output).toContain(agentStatusLabel(status)) + expect(output).not.toContain('Done') +}) + +test('a single condensed agent retains the token unit in a narrow terminal', async () => { + const view = renderToolUseProgressMessage(group(1, 633)[0]!.progressMessages as never, { tools: [], verbose: false, terminalSize: { columns: 40, rows: 12 } }) + expect(await renderToString(view, 40)).toContain('~633 tokens') +}) + +test('interrupted grouped tool results are stopped, preserving partial usage', async () => { + const stopped = group(2, 633).map(agent => ({ ...agent, isResolved: true, isInProgress: false, isError: true, result: { param: { type: 'tool_result', tool_use_id: agent.param.id, is_error: true, content: 'Tool execution interrupted by the user.' } } })) + const view = renderGroupedAgentToolUse(stopped as never, { tools: [], shouldAnimate: false, terminalSize: { columns: 40, rows: 12 } }) + const output = await renderToString(view, 40) + expect(output).toContain('Stopped') + expect(output).toContain('~633 tokens') + expect(output).not.toContain('Failed') +}) diff --git a/src/components/agentPresentation.ts b/src/components/agentPresentation.ts new file mode 100644 index 0000000000..e5b0746649 --- /dev/null +++ b/src/components/agentPresentation.ts @@ -0,0 +1,26 @@ +export type AgentDisplayStatus = 'pending' | 'running' | 'backgrounded' | 'completed' | 'failed' | 'killed' | 'timeout' | 'max_turns' | 'max_tool_calls' +export function agentStatusLabel(status: AgentDisplayStatus, activity?: string | null): string { + switch (status) { + case 'failed': return 'Failed' + case 'killed': return 'Stopped' + case 'timeout': return 'Time limit reached' + case 'max_turns': return 'Turn limit reached' + case 'max_tool_calls': return 'Tool limit reached' + case 'completed': return 'Done' + case 'backgrounded': return 'Running in the background' + case 'pending': return 'Awaiting response' + default: return activity || 'Working…' + } +} + +/** Reserve the prompt/footer and divide the dynamic area among active groups. */ +export function agentGroupLayout(columns: number, rows: number, agents: number, activeGroups = 1) { + const budget = Math.max(1, Math.floor(Math.max(1, rows - 8) / Math.max(1, activeGroups))) + const gap = budget >= 3 ? 1 : 0 + const compact = columns < 70 || agents * 2 + 2 > budget + const lineHeight = compact ? 1 : 2 + const remaining = budget - gap - 1 + const visible = agents * lineHeight <= remaining ? agents : Math.max(0, Math.floor((remaining - 1) / lineHeight)) + const showOverflow = visible < agents && remaining > 0 + return { compact, visible, hidden: agents - visible, showOverflow, gap, width: Math.max(1, columns), height: gap + 1 + visible * lineHeight + Number(showOverflow) } +} diff --git a/src/components/messages/GroupedToolUseContent.tsx b/src/components/messages/GroupedToolUseContent.tsx index 5d2e730f78..180baaec62 100644 --- a/src/components/messages/GroupedToolUseContent.tsx +++ b/src/components/messages/GroupedToolUseContent.tsx @@ -1,5 +1,8 @@ import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; import * as React from 'react'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js'; +import { isLocalAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; import { filterToolProgressMessages, findToolByNameOrUniquePrefix, type Tools } from '../../Tool.js'; import type { GroupedToolUseMessage } from '../../types/message.js'; import type { buildMessageLookups } from '../../utils/messages.js'; @@ -17,6 +20,8 @@ export function GroupedToolUseContent({ inProgressToolUseIDs, shouldAnimate }: Props): React.ReactNode { + const terminalSize = useTerminalSize(); + const tasks = useAppStateMaybeOutsideOfProvider(state => state.tasks); const tool = findToolByNameOrUniquePrefix(tools, message.toolName); if (!tool?.renderGroupedToolUse) { return null; @@ -39,19 +44,27 @@ export function GroupedToolUseContent({ } const toolUsesData = message.messages.map(msg => { const content = msg.message.content[0]; - const result = resultsByToolUseId.get(content.id); + let result = resultsByToolUseId.get(content.id); + const outputAgentId = (result?.output as { agentId?: string } | undefined)?.agentId; + const task = outputAgentId ? tasks?.[outputAgentId] : Object.values(tasks ?? {}).find(task => isLocalAgentTask(task) && task.toolUseId === content.id); + const liveAgent = isLocalAgentTask(task) ? task : undefined; + const progressMessages = filterToolProgressMessages(lookups.progressMessagesByToolUseID.get(content.id) ?? []); + if (liveAgent?.progress) progressMessages.push({ type: 'progress', uuid: `agent_usage_${liveAgent.id}`, data: { type: 'agent_usage', agentId: liveAgent.id, ...liveAgent.progress } }); + if (result && liveAgent && liveAgent.status !== 'running') result = { ...result, output: { ...result.output as object, ...liveAgent.result, completionReason: liveAgent.status === 'completed' ? liveAgent.result?.completionReason ?? 'completed' : liveAgent.status } }; return { param: content as ToolUseBlockParam, - isResolved: lookups.resolvedToolUseIDs.has(content.id), - isError: lookups.erroredToolUseIDs.has(content.id), - isInProgress: inProgressToolUseIDs.has(content.id), - progressMessages: filterToolProgressMessages(lookups.progressMessagesByToolUseID.get(content.id) ?? []), + isResolved: liveAgent ? liveAgent.status !== 'running' : lookups.resolvedToolUseIDs.has(content.id), + isError: liveAgent?.status === 'failed' || lookups.erroredToolUseIDs.has(content.id), + isInProgress: liveAgent?.status === 'running' || inProgressToolUseIDs.has(content.id), + progressMessages, result }; }); const anyInProgress = toolUsesData.some(d => d.isInProgress); return tool.renderGroupedToolUse(toolUsesData, { shouldAnimate: shouldAnimate && anyInProgress, + terminalSize, + activeGroupCount: Math.max(1, Math.ceil(inProgressToolUseIDs.size / Math.max(1, toolUsesData.length))), tools }); } diff --git a/src/components/tasks/AsyncAgentDetailDialog.tsx b/src/components/tasks/AsyncAgentDetailDialog.tsx index 87fb34ef98..4a73f0803f 100644 --- a/src/components/tasks/AsyncAgentDetailDialog.tsx +++ b/src/components/tasks/AsyncAgentDetailDialog.tsx @@ -1,3 +1,5 @@ +import { agentUsageDisplay } from '../../utils/agentUsage.js'; +import { agentStatusLabel } from '../agentPresentation.js'; import { c as _c } from "react-compiler-runtime"; import React, { useMemo } from 'react'; import type { DeepImmutable } from 'src/types/utils.js'; @@ -98,6 +100,7 @@ export function AsyncAgentDetailDialog(t0) { const planContent = t5; const displayPrompt = agent.prompt.length > 300 ? agent.prompt.substring(0, 297) + "\u2026" : agent.prompt; const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount; + const tokenDisplay = agentUsageDisplay(agent.result?.tokenUsage ?? agent.progress?.tokenUsage, tokenCount, formatNumber); const toolUseCount = agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount; const t6 = agent.selectedAgent?.agentType ?? "agent"; const t7 = agent.description || "Async agent"; @@ -111,18 +114,19 @@ export function AsyncAgentDetailDialog(t0) { t8 = $[13]; } const title = t8; + const displayStatus = agent.status === 'completed' ? agent.result?.completionReason ?? 'completed' : agent.status; let t9; - if ($[14] !== agent.status) { - t9 = agent.status !== "running" && {getTaskStatusIcon(agent.status)}{" "}{agent.status === "completed" ? "Completed" : agent.status === "failed" ? "Failed" : "Stopped"}{" \xB7 "}; - $[14] = agent.status; + if ($[14] !== displayStatus) { + t9 = agent.status !== "running" && {getTaskStatusIcon(agent.status)}{" "}{agentStatusLabel(displayStatus)}{" \xB7 "}; + $[14] = displayStatus; $[15] = t9; } else { t9 = $[15]; } let t10; - if ($[16] !== tokenCount) { - t10 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[16] = tokenCount; + if ($[16] !== tokenDisplay) { + t10 = <> · {tokenDisplay}; + $[16] = tokenDisplay; $[17] = t10; } else { t10 = $[17]; diff --git a/src/components/tasks/InProcessTeammateDetailDialog.tsx b/src/components/tasks/InProcessTeammateDetailDialog.tsx index 5f02866ca7..17ef8bcd55 100644 --- a/src/components/tasks/InProcessTeammateDetailDialog.tsx +++ b/src/components/tasks/InProcessTeammateDetailDialog.tsx @@ -1,3 +1,4 @@ +import { agentUsageDisplay } from '../../utils/agentUsage.js'; import { c as _c } from "react-compiler-runtime"; import React, { useMemo } from 'react'; import type { DeepImmutable } from 'src/types/utils.js'; @@ -104,6 +105,7 @@ export function InProcessTeammateDetailDialog(t0) { } const activity = t5; const tokenCount = teammate.result?.totalTokens ?? teammate.progress?.tokenCount; + const tokenDisplay = agentUsageDisplay(teammate.result?.tokenUsage ?? teammate.progress?.tokenUsage, tokenCount, formatNumber); const toolUseCount = teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount; let t6; if ($[12] !== teammate.prompt) { @@ -158,9 +160,9 @@ export function InProcessTeammateDetailDialog(t0) { t11 = $[25]; } let t12; - if ($[26] !== tokenCount) { - t12 = tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens; - $[26] = tokenCount; + if ($[26] !== tokenDisplay) { + t12 = <> · {tokenDisplay}; + $[26] = tokenDisplay; $[27] = t12; } else { t12 = $[27]; diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index 1369945a3a..4ba945a9fc 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -9,6 +9,7 @@ import { z } from 'zod/v4' import { lazySchema } from '../../utils/lazySchema.js' +import { agentTokenUsageSchema } from '../../utils/agentUsageSchema.js' // ============================================================================ // Usage & Model Types @@ -1705,6 +1706,7 @@ export const SDKTaskNotificationMessageSchema = lazySchema(() => total_tokens: z.number(), tool_uses: z.number(), duration_ms: z.number(), + token_usage: agentTokenUsageSchema.optional(), }) .optional(), uuid: UUIDPlaceholder(), @@ -1758,6 +1760,7 @@ export const SDKTaskProgressMessageSchema = lazySchema(() => total_tokens: z.number(), tool_uses: z.number(), duration_ms: z.number(), + token_usage: agentTokenUsageSchema.optional(), }), last_tool_name: z.string().optional(), summary: z.string().optional(), diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index e383097971..2ea32c2365 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -1957,6 +1957,15 @@ export type SDKTaskNotificationMessage = { total_tokens: number tool_uses: number duration_ms: number + token_usage?: { + confirmed: number + estimated: number + state: "pending" | "estimated" | "reported" + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + } } uuid: string session_id: string @@ -1985,6 +1994,15 @@ export type SDKTaskProgressMessage = { total_tokens: number tool_uses: number duration_ms: number + token_usage?: { + confirmed: number + estimated: number + state: "pending" | "estimated" | "reported" + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + } } last_tool_name?: string summary?: string @@ -2257,6 +2275,15 @@ export type SDKMessage = ({ total_tokens: number tool_uses: number duration_ms: number + token_usage?: { + confirmed: number + estimated: number + state: "pending" | "estimated" | "reported" + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + } } uuid: string session_id: string @@ -2281,6 +2308,15 @@ export type SDKMessage = ({ total_tokens: number tool_uses: number duration_ms: number + token_usage?: { + confirmed: number + estimated: number + state: "pending" | "estimated" | "reported" + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + } } last_tool_name?: string summary?: string diff --git a/src/hooks/useApiKeyVerification.test.tsx b/src/hooks/useApiKeyVerification.test.tsx index 438cfd1739..38ebc028d2 100644 --- a/src/hooks/useApiKeyVerification.test.tsx +++ b/src/hooks/useApiKeyVerification.test.tsx @@ -4,6 +4,10 @@ import { afterEach, expect, mock, test } from 'bun:test' import React from 'react' import { createRoot, Text } from '../ink.js' +// Exercise the retained compatibility implementation explicitly. +const actualOauth = { ...await import('../constants/oauth.js') } +mock.module('../constants/oauth.js', () => ({ ...actualOauth, isVerbooMode: () => false })) + type AuthState = { anthropicAuthEnabled: boolean claudeSubscriber: boolean diff --git a/src/hooks/useCancelRequest.ts b/src/hooks/useCancelRequest.ts index 9b07a493bb..f7b5e9bbf3 100644 --- a/src/hooks/useCancelRequest.ts +++ b/src/hooks/useCancelRequest.ts @@ -43,6 +43,7 @@ type CancelRequestHandlerProps = { isQueuePaused: () => boolean onAgentsKilled: () => void isMessageSelectorVisible: boolean + isTaskDialogVisible?: boolean screen: Screen popCommandFromQueue?: () => void vimMode?: VimMode @@ -65,6 +66,7 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null { isQueuePaused, onAgentsKilled, isMessageSelectorVisible, + isTaskDialogVisible = false, screen, popCommandFromQueue, vimMode, @@ -83,8 +85,19 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null { const viewSelectionMode = useAppState(s => s.viewSelectionMode) const selection = useSelection() - // Always registered, and reads live state at the keypress boundary. React - // may not have rendered a newly-started or just-cancelled turn yet. + // Task management owns its close gesture. Other active-work cancellation + // keeps priority and reads live state before slower modal/Vim/chord handlers. + const isOverlayActive = useIsOverlayActive() + const isViewingTeammate = viewSelectionMode === 'viewing-agent' + const isContextActive = + screen !== 'transcript' && + !isSearchingHistory && + !isMessageSelectorVisible && + !isLocalJSXCommand && + !isHelpOpen && + !isOverlayActive && + !(isVimModeEnabled() && vimMode === 'INSERT') + const cancelActiveWork = useCallback(() => { if (!canCancelWork()) return false logEvent('tengu_cancel', { @@ -97,11 +110,12 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null { useKeybinding('chat:cancel', cancelActiveWork, { context: 'Chat', priority: true, + isActive: !isTaskDialogVisible, }) useKeybinding('app:interrupt', () => { if (selection.hasSelection()) return false return cancelActiveWork() - }, { context: 'Global', priority: true }) + }, { context: 'Global', priority: true, isActive: !isTaskDialogVisible }) const handleCancel = useCallback(() => { const cancelProps = { @@ -141,23 +155,10 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null { // Other contexts (Transcript, HistorySearch, Help) have their own escape handlers // Overlays (ModelPicker, ThinkingToggle, etc.) register themselves via useRegisterOverlay // Local JSX commands (like /model, /btw) handle their own input - const isOverlayActive = useIsOverlayActive() const hasQueuedCommands = !isQueuePaused() && queuedCommandsLength > 0 // Idle Escape can leave bash/background mode when the input is empty. const isInSpecialModeWithEmptyInput = inputMode !== undefined && inputMode !== 'prompt' && !inputValue - // When viewing a teammate's transcript, let useBackgroundTaskNavigation handle Escape - const isViewingTeammate = viewSelectionMode === 'viewing-agent' - // Context guards: other screens/overlays handle their own cancel - const isContextActive = - screen !== 'transcript' && - !isSearchingHistory && - !isMessageSelectorVisible && - !isLocalJSXCommand && - !isHelpOpen && - !isOverlayActive && - !(isVimModeEnabled() && vimMode === 'INSERT') - // Escape (chat:cancel) defers to mode-exit when in special mode with empty // input, and to useBackgroundTaskNavigation when viewing a teammate const isEscapeActive = diff --git a/src/keybindings/cancelPriority.test.tsx b/src/keybindings/cancelPriority.test.tsx index 12e062763f..eca0677667 100644 --- a/src/keybindings/cancelPriority.test.tsx +++ b/src/keybindings/cancelPriority.test.tsx @@ -23,7 +23,8 @@ test.each([ ['Kitty press/release', '\x1b[27;1:1u\x1b[27;1:3u'], ['modifyOtherKeys Esc', '\x1b[27;1;27~'], ['Ctrl+C', '\x03'], -])('%s cancels ahead of modal/Vim/chord handlers, then falls through when idle', async (_name, sequence) => { + ['task dialog Esc', '\x1b', true], +])('%s respects task management focus and otherwise cancels active work before modal/Vim/chord handlers', async (_name, sequence, taskDialog = false) => { const stdout = new PassThrough() stdout.resume() const stdin = Object.assign(new PassThrough(), { @@ -60,6 +61,7 @@ test.each([ active} isQueuePaused={() => !active} onCancel={() => { active = false; cancelled++ }} onAgentsKilled={() => {}} screen="transcript" isMessageSelectorVisible isLocalJSXCommand isSearchingHistory + isTaskDialogVisible={taskDialog} isHelpOpen vimMode="INSERT" inputMode="bash" inputValue="" /> } @@ -84,6 +86,11 @@ test.each([ } stdin.write(sequence) await Bun.sleep(100) + if (taskDialog) { + expect(cancelled).toBe(0) + expect(modalKeys).toBe(1) + return + } expect(cancelled).toBe(1) expect(modalKeys).toBe(0) expect(domKeys).toBe(0) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 474bcc4a1e..73ff29bef4 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -2275,6 +2275,7 @@ export function REPL({ onCancel, onAgentsKilled: () => setMessages(prev => [...prev, createAgentsKilledMessage()]), isMessageSelectorVisible: isMessageSelectorVisible || !!showBashesDialog, + isTaskDialogVisible: !!showBashesDialog, screen, popCommandFromQueue: handleQueuedCommandOnCancel, vimMode, @@ -2715,6 +2716,14 @@ export function REPL({ // history). Replacing those leaves the AgentTool UI stuck at // "Initializing…" because it renders the full progress trail. setMessages(oldMessages => { + if (newMessage.data.type === 'agent_usage') { + const index = oldMessages.findLastIndex(m => m.type === 'progress' && m.data.type === 'agent_usage' && m.parentToolUseID === newMessage.parentToolUseID && m.data.agentId === newMessage.data.agentId); + if (index >= 0) { + const copy = oldMessages.slice(); + copy[index] = { ...newMessage, uuid: oldMessages[index].uuid }; + return copy; + } + } const last = oldMessages.at(-1); if (last?.type === 'progress' && last.parentToolUseID === newMessage.parentToolUseID && last.data.type === newMessage.data.type) { const copy = oldMessages.slice(); diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index f5cea59508..9568d9f243 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -2290,6 +2290,11 @@ async function* queryModel( const lastMsg = newMessages.at(-1) if (lastMsg) { lastMsg.message.usage = usage + if (part.usage != null) { + lastMsg.message.usageReported = true + lastMsg.message.usageInputReported = part.usageInputReported !== false + lastMsg.message.usageOutputReported = part.usageOutputReported !== false + } lastMsg.message.stop_reason = stopReason } diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index e286db26c5..d976a8b24e 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -1,7 +1,11 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import { getAnthropicClient } from './client.js' +// Exercise the retained compatibility implementation explicitly. +const actualOauth = { ...await import('../../constants/oauth.js') } +mock.module('../../constants/oauth.js', () => ({ ...actualOauth, isVerbooMode: () => false })) + type FetchType = typeof globalThis.fetch type ShimClient = { diff --git a/src/services/api/openaiShim.diagnostics.test.ts b/src/services/api/openaiShim.diagnostics.test.ts index 5585a30a2d..91471943e2 100644 --- a/src/services/api/openaiShim.diagnostics.test.ts +++ b/src/services/api/openaiShim.diagnostics.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +const actualDebug = { ...await import('../../utils/debug.js') } + const originalFetch = globalThis.fetch const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, @@ -35,6 +37,7 @@ afterEach(() => { test('logs classified transport diagnostics with category and code', async () => { const debugSpy = mock(() => {}) mock.module('../../utils/debug.js', () => ({ + ...actualDebug, logForDebugging: debugSpy, })) @@ -82,6 +85,7 @@ test('logs classified transport diagnostics with category and code', async () => test('redacts credentials in transport diagnostic URL logs', async () => { const debugSpy = mock(() => {}) mock.module('../../utils/debug.js', () => ({ + ...actualDebug, logForDebugging: debugSpy, })) @@ -129,6 +133,7 @@ test('redacts credentials in transport diagnostic URL logs', async () => { test('logs self-heal localhost fallback with redacted from/to URLs', async () => { const debugSpy = mock(() => {}) mock.module('../../utils/debug.js', () => ({ + ...actualDebug, logForDebugging: debugSpy, })) @@ -206,6 +211,7 @@ test('logs self-heal localhost fallback with redacted from/to URLs', async () => test('logs self-heal toolless retry for local tool-call incompatibility', async () => { const debugSpy = mock(() => {}) mock.module('../../utils/debug.js', () => ({ + ...actualDebug, logForDebugging: debugSpy, })) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index a6abac8a5d..7c355fb4b1 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1145,6 +1145,17 @@ function makeMessageId(): string { return `msg_${randomUUID().replace(/-/g, '')}` } +// Normalization fills absent fields with zero for legacy consumers. Preserve +// presence separately so live agent counters never mistake absence for a report. +function usagePresence(raw: unknown) { + const value = (raw ?? {}) as Record + const valid = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value >= 0 + return { + ...(!valid(value.prompt_tokens) && !valid(value.input_tokens) && { usageInputReported: false }), + ...(!valid(value.completion_tokens) && !valid(value.output_tokens) && { usageOutputReported: false }), + } +} + function convertChunkUsage( usage: OpenAIStreamChunk['usage'] | undefined, ): Partial | undefined { @@ -1475,6 +1486,7 @@ async function* openaiStreamToAnthropic( model, stop_reason: null, stop_sequence: null, + usageReported: false, usage: { input_tokens: 0, output_tokens: 0, @@ -2471,7 +2483,7 @@ async function* openaiStreamToAnthropic( yield { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, - ...(chunkUsage ? { usage: chunkUsage } : {}), + ...(chunkUsage ? { usage: chunkUsage, ...usagePresence(chunk.usage) } : {}), } if (chunkUsage) { hasEmittedFinalUsage = true @@ -2489,6 +2501,7 @@ async function* openaiStreamToAnthropic( type: 'message_delta', delta: { stop_reason: lastStopReason, stop_sequence: null }, usage: chunkUsage, + ...usagePresence(chunk.usage), } hasEmittedFinalUsage = true } @@ -4022,6 +4035,8 @@ class OpenAIShimMessages { usage: buildAnthropicUsageFromRawUsage( data.usage as unknown as Record | undefined, ), + ...(data.usage == null && { usageReported: false }), + ...usagePresence(data.usage), } } } diff --git a/src/services/api/withRetry.test.ts b/src/services/api/withRetry.test.ts index 4e24137dda..b24c9d1756 100644 --- a/src/services/api/withRetry.test.ts +++ b/src/services/api/withRetry.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { APIError } from '@anthropic-ai/sdk' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +const actualProviders = { ...await import('src/utils/model/providers.js') } + // Helper to build a mock APIError with specific headers function makeError(headers: Record): APIError { const headersObj = new Headers(headers) @@ -61,6 +63,7 @@ async function importFreshWithRetryModule( ) { mock.restore() mock.module('src/utils/model/providers.js', () => ({ + ...actualProviders, getAPIProvider: () => provider, getAPIProviderForStatsig: () => provider, })) diff --git a/src/services/oauth/purchaseFlow.ui.test.tsx b/src/services/oauth/purchaseFlow.ui.test.tsx index 3011ea7bfc..596b906c84 100644 --- a/src/services/oauth/purchaseFlow.ui.test.tsx +++ b/src/services/oauth/purchaseFlow.ui.test.tsx @@ -169,6 +169,8 @@ test('navigates from the standalone selector through the plan grid with arrow ke await Bun.sleep(20) } + // A committed frame precedes registration of the grid input effect. + await Bun.sleep(30) stdin.write('\x1B[C') await Bun.sleep(20) stdin.write('\r') diff --git a/src/services/tips/sponsoredTips.test.ts b/src/services/tips/sponsoredTips.test.ts index 6eade0d95b..057326bb93 100644 --- a/src/services/tips/sponsoredTips.test.ts +++ b/src/services/tips/sponsoredTips.test.ts @@ -1,155 +1,8 @@ -import { describe, expect, mock, test } from 'bun:test' +import { expect, test } from 'bun:test' +import { sponsoredTipsEnabled, getSponsoredTipsFrequency, sponsoredTips } from './sponsoredTips.js' -type StubSettings = { - sponsoredTipsEnabled?: boolean - sponsoredTipsFrequency?: number - spinnerTipsEnabled?: boolean -} - -const settingsRef: { value: StubSettings } = { value: {} } -const configRef: { - value: { numStartups: number; sponsoredTipsHistory?: { lastShownAt: number; totalShown: number } } -} = { value: { numStartups: 100 } } - -// mock.module is process-global — install once, then mutate the refs per test. -mock.module('../../utils/settings/settings.js', () => ({ - getSettings_DEPRECATED: () => settingsRef.value, - getInitialSettings: () => settingsRef.value, - getSettingsForSource: () => undefined, -})) - -mock.module('../../utils/config.js', () => ({ - getGlobalConfig: () => configRef.value, - saveGlobalConfig: (mut: (c: typeof configRef.value) => typeof configRef.value) => { - configRef.value = mut(configRef.value) - }, -})) - -async function freshImport() { - const stamp = `${Date.now()}-${Math.random()}` - return { - sponsoredTips: await import(`./sponsoredTips.ts?ts=${stamp}`), - tipHistory: await import(`./tipHistory.ts?ts=${stamp}`), - } -} - -function resetState(settings: StubSettings = {}, numStartups = 100) { - settingsRef.value = settings - configRef.value = { numStartups } -} - -describe('sponsoredTipsEnabled', () => { - test('defaults to true when no settings present', async () => { - resetState() - const { sponsoredTips } = await freshImport() - expect(sponsoredTips.sponsoredTipsEnabled()).toBe(true) - }) - - test('returns false when explicitly disabled', async () => { - resetState({ sponsoredTipsEnabled: false }) - const { sponsoredTips } = await freshImport() - expect(sponsoredTips.sponsoredTipsEnabled()).toBe(false) - }) - - test('returns false when frequency is 0', async () => { - resetState({ sponsoredTipsFrequency: 0 }) - const { sponsoredTips } = await freshImport() - expect(sponsoredTips.sponsoredTipsEnabled()).toBe(false) - }) -}) - -describe('getSponsoredTipsFrequency', () => { - test('defaults to 10', async () => { - resetState() - const { sponsoredTips } = await freshImport() - expect(sponsoredTips.getSponsoredTipsFrequency()).toBe(10) - }) - - test('honors user-configured frequency', async () => { - resetState({ sponsoredTipsFrequency: 25 }) - const { sponsoredTips } = await freshImport() - expect(sponsoredTips.getSponsoredTipsFrequency()).toBe(25) - }) - - test('rejects negative values', async () => { - resetState({ sponsoredTipsFrequency: -5 }) - const { sponsoredTips } = await freshImport() - expect(sponsoredTips.getSponsoredTipsFrequency()).toBe(10) - }) -}) - -describe('sponsored tip catalog', () => { - test('has exactly 4 Atomic tips', async () => { - resetState() - const { sponsoredTips } = await freshImport() - expect(sponsoredTips.sponsoredTips.length).toBe(4) - expect( - sponsoredTips.sponsoredTips.every( - (t: { sponsor?: { name: string; url?: string } }) => - t.sponsor?.name === 'Atomic Chat' && - t.sponsor.url === 'https://atomic.chat/', - ), - ).toBe(true) - }) - - test('all tips have unique ids prefixed with atomic-', async () => { - resetState() - const { sponsoredTips } = await freshImport() - const ids = sponsoredTips.sponsoredTips.map((t: { id: string }) => t.id) - expect(new Set(ids).size).toBe(ids.length) - expect(ids.every((id: string) => id.startsWith('atomic-'))).toBe(true) - }) - - test('rendered content embeds sponsor name, tip body, and URL', async () => { - resetState() - const { sponsoredTips } = await freshImport() - const tip = sponsoredTips.sponsoredTips[0] - const rendered: string = await tip.content({ theme: 'dark' }) - // ANSI codes wrap the strings — assert on plain substrings - expect(rendered).toContain('Sponsored') - expect(rendered).toContain('Atomic Chat') - expect(rendered).toContain('Setup free local models') - expect(rendered).toContain('https://atomic.chat/') - }) - - test('isRelevant follows sponsoredTipsEnabled', async () => { - resetState({ sponsoredTipsEnabled: false }) - const { sponsoredTips } = await freshImport() - const results = await Promise.all( - sponsoredTips.sponsoredTips.map((t: { isRelevant: () => Promise }) => - t.isRelevant(), - ), - ) - expect(results.every((r: boolean) => r === false)).toBe(true) - }) -}) - -describe('sponsored history tracking', () => { - test('records lastShownAt and increments totalShown', async () => { - resetState({}, 50) - const { tipHistory } = await freshImport() - tipHistory.recordSponsoredTipShown() - expect(configRef.value.sponsoredTipsHistory).toEqual({ - lastShownAt: 50, - totalShown: 1, - }) - tipHistory.recordSponsoredTipShown() - expect(configRef.value.sponsoredTipsHistory).toEqual({ - lastShownAt: 50, - totalShown: 2, - }) - }) - - test('getSessionsSinceLastSponsored returns Infinity when never shown', async () => { - resetState({}, 100) - const { tipHistory } = await freshImport() - expect(tipHistory.getSessionsSinceLastSponsored()).toBe(Infinity) - }) - - test('getSessionsSinceLastSponsored returns delta from current startups', async () => { - resetState({}, 100) - configRef.value.sponsoredTipsHistory = { lastShownAt: 92, totalShown: 3 } - const { tipHistory } = await freshImport() - expect(tipHistory.getSessionsSinceLastSponsored()).toBe(8) - }) +test('Verboo Code has no sponsored tip catalogue or enabled advertising slots', () => { + expect(sponsoredTipsEnabled()).toBe(false) + expect(getSponsoredTipsFrequency()).toBe(0) + expect(sponsoredTips).toEqual([]) }) diff --git a/src/services/tips/tipScheduler.test.ts b/src/services/tips/tipScheduler.test.ts index 530067f0ee..7e5e9abe98 100644 --- a/src/services/tips/tipScheduler.test.ts +++ b/src/services/tips/tipScheduler.test.ts @@ -76,8 +76,8 @@ function setState(opts: { relevantTipsRef.value = opts.tips } -describe('getTipToShowOnSpinner — sponsored partitioning', () => { - test('picks sponsored when cap met and sponsored tips eligible', async () => { +describe('getTipToShowOnSpinner — legacy sponsorship settings', () => { + test('uses normal tip ordering regardless of legacy sponsorship frequency', async () => { setState({ numStartups: 100, lastSponsored: 80, // 20 sessions ago, frequency 10 → eligible @@ -86,7 +86,7 @@ describe('getTipToShowOnSpinner — sponsored partitioning', () => { }) const { getTipToShowOnSpinner } = await freshScheduler() const pick = await getTipToShowOnSpinner() - expect(pick?.id).toBe('atomic-x') + expect(pick?.id).toBe('regular-1') }) test('falls back to regular when cap not met', async () => { @@ -113,7 +113,7 @@ describe('getTipToShowOnSpinner — sponsored partitioning', () => { expect(pick?.id).toBe('regular-1') }) - test('first-ever sponsored slot is eligible (no history)', async () => { + test('no legacy sponsorship history grants priority', async () => { setState({ numStartups: 100, // no lastSponsored → Infinity sessions @@ -122,7 +122,7 @@ describe('getTipToShowOnSpinner — sponsored partitioning', () => { }) const { getTipToShowOnSpinner } = await freshScheduler() const pick = await getTipToShowOnSpinner() - expect(pick?.id).toBe('atomic-x') + expect(pick?.id).toBe('regular-1') }) test('returns undefined when no tips at all', async () => { @@ -145,14 +145,12 @@ describe('getTipToShowOnSpinner — sponsored partitioning', () => { }) describe('recordShownTip — sponsored side effects', () => { - test('records sponsored history when tip has sponsor', async () => { + test('records ordinary history without reviving removed sponsorship tracking', async () => { setState({ numStartups: 100, tips: [] }) const { recordShownTip } = await freshScheduler() recordShownTip(makeTip('atomic-x', true)) - expect(configRef.value.sponsoredTipsHistory).toEqual({ - lastShownAt: 100, - totalShown: 1, - }) + expect(configRef.value.sponsoredTipsHistory).toBeUndefined() + expect(configRef.value.tipsHistory).toEqual({ 'atomic-x': 100 }) }) test('does not record sponsored history for regular tips', async () => { diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index a09e8168ad..a2ed180fed 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -1,4 +1,5 @@ import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; +import { agentUsageFromMessages, combineAgentUsage, type AgentTokenUsage, type AgentUsageUpdate } from '../../utils/agentUsage.js'; import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG, WORKTREE_BRANCH_TAG, WORKTREE_PATH_TAG, WORKTREE_TAG } from '../../constants/xml.js'; import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; import type { AppState } from '../../state/AppState.js'; @@ -33,6 +34,7 @@ export type ToolActivity = { export type AgentProgress = { toolUseCount: number; tokenCount: number; + tokenUsage?: AgentTokenUsage; lastActivity?: ToolActivity; recentActivities?: ToolActivity[]; summary?: string; @@ -40,23 +42,28 @@ export type AgentProgress = { const MAX_RECENT_ACTIVITIES = 5; export type ProgressTracker = { toolUseCount: number; - // Track input and output separately to avoid double-counting. - // input_tokens in Claude API is cumulative per turn (includes all previous context), - // so we keep the latest value. output_tokens is per-turn, so we sum those. - latestInputTokens: number; - cumulativeOutputTokens: number; + usageUpdates: Map; + messages: Map; + toolUseIds: Set; recentActivities: ToolActivity[]; }; export function createProgressTracker(): ProgressTracker { return { toolUseCount: 0, - latestInputTokens: 0, - cumulativeOutputTokens: 0, + usageUpdates: new Map(), + messages: new Map(), + toolUseIds: new Set(), recentActivities: [] }; } export function getTokenCountFromTracker(tracker: ProgressTracker): number { - return tracker.latestInputTokens + tracker.cumulativeOutputTokens; + return getTrackerUsage(tracker).confirmed; +} +export function updateProgressUsage(tracker: ProgressTracker, update: AgentUsageUpdate): void { + tracker.usageUpdates.set(update.executionId, update.usage); +} +export function getTrackerUsage(tracker: ProgressTracker): AgentTokenUsage { + return tracker.usageUpdates.size ? combineAgentUsage(tracker.usageUpdates.values()) : agentUsageFromMessages([...tracker.messages.values()]); } /** @@ -69,12 +76,11 @@ export function updateProgressFromMessage(tracker: ProgressTracker, message: Mes if (message.type !== 'assistant') { return; } - const usage = message.message.usage; - // Keep latest input (it's cumulative in the API), sum outputs - tracker.latestInputTokens = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); - tracker.cumulativeOutputTokens += usage.output_tokens; + tracker.messages.set(message.uuid, message); for (const content of message.message.content) { if (content.type === 'tool_use') { + if (tracker.toolUseIds.has(content.id)) continue; + tracker.toolUseIds.add(content.id); tracker.toolUseCount++; // Omit StructuredOutput from preview - it's an internal tool if (content.name !== SYNTHETIC_OUTPUT_TOOL_NAME) { @@ -98,6 +104,7 @@ export function getProgressUpdate(tracker: ProgressTracker): AgentProgress { return { toolUseCount: tracker.toolUseCount, tokenCount: getTokenCountFromTracker(tracker), + tokenUsage: getTrackerUsage(tracker), lastActivity: tracker.recentActivities.length > 0 ? tracker.recentActivities[tracker.recentActivities.length - 1] : undefined, recentActivities: [...tracker.recentActivities] }; @@ -216,6 +223,7 @@ export function enqueueAgentNotification({ finalMessage?: string; usage?: { totalTokens: number; + tokenUsage?: AgentTokenUsage; toolUses: number; durationMs: number; }; @@ -249,7 +257,7 @@ export function enqueueAgentNotification({ const outputPath = getTaskOutputPath(taskId); const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; const resultSection = finalMessage ? `\n${finalMessage}` : ''; - const usageSection = usage ? `\n${usage.totalTokens}${usage.toolUses}${usage.durationMs}` : ''; + const usageSection = usage ? `\n${usage.totalTokens}${usage.toolUses}${usage.durationMs}${usage.tokenUsage ? `${JSON.stringify(usage.tokenUsage)}` : ''}` : ''; const worktreeSection = worktreePath ? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}` : ''}` : ''; const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} @@ -338,9 +346,9 @@ export function markAgentsNotified(taskId: string, setAppState: SetAppState): vo * Preserves the existing summary field so that background summarization * results are not clobbered by progress updates from assistant messages. */ -export function updateAgentProgress(taskId: string, progress: AgentProgress, setAppState: SetAppState): void { +export function updateAgentProgress(taskId: string, progress: AgentProgress, setAppState: SetAppState, executionId?: string, final = false): void { updateTaskState(taskId, setAppState, task => { - if (task.status !== 'running') { + if ((task.status !== 'running' && !(final && executionId !== undefined && (task.status === 'killed' || task.status === 'failed'))) || (executionId !== undefined && task.executionId !== executionId)) { return task; } const existingSummary = task.progress?.summary; @@ -420,8 +428,10 @@ export function completeAgentTask(result: AgentToolResult, setAppState: SetAppSt task.unregisterCleanup?.(); return { ...task, - status: 'completed', + status: result.completionReason === 'failed' ? 'failed' : result.completionReason === 'killed' ? 'killed' : 'completed', + error: result.completionReason === 'failed' ? result.content.map(block => block.text).join('\n') : undefined, result, + progress: { ...task.progress, tokenCount: result.totalTokens, tokenUsage: result.tokenUsage, toolUseCount: result.totalToolUseCount }, endTime: Date.now(), evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, diff --git a/src/tasks/LocalAgentTask/agentUsage.test.ts b/src/tasks/LocalAgentTask/agentUsage.test.ts new file mode 100644 index 0000000000..b7e7a3a921 --- /dev/null +++ b/src/tasks/LocalAgentTask/agentUsage.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'bun:test' +import { createProgressTracker, updateProgressFromMessage, getProgressUpdate, updateProgressUsage, updateAgentProgress } from './LocalAgentTask.js' +import { finalizeAgentTool } from '../../tools/AgentTool/agentToolUtils.js' +import { createAssistantMessage } from '../../utils/messages.js' +import { AgentUsageAccumulator } from '../../utils/agentUsage.js' + +test('late usage, duplicated tool records and final virtual messages agree across progress and result', () => { + const message = createAssistantMessage({ content: [{ type: 'tool_use', id: 'tool-one', name: 'Read', input: { file_path: 'fixture' } }] }) + message.message.model = 'fixture' + const tracker = createProgressTracker() + updateProgressFromMessage(tracker, message) + updateProgressFromMessage(tracker, message) + message.message.usage = { ...message.message.usage, input_tokens: 123, output_tokens: 45 } + const notice = createAssistantMessage({ content: 'Time budget reached', isVirtual: true }) + updateProgressFromMessage(tracker, notice) + const result = finalizeAgentTool([message, message, notice], 'fixture', { prompt: 'fixture', resolvedAgentModel: 'fixture', isBuiltInAgent: false, startTime: Date.now(), agentType: 'test', isAsync: true }) + expect(getProgressUpdate(tracker)).toMatchObject({ tokenCount: 168, toolUseCount: 1 }) + expect(result.totalTokens).toBe(168) + expect(result.usage.input_tokens).toBe(123) + expect(result.totalToolUseCount).toBe(1) +}) + +test('cancel flush keeps consumption without resurrecting a stopped or replaced task', () => { + let state = { tasks: { agent: { id: 'agent', type: 'local_agent', status: 'killed', executionId: 'current' } } } as any + const setState = (update: (value: typeof state) => typeof state) => { state = update(state) } + const progress = { tokenCount: 144, toolUseCount: 1, recentActivities: [] } + const before = state + updateAgentProgress('agent', progress, setState, 'old', true) + expect(state.tasks.agent.progress).toBeUndefined() + updateAgentProgress('agent', progress, setState, 'current') + expect(state.tasks.agent.progress).toBeUndefined() + updateAgentProgress('agent', progress, setState, 'current', true) + expect(state.tasks.agent.progress.tokenCount).toBe(144) + expect(state.tasks.agent.status).toBe('killed') + expect(before.tasks.agent.progress).toBeUndefined() +}) + +test('foreground/background snapshots are replaced per execution, never added twice', () => { + const tracker = createProgressTracker() + const a = new AgentUsageAccumulator() + const m = createAssistantMessage({ content: 'Result' }) + m.message.model = 'fixture'; m.message.usage.input_tokens = 100 + a.observe(m) + updateProgressUsage(tracker, { executionId: 'foreground', usage: a.snapshot() }) + updateProgressUsage(tracker, { executionId: 'foreground', usage: a.snapshot() }) + updateProgressUsage(tracker, { executionId: 'background', usage: a.snapshot() }) + expect(getProgressUpdate(tracker).tokenCount).toBe(200) +}) diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 7f5e6b74f9..7fed60b77b 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -11,7 +11,7 @@ import { startAgentSummarization } from '../../services/AgentSummary/agentSummar import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; import { clearDumpState } from '../../services/api/dumpPrompts.js'; -import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; +import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage, updateProgressUsage, getTrackerUsage } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; import { assembleToolPool } from '../../tools.js'; import { asAgentId } from '../../types/ids.js'; @@ -24,7 +24,7 @@ import { isEnvTruthy } from '../../utils/envUtils.js'; import { AbortError, errorMessage, toError } from '../../utils/errors.js'; import type { CacheSafeParams } from '../../utils/forkedAgent.js'; import { lazySchema } from '../../utils/lazySchema.js'; -import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from '../../utils/messages.js'; +import { createUserMessage, extractTextContent, normalizeMessages } from '../../utils/messages.js'; import { resolveAgentExecutionModel } from '../../services/api/agentRouting.js'; import { getInitialSettings } from '../../utils/settings/settings.js'; import { createAgentExecutionBudgetState } from '../../query/agentExecutionBudget.js'; @@ -772,14 +772,15 @@ export const AgentTool = buildTool({ void runWithAgentContext(asyncAgentContext, () => wrapWithCwd(() => runAsyncAgentLifecycle({ taskId: agentBackgroundTask.agentId, abortController: agentBackgroundTask.abortController!, - makeStream: onCacheSafeParams => runAgent({ + makeStream: (onCacheSafeParams, onUsageUpdate) => runAgent({ ...runAgentParams, override: { ...runAgentParams.override, agentId: asAgentId(agentBackgroundTask.agentId), abortController: agentBackgroundTask.abortController! }, - onCacheSafeParams + onCacheSafeParams, + onUsageUpdate }), metadata, description, @@ -888,9 +889,24 @@ export const AgentTool = buildTool({ // const capture for sound type narrowing inside the callback below const summaryTaskId = foregroundTaskId; + const publishSyncUsage = (update: import('../../utils/agentUsage.js').AgentUsageUpdate) => { + const task = foregroundTaskId ? toolUseContext.getAppState().tasks[foregroundTaskId] : undefined; + if (isLocalAgentTask(task) && task.executionId !== foregroundExecutionId) return; + if (wasBackgrounded && !update.final) return; + updateProgressUsage(syncTracker, update); + if (wasBackgrounded) return; + const progress = getProgressUpdate(syncTracker); + if (foregroundTaskId) { + updateAsyncAgentProgress(foregroundTaskId, progress, rootSetAppState, foregroundExecutionId, update.final); + emitTaskProgress(syncTracker, foregroundTaskId, toolUseContext.toolUseId, description, agentStartTime); + } + onProgress?.({ toolUseID: `agent_usage_${syncAgentId}`, data: { type: 'agent_usage', agentId: syncAgentId, ...progress } }); + }; + // Get async iterator for the agent const agentIterator = runAgent({ ...runAgentParams, + onUsageUpdate: publishSyncUsage, override: { ...runAgentParams.override, agentId: syncAgentId, @@ -976,13 +992,19 @@ export const AgentTool = buildTool({ if (!isCurrentBackground()) return; if (backgroundController.signal.aborted) throw new AbortError(); // Initialize progress tracking from existing messages - const tracker = createProgressTracker(); + const tracker = syncTracker; const resolveActivity2 = createActivityDescriptionResolver(toolUseContext.options.tools); for (const existingMsg of agentMessages) { updateProgressFromMessage(tracker, existingMsg, resolveActivity2, toolUseContext.options.tools); } for await (const msg of runAgent({ ...runAgentParams, + onUsageUpdate: update => { + if (!isCurrentBackground()) return; + updateProgressUsage(tracker, update); + updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState, task.executionId, update.final); + emitTaskProgress(tracker, backgroundedTaskId, toolUseContext.toolUseId, description, startTime); + }, isAsync: true, // Agent is now running in background override: { @@ -1011,7 +1033,7 @@ export const AgentTool = buildTool({ } if (!isCurrentBackground()) return; if (backgroundController.signal.aborted) throw new AbortError(); - const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); + const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, { ...metadata, tokenUsage: getTrackerUsage(tracker) }); // Mark task completed FIRST so TaskOutput(block=true) // unblocks immediately. classifyHandoffIfNeeded and @@ -1042,11 +1064,11 @@ export const AgentTool = buildTool({ enqueueAgentNotification({ taskId: backgroundedTaskId, description, - status: 'completed', + status: agentResult.completionReason === 'failed' ? 'failed' : 'completed', setAppState: rootSetAppState, finalMessage, usage: { - totalTokens: getTokenCountFromTracker(tracker), + totalTokens: getTokenCountFromTracker(tracker), tokenUsage: getTrackerUsage(tracker), toolUses: agentResult.totalToolUseCount, durationMs: agentResult.totalDurationMs }, @@ -1145,7 +1167,7 @@ export const AgentTool = buildTool({ // Keep AppState task.progress in sync when SDK summaries are // enabled, so updateAgentSummary reads correct token/tool counts // instead of zeros. - if (getSdkAgentProgressSummariesEnabled()) { + if (foregroundTaskId) { updateAsyncAgentProgress(foregroundTaskId, getProgressUpdate(syncTracker), rootSetAppState); } } @@ -1222,6 +1244,10 @@ export const AgentTool = buildTool({ } finally { const foregroundTask = foregroundTaskId ? toolUseContext.getAppState().tasks[foregroundTaskId] : undefined; const wasReplaced = isLocalAgentTask(foregroundTask) && foregroundTask.executionId !== foregroundExecutionId; + wasAborted ||= (toolUseContext.abortController.signal.aborted || foregroundAbortController?.signal.aborted === true) && !metadata.executionBudgetState?.completionReason; + if (agentMessages.findLast(message => message.type === 'assistant')?.isApiErrorMessage) { + syncAgentError ??= new Error(extractPartialResult(agentMessages) || 'Agent request failed'); + } // Clear the background hint UI if (toolUseContext.setToolJSX) { toolUseContext.setToolJSX(null); @@ -1251,7 +1277,8 @@ export const AgentTool = buildTool({ usage: { total_tokens: progress.tokenCount, tool_uses: progress.toolUseCount, - duration_ms: Date.now() - agentStartTime + duration_ms: Date.now() - agentStartTime, + token_usage: progress.tokenUsage } }); } @@ -1279,8 +1306,7 @@ export const AgentTool = buildTool({ // Re-throw abort errors // TODO: Find a cleaner way to express this - const lastMessage = agentMessages.findLast(_ => _.type !== 'system' && _.type !== 'progress'); - if (lastMessage && isSyntheticMessage(lastMessage)) { + if ((toolUseContext.abortController.signal.aborted || foregroundAbortController?.signal.aborted) && !metadata.executionBudgetState?.completionReason) { logEvent('tengu_agent_tool_terminated', { agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -1307,7 +1333,7 @@ export const AgentTool = buildTool({ // This allows the parent agent to see partial progress even after an error logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`); } - const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata); + const agentResult = finalizeAgentTool(agentMessages, syncAgentId, { ...metadata, tokenUsage: getTrackerUsage(syncTracker), completionReason: syncAgentError ? 'failed' : undefined }); if (feature('TRANSCRIPT_CLASSIFIER')) { const currentAppState = toolUseContext.getAppState(); const handoffWarning = await classifyHandoffIfNeeded({ diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index ecae679b8d..b01adec651 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -1,3 +1,6 @@ +import { agentUsageFromMessages, agentUsageDisplay, type AgentTokenUsage } from '../../utils/agentUsage.js'; +import { agentGroupLayout, agentStatusLabel } from '../../components/agentPresentation.js'; +import { TOOL_EXECUTION_INTERRUPTED } from '../../utils/finishInterruptedMessages.js'; import { c as _c } from "react-compiler-runtime"; import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; import * as React from 'react'; @@ -22,7 +25,7 @@ import { count } from '../../utils/array.js'; import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js'; import { getDisplayPath } from '../../utils/file.js'; import { formatDuration, formatNumber } from '../../utils/format.js'; -import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js'; +import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS, INTERRUPT_MESSAGE_FOR_TOOL_USE } from '../../utils/messages.js'; import type { ModelAlias } from '../../utils/model/aliases.js'; import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js'; import type { Theme, ThemeName } from '../../utils/theme.js'; @@ -373,8 +376,8 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage content, prompt } = data; - const result = [totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs)]; - const completionMessage = `Done (${result.join(' · ')})`; + const result = [totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, agentUsageDisplay(data.tokenUsage, totalTokens, formatNumber), formatDuration(totalDurationMs)]; + const completionMessage = `${agentStatusLabel(data.completionReason ?? 'completed')} (${result.join(' · ')})`; const finalAssistantMessage = createAssistantMessage({ content: completionMessage, usage: { @@ -468,37 +471,17 @@ export function renderToolUseProgressMessage(progressMessages: ProgressMessage

{ - const toolUseCount = count(progressMessages, msg => { - if (!hasProgressMessage(msg.data)) { - return false; - } - const message = msg.data.message; - return message.message.content.some(content => content.type === 'tool_use'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; - if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; - } - return { - toolUseCount, - tokens - }; - }; + const getProgressStats = () => calculateAgentStats(progressMessages); if (shouldUseCondensedMode) { const { toolUseCount, - tokens + tokens, tokenUsage } = getProgressStats(); return - - In progress… · {toolUseCount} tool{' '} - {toolUseCount === 1 ? 'use' : 'uses'} - {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} - - + + {agentUsageDisplay(tokenUsage, tokens, formatNumber)} + {` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · working`} + ; } @@ -533,7 +516,7 @@ export function renderToolUseProgressMessage(progressMessages: ProgressMessage

- {INITIALIZING_TEXT} + {agentUsageDisplay(getProgressStats().tokenUsage, getProgressStats().tokens, formatNumber)} ; } const { @@ -561,6 +544,7 @@ export function renderToolUseProgressMessage(progressMessages: ProgressMessage

; })} + {agentUsageDisplay(getProgressStats().tokenUsage, getProgressStats().tokens, formatNumber)} {hiddenToolUseCount > 0 && +{hiddenToolUseCount} more tool{' '} {hiddenToolUseCount === 1 ? 'use' : 'uses'} @@ -624,27 +608,17 @@ export function renderToolUseErrorMessage(result: ToolResultBlockParam['content' ; } -function calculateAgentStats(progressMessages: ProgressMessage[]): { - toolUseCount: number; - tokens: number | null; -} { - const toolUseCount = count(progressMessages, msg => { - if (!hasProgressMessage(msg.data)) { - return false; - } - const message = msg.data.message; - return message.type === 'user' && message.message.content.some(content => content.type === 'tool_result'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; - if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; - } - return { - toolUseCount, - tokens - }; +export function calculateAgentStats(progressMessages: ProgressMessage[]): { toolUseCount: number; tokens: number | null; tokenUsage?: AgentTokenUsage } { + const live = progressMessages.findLast(pm => pm.data.type === 'agent_usage')?.data; + if (live) return { toolUseCount: live.toolUseCount, tokens: live.tokenCount, tokenUsage: live.tokenUsage }; + const messages = progressMessages.filter(pm => hasProgressMessage(pm.data)).map(pm => pm.data.message); + const ids = new Set(); + for (const message of messages) for (const block of message.message.content) { + if (block.type === 'tool_use') ids.add(block.id); + if (block.type === 'tool_result') ids.add(block.tool_use_id); + } + const tokenUsage = agentUsageFromMessages(messages); + return { toolUseCount: ids.size, tokens: tokenUsage.confirmed, tokenUsage }; } export function renderGroupedAgentToolUse(toolUses: Array<{ param: ToolUseBlockParam; @@ -659,6 +633,8 @@ export function renderGroupedAgentToolUse(toolUses: Array<{ }>, options: { shouldAnimate: boolean; tools: Tools; + terminalSize?: { columns: number; rows: number }; + activeGroupCount?: number; }): React.ReactNode | null { const { shouldAnimate, @@ -713,8 +689,10 @@ export function renderGroupedAgentToolUse(toolUses: Array<{ id: param.id, agentType, description, - toolUseCount: stats.toolUseCount, - tokens: stats.tokens, + toolUseCount: result?.output?.totalToolUseCount ?? stats.toolUseCount, + tokens: result?.output?.totalTokens ?? stats.tokens, + tokenUsage: result?.output?.tokenUsage ?? stats.tokenUsage, + status: result?.output?.completionReason ?? (result?.param.is_error && (result.param.content === TOOL_EXECUTION_INTERRUPTED || result.param.content === INTERRUPT_MESSAGE_FOR_TOOL_USE) ? 'killed' as const : undefined), isResolved, isError, isAsync, @@ -735,8 +713,9 @@ export function renderGroupedAgentToolUse(toolUses: Array<{ // Check if all resolved agents are async (background) const allAsync = agentStats.every(stat => stat.isAsync); - return - + const layout = agentGroupLayout(options.terminalSize?.columns ?? 80, options.terminalSize?.rows ?? 24, agentStats.length, options.activeGroupCount); + return + {allComplete ? allAsync ? <> @@ -754,7 +733,8 @@ export function renderGroupedAgentToolUse(toolUses: Array<{ {!allAsync && } - {agentStats.map((stat, index) => )} + {agentStats.slice(0, layout.visible).map((stat, index) => )} + {layout.showOverflow && +{layout.hidden} agents · ctrl+o to expand / ↓ to manage} ; } export function userFacingName(input: Partial<{ diff --git a/src/tools/AgentTool/agentToolUtils.budget.test.ts b/src/tools/AgentTool/agentToolUtils.budget.test.ts index afe3edea42..362cf20ed1 100644 --- a/src/tools/AgentTool/agentToolUtils.budget.test.ts +++ b/src/tools/AgentTool/agentToolUtils.budget.test.ts @@ -1,8 +1,11 @@ import { expect, test } from 'bun:test' -import { createAssistantMessage } from '../../utils/messages.js' +import { createAssistantMessage, createAssistantAPIErrorMessage } from '../../utils/messages.js' +import { createAttachmentMessage } from '../../utils/attachments.js' import { createAgentExecutionBudgetState } from '../../query/agentExecutionBudget.js' import { finalizeAgentTool } from './agentToolUtils.js' +import { completeAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import type { AppState } from '../../state/AppState.js' test('returns prior findings together with the hard-timeout notice', () => { const budget = createAgentExecutionBudgetState( @@ -50,3 +53,25 @@ test('returns prior findings together with the hard-timeout notice', () => { hardTimeoutMs: 180_000, }) }) + +test('an API error is a failed task; a later recovered response can complete normally', () => { + const error = createAssistantAPIErrorMessage({ content: 'Provider request rejected' }) + const metadata = { prompt: 'fixture', resolvedAgentModel: 'fixture', isBuiltInAgent: true, startTime: Date.now(), agentType: 'general-purpose', isAsync: true } + const failed = finalizeAgentTool([error], 'agent-test', metadata) + expect(failed.completionReason).toBe('failed') + expect(failed.tokenUsage?.state).toBe('pending') + let state = { tasks: { 'agent-test': { id: 'agent-test', type: 'local_agent', status: 'running' } } } as unknown as AppState + completeAgentTask(failed, update => { state = update(state) }) + expect(state.tasks['agent-test']).toMatchObject({ status: 'failed', error: 'Provider request rejected', result: failed }) + const recovered = finalizeAgentTool([error, createAssistantMessage({ content: 'Recovered result' })], 'agent-test', metadata) + expect(recovered.completionReason).toBe('completed') +}) + +test('the max_turns attachment carries the terminal reason without a separate execution budget', () => { + const result = finalizeAgentTool([ + createAssistantMessage({ content: 'Partial finding' }), + createAttachmentMessage({ type: 'max_turns_reached', maxTurns: 1 }), + ], 'agent-test', { prompt: 'fixture', resolvedAgentModel: 'fixture', isBuiltInAgent: true, startTime: Date.now(), agentType: 'general-purpose', isAsync: false }) + expect(result.completionReason).toBe('max_turns') + expect(result.content[0]?.text).toBe('Partial finding') +}) diff --git a/src/tools/AgentTool/agentToolUtils.ts b/src/tools/AgentTool/agentToolUtils.ts index bfddbde60f..f5c1eb025b 100644 --- a/src/tools/AgentTool/agentToolUtils.ts +++ b/src/tools/AgentTool/agentToolUtils.ts @@ -1,4 +1,6 @@ import { feature } from 'bun:bundle' +import { agentUsageFromMessages, type AgentTokenUsage, type AgentUsageUpdate } from '../../utils/agentUsage.js' +import { agentTokenUsageSchema } from '../../utils/agentUsageSchema.js' import { z } from 'zod/v4' import { clearInvokedSkillsForAgent } from '../../bootstrap/state.js' import { @@ -34,6 +36,8 @@ import { type ProgressTracker, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage, + updateProgressUsage, + getTrackerUsage, } from '../../tasks/LocalAgentTask/LocalAgentTask.js' import { asAgentId } from '../../types/ids.js' import type { Message as MessageType } from '../../types/message.js' @@ -60,7 +64,6 @@ import { } from '../../utils/permissions/yoloClassifier.js' import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.js' import { isInProcessTeammate } from '../../utils/teammateContext.js' -import { getTokenCountFromUsage } from '../../utils/tokens.js' import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js' import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js' import type { AgentDefinition } from './loadAgentsDir.js' @@ -240,6 +243,7 @@ export const agentToolResultSchema = lazySchema(() => totalToolUseCount: z.number(), totalDurationMs: z.number(), totalTokens: z.number(), + tokenUsage: agentTokenUsageSchema.optional(), usage: z.object({ input_tokens: z.number(), output_tokens: z.number(), @@ -260,7 +264,7 @@ export const agentToolResultSchema = lazySchema(() => .nullable(), }), completionReason: z - .enum(['completed', 'max_turns', 'max_tool_calls', 'timeout']) + .enum(['completed', 'max_turns', 'max_tool_calls', 'timeout', 'failed', 'killed']) .optional(), budgetUsage: z .object({ @@ -277,17 +281,17 @@ export const agentToolResultSchema = lazySchema(() => export type AgentToolResult = z.input> export function countToolUses(messages: MessageType[]): number { - let count = 0 + const ids = new Set() for (const m of messages) { if (m.type === 'assistant') { for (const block of m.message.content) { if (block.type === 'tool_use') { - count++ + ids.add(block.id) } } } } - return count + return ids.size } export function finalizeAgentTool( @@ -301,6 +305,8 @@ export function finalizeAgentTool( agentType: string isAsync: boolean executionBudgetState?: AgentExecutionBudgetState + tokenUsage?: AgentTokenUsage + completionReason?: 'failed' | 'killed' }, ): AgentToolResult { const { @@ -346,10 +352,12 @@ export function finalizeAgentTool( } } - const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message.usage) + const tokenUsage = metadata.tokenUsage ?? agentUsageFromMessages(agentMessages) + const totalTokens = tokenUsage.confirmed const totalToolUseCount = countToolUses(agentMessages) - const completionReason: AgentCompletionReason = - executionBudgetState?.completionReason ?? 'completed' + const reachedMaxTurns = agentMessages.some(message => message.type === 'attachment' && message.attachment.type === 'max_turns_reached') + const completionReason: AgentCompletionReason | 'failed' | 'killed' = + metadata.completionReason ?? executionBudgetState?.completionReason ?? (reachedMaxTurns ? 'max_turns' : lastAssistantMessage.isApiErrorMessage ? 'failed' : 'completed') logEvent('tengu_agent_tool_completed', { agent_type: @@ -390,10 +398,11 @@ export function finalizeAgentTool( content, totalDurationMs: Date.now() - startTime, totalTokens, + tokenUsage, totalToolUseCount, - usage: lastAssistantMessage.message.usage, + usage: { ...lastAssistantMessage.message.usage, input_tokens: tokenUsage.inputTokens, output_tokens: tokenUsage.outputTokens, cache_read_input_tokens: tokenUsage.cacheReadTokens, cache_creation_input_tokens: tokenUsage.cacheCreationTokens }, + completionReason, ...(executionBudgetState && { - completionReason, budgetUsage: getAgentBudgetUsage(executionBudgetState), }), } @@ -415,7 +424,7 @@ export function emitTaskProgress( toolUseId: string | undefined, description: string, startTime: number, - lastToolName: string, + lastToolName?: string, ): void { const progress = getProgressUpdate(tracker) emitTaskProgressEvent({ @@ -424,6 +433,7 @@ export function emitTaskProgress( description: progress.lastActivity?.activityDescription ?? description, startTime, totalTokens: progress.tokenCount, + tokenUsage: progress.tokenUsage, toolUses: progress.toolUseCount, lastToolName, }) @@ -564,6 +574,7 @@ export async function runAsyncAgentLifecycle({ abortController: AbortController makeStream: ( onCacheSafeParams: ((p: CacheSafeParams) => void) | undefined, + onUsageUpdate: (update: AgentUsageUpdate) => void, ) => AsyncGenerator metadata: Parameters[2] description: string @@ -600,7 +611,13 @@ export async function runAsyncAgentLifecycle({ stopSummarization = stop } : undefined - for await (const message of makeStream(onCacheSafeParams)) { + const onUsageUpdate = (update: AgentUsageUpdate) => { + if (!isCurrentExecution() || (abortController.signal.aborted && !update.final)) return + updateProgressUsage(tracker, update) + updateAsyncAgentProgress(taskId, getProgressUpdate(tracker), rootSetAppState, executionId, update.final) + emitTaskProgress(tracker, taskId, toolUseContext.toolUseId, description, metadata.startTime) + } + for await (const message of makeStream(onCacheSafeParams, onUsageUpdate)) { if (!isCurrentExecution()) return if (abortController.signal.aborted) throw new AbortError() agentMessages.push(message) @@ -648,7 +665,7 @@ export async function runAsyncAgentLifecycle({ if (!isCurrentExecution()) return if (abortController.signal.aborted) throw new AbortError() - const agentResult = finalizeAgentTool(agentMessages, taskId, metadata) + const agentResult = finalizeAgentTool(agentMessages, taskId, { ...metadata, tokenUsage: getTrackerUsage(tracker) }) // Mark task completed FIRST so TaskOutput(block=true) unblocks // immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult @@ -680,11 +697,11 @@ export async function runAsyncAgentLifecycle({ enqueueAgentNotification({ taskId, description, - status: 'completed', + status: agentResult.completionReason === 'failed' ? 'failed' : 'completed', setAppState: rootSetAppState, finalMessage, usage: { - totalTokens: getTokenCountFromTracker(tracker), + totalTokens: getTokenCountFromTracker(tracker), tokenUsage: getTrackerUsage(tracker), toolUses: agentResult.totalToolUseCount, durationMs: agentResult.totalDurationMs, }, @@ -721,6 +738,7 @@ export async function runAsyncAgentLifecycle({ setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, finalMessage: partialResult, + usage: { totalTokens: getTokenCountFromTracker(tracker), tokenUsage: getTrackerUsage(tracker), toolUses: tracker.toolUseCount, durationMs: Date.now() - metadata.startTime }, ...worktreeResult, }) return @@ -736,6 +754,7 @@ export async function runAsyncAgentLifecycle({ error: msg, setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, + usage: { totalTokens: getTokenCountFromTracker(tracker), tokenUsage: getTrackerUsage(tracker), toolUses: tracker.toolUseCount, durationMs: Date.now() - metadata.startTime }, ...worktreeResult, }) } finally { diff --git a/src/tools/AgentTool/loadAgentsDir.ts b/src/tools/AgentTool/loadAgentsDir.ts index aa5642edd0..84a2bcf4d6 100644 --- a/src/tools/AgentTool/loadAgentsDir.ts +++ b/src/tools/AgentTool/loadAgentsDir.ts @@ -201,7 +201,9 @@ export function getActiveAgentsFromList( const builtInAgents = allAgents.filter(a => a.source === 'built-in') const pluginAgents = allAgents.filter(a => a.source === 'plugin') const userAgents = allAgents.filter(a => a.source === 'userSettings') - const projectAgents = allAgents.filter(a => a.source === 'projectSettings') + // The loader orders project directories from nearest/canonical to legacy/parent. + // Apply low priority definitions first so the nearest .verboo definition wins. + const projectAgents = allAgents.filter(a => a.source === 'projectSettings').reverse() const managedAgents = allAgents.filter(a => a.source === 'policySettings') const flagAgents = allAgents.filter(a => a.source === 'flagSettings') diff --git a/src/tools/AgentTool/resumeAgent.ts b/src/tools/AgentTool/resumeAgent.ts index f745ab70df..4cf56505fc 100644 --- a/src/tools/AgentTool/resumeAgent.ts +++ b/src/tools/AgentTool/resumeAgent.ts @@ -245,7 +245,7 @@ export async function resumeAgentBackground({ runAsyncAgentLifecycle({ taskId: agentBackgroundTask.agentId, abortController: agentBackgroundTask.abortController!, - makeStream: onCacheSafeParams => + makeStream: (onCacheSafeParams, onUsageUpdate) => runAgent({ ...runAgentParams, override: { @@ -254,6 +254,7 @@ export async function resumeAgentBackground({ abortController: agentBackgroundTask.abortController!, }, onCacheSafeParams, + onUsageUpdate, }), metadata, description: uiDescription, diff --git a/src/tools/AgentTool/runAgent.ts b/src/tools/AgentTool/runAgent.ts index 2ae7eb92f9..9769bbeefb 100644 --- a/src/tools/AgentTool/runAgent.ts +++ b/src/tools/AgentTool/runAgent.ts @@ -1,6 +1,7 @@ import { feature } from 'bun:bundle' import type { UUID } from 'crypto' import { randomUUID } from 'crypto' +import { AgentUsageAccumulator, createUsagePublisher, type AgentUsageUpdate } from '../../utils/agentUsage.js' import uniqBy from 'lodash-es/uniqBy.js' import { logForDebugging } from 'src/utils/debug.js' import { getProjectRoot, getSessionId } from '../../bootstrap/state.js' @@ -77,6 +78,7 @@ import { recordSidechainTranscript, setAgentTranscriptSubdir, writeAgentMetadata, + writeAgentUsageMetadata, } from '../../utils/sessionStorage.js' import { isRestrictedToPluginOnly, @@ -276,6 +278,7 @@ export async function* runAgent({ description, transcriptSubdir, onQueryProgress, + onUsageUpdate, agentName, modelResolution, executionBudgetState: providedExecutionBudgetState, @@ -338,6 +341,7 @@ export async function* runAgent({ * during long single-block streams (e.g. thinking) where no assistant * message is yielded for >60s. */ onQueryProgress?: () => void + onUsageUpdate?: (update: AgentUsageUpdate) => void /** Agent name (team member name) for routing resolution */ agentName?: string /** Pre-resolved by AgentTool so prompt/logging/API use the exact same route. */ @@ -789,8 +793,12 @@ export async function* runAgent({ void recordSidechainTranscript(initialMessages, agentId).catch(_err => logForDebugging(`Failed to record sidechain transcript: ${_err}`), ) - void writeAgentMetadata(agentId, { + const usageExecutionId = randomUUID() + const usageAccumulator = new AgentUsageAccumulator(Math.ceil(JSON.stringify({ messages: initialMessages, system: agentSystemPrompt }).length / 4)) + const usagePublisher = createUsagePublisher(onUsageUpdate) + const metadataWrite = writeAgentMetadata(agentId, { agentType: agentDefinition.agentType, + executionId: usageExecutionId, ...(worktreePath && { worktreePath }), ...(description && { description }), }).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`)) @@ -829,6 +837,8 @@ export async function* runAgent({ executionBudgetState, })) { onQueryProgress?.() + usageAccumulator.observe(message) + usagePublisher.update({ executionId: usageExecutionId, usage: usageAccumulator.snapshot() }, message.type === 'stream_event' && (message.event.type === 'message_delta' || message.event.type === 'message_stop')) // Forward subagent API request starts to parent's metrics display // so TTFT/OTPS update during subagent execution. if ( @@ -844,6 +854,7 @@ export async function* runAgent({ if (message.type === 'attachment') { // Handle max turns reached signal from query.ts if (message.attachment.type === 'max_turns_reached') { + yield message logForDebugging( `[Agent : $ @@ -890,6 +901,9 @@ export async function* runAgent({ agentDefinition.callback() } } finally { + usagePublisher.update({ executionId: usageExecutionId, usage: usageAccumulator.snapshot(), final: true }, true) + await metadataWrite + await writeAgentUsageMetadata(agentId, usageExecutionId, usageAccumulator.snapshot()).catch(error => logForDebugging(`Failed to persist agent usage: ${error}`)) stopBudgetTimers?.() // Clean up agent-specific MCP servers (runs on normal completion, abort, or error) await mcpCleanup() diff --git a/src/tools/WebFetchTool/domainCheck.test.ts b/src/tools/WebFetchTool/domainCheck.test.ts index 243fedceec..2912e49bd9 100644 --- a/src/tools/WebFetchTool/domainCheck.test.ts +++ b/src/tools/WebFetchTool/domainCheck.test.ts @@ -58,7 +58,7 @@ describe('checkDomainBlocklist', () => { expect(getSpy).not.toHaveBeenCalled() }) - test('calls Anthropic domain check in first-party mode', async () => { + test('Verboo mode never calls the Anthropic domain service', async () => { delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI delete process.env.CLAUDE_CODE_USE_GITHUB @@ -78,6 +78,6 @@ describe('checkDomainBlocklist', () => { const result = await checkDomainBlocklist('example.com') expect(result.status).toBe('allowed') - expect(getSpy).toHaveBeenCalledTimes(1) + expect(getSpy).not.toHaveBeenCalled() }) }) diff --git a/src/utils/agentUsage.test.ts b/src/utils/agentUsage.test.ts new file mode 100644 index 0000000000..0dcd35c600 --- /dev/null +++ b/src/utils/agentUsage.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test' +import { AgentUsageAccumulator, agentUsageDisplay, agentUsageFromMessages, combineAgentUsage, createUsagePublisher, type AgentUsageUpdate } from './agentUsage.js' + +const stream = (event: Record) => ({ type: 'stream_event', event }) +const start = (id = 'response', usageReported = false) => stream({ type: 'message_start', message: { id, usageReported, usage: { input_tokens: 0, output_tokens: 0 } } }) +const delta = (text: string, type = 'text_delta') => stream({ type: 'content_block_delta', delta: { type, [type === 'text_delta' ? 'text' : type === 'thinking_delta' ? 'thinking' : 'partial_json']: text } }) +const usage = (input = 123, output = 45) => stream({ type: 'message_delta', usage: { input_tokens: input, output_tokens: output } }) +const assistant = (id: string, input: number, output: number, extra = {}) => ({ type: 'assistant', uuid: id, message: { id, model: 'fixture', content: [{ type: 'text', text: 'Result' }], usage: { input_tokens: input, output_tokens: output } }, ...extra }) + +test('late official usage replaces the estimate, including an explicit zero', () => { + const a = new AgentUsageAccumulator(20) + a.observe(start()) + expect(agentUsageDisplay(a.snapshot())).toBe('Awaiting response') + a.observe(delta('a'.repeat(40))) + expect(a.snapshot()).toMatchObject({ confirmed: 0, estimated: 30, state: 'estimated' }) + a.observe(usage()) + expect(a.snapshot()).toMatchObject({ confirmed: 168, estimated: 0, state: 'reported' }) + a.observe(usage()) + expect(a.snapshot().confirmed).toBe(168) + const zero = new AgentUsageAccumulator() + zero.observe(start()); zero.observe(delta('test')); zero.observe(usage(0, 0)) + expect(agentUsageDisplay(zero.snapshot())).toBe('0 tokens') +}) + +test('native input usage and cache remain separate from estimated output', () => { + const a = new AgentUsageAccumulator() + a.observe(stream({ type: 'message_start', message: { id: 'native', usage: { input_tokens: 10, cache_read_input_tokens: 70, cache_creation_input_tokens: 20, output_tokens: 0 } } })) + a.observe(delta('hello world!')) + expect(a.snapshot()).toMatchObject({ confirmed: 100, estimated: 3 }) + a.observe(stream({ type: 'message_delta', usage: { input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 7 } })) + expect(a.snapshot()).toMatchObject({ confirmed: 107, estimated: 0, state: 'reported' }) +}) + +test('missing usage stays approximate after stop and abort; synthetic notices preserve consumption', () => { + const a = new AgentUsageAccumulator() + a.observe(start()) + a.observe(delta('thinking and working', 'thinking_delta')) + a.observe(delta('{"path":"src"}', 'input_json_delta')) + a.observe(stream({ type: 'message_stop' })) + a.observe(assistant('notice', 0, 0, { isVirtual: true })) + expect(a.snapshot().state).toBe('estimated') + expect(a.snapshot().estimated).toBeGreaterThan(0) +}) + +test('consumption sums unique responses across retries, including reused provider IDs', () => { + const a = new AgentUsageAccumulator() + for (let i = 0; i < 3; i++) { a.observe({ type: 'stream_request_start' }); a.observe(start()); a.observe(usage(10, 5)) } + expect(a.snapshot().confirmed).toBe(45) +}) + +test('split and replayed assistant records count response usage once', () => { + const first = assistant('one', 123, 45) + const split = { ...first, uuid: 'split' } + expect(agentUsageFromMessages([first, split, first, assistant('two', 10, 2), assistant('notice', 0, 0, { isVirtual: true })]).confirmed).toBe(180) +}) + +test('non-streaming fallback preserves an unfinished streaming estimate separately', () => { + const a = new AgentUsageAccumulator() + a.observe(start('stream')) + a.observe(delta('a'.repeat(40))) + a.observe(assistant('fallback', 10, 3)) + expect(a.snapshot()).toMatchObject({ confirmed: 13, estimated: 10, state: 'estimated' }) +}) + +test('partial and invalid usage cannot turn missing counters into an official zero', () => { + const a = new AgentUsageAccumulator(100) + a.observe(start()); a.observe(delta('a'.repeat(40))) + a.observe(stream({ type: 'message_delta', usage: { input_tokens: 120, output_tokens: 0 }, usageOutputReported: false })) + expect(a.snapshot()).toMatchObject({ confirmed: 120, estimated: 10, state: 'estimated' }) + a.observe(stream({ type: 'message_delta', usage: { output_tokens: -1, input_tokens: NaN } })) + expect(a.snapshot()).toMatchObject({ confirmed: 120, estimated: 10, state: 'estimated' }) + a.observe(stream({ type: 'message_delta', usage: { output_tokens: 24 } })) + expect(a.snapshot()).toMatchObject({ confirmed: 144, estimated: 0, state: 'reported' }) +}) + +describe('seeded concurrent agent event replay', () => { + for (const count of [1, 2, 8, 20]) test(`${count} agents retain attribution through 10,000 interleaved events`, () => { + const agents = Array.from({ length: count }, () => new AgentUsageAccumulator()) + for (const a of agents) a.observe(start()) + let seed = 42 + for (let i = 0; i < 10_000; i++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + agents[seed % count]!.observe(delta('chunk')) + } + for (const [i, a] of agents.entries()) a.observe(usage(100 + i, 20 + i)) + expect(combineAgentUsage(agents.map(a => a.snapshot())).confirmed).toBe(count * 120 + count * (count - 1)) + for (const [i, a] of agents.entries()) expect(a.snapshot().confirmed).toBe(120 + 2 * i) + }) +}) + +test('publisher flushes quiet tails and final usage immediately without delayed repaint', async () => { + const updates: AgentUsageUpdate[] = [] + const publisher = createUsagePublisher(u => updates.push(u)) + const a = new AgentUsageAccumulator() + a.observe(start()); a.observe(delta('test')) + for (let i = 0; i < 50; i++) publisher.update({ executionId: 'run', usage: a.snapshot() }) + expect(updates).toHaveLength(0) + await Bun.sleep(125) + expect(updates).toHaveLength(1) + a.observe(usage()) + publisher.update({ executionId: 'run', usage: a.snapshot() }, true) + expect(updates.at(-1)?.usage.confirmed).toBe(168) + publisher.flush() + expect(updates).toHaveLength(2) +}) diff --git a/src/utils/agentUsage.ts b/src/utils/agentUsage.ts new file mode 100644 index 0000000000..a520936135 --- /dev/null +++ b/src/utils/agentUsage.ts @@ -0,0 +1,186 @@ +/** Serializable agent consumption. Estimates never enter confirmed usage/cost fields. */ +export type AgentTokenUsage = { + confirmed: number + estimated: number + state: 'pending' | 'estimated' | 'reported' + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number +} +export type AgentUsageUpdate = { executionId: string; usage: AgentTokenUsage; final?: boolean } + +type RecordValue = Record +function object(value: unknown): RecordValue { + return typeof value === 'object' && value !== null ? value as RecordValue : {} +} +function validNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} +function hasInputUsage(value: RecordValue): boolean { + return validNumber(value.input_tokens) +} +function number(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} +export function emptyAgentUsage(): AgentTokenUsage { + return { confirmed: 0, estimated: 0, state: 'pending', inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 } +} +export function combineAgentUsage(values: Iterable): AgentTokenUsage { + const total = emptyAgentUsage() + let seen = false + let unreported = false + for (const value of values) { + seen = true + total.inputTokens += value.inputTokens + total.outputTokens += value.outputTokens + total.cacheReadTokens += value.cacheReadTokens + total.cacheCreationTokens += value.cacheCreationTokens + total.estimated += value.estimated + unreported ||= value.state !== 'reported' + } + total.confirmed = total.inputTokens + total.outputTokens + total.cacheReadTokens + total.cacheCreationTokens + total.state = seen && !unreported ? 'reported' : total.estimated || total.confirmed ? 'estimated' : 'pending' + return total +} + +type ResponseUsage = { + usage: RecordValue + inputKnown: boolean + reported: boolean + outputKnown: boolean + characters: number + inputEstimate: number + streamed: boolean + blocks: Set +} + +/** Consume events before runAgent filters streaming deltas. One entry per response attempt. */ +export class AgentUsageAccumulator { + private responses: ResponseUsage[] = [] + private byId = new Map() + private active: ResponseUsage | undefined + constructor(private inputEstimate = 0) {} + + observe(value: unknown): void { + const item = object(value) + if (item.type === 'stream_request_start') { this.active = undefined; return } + if (item.type === 'stream_event') { + const event = object(item.event) + if (event.type === 'message_start') { + const message = object(event.message) + this.active = this.create(true) + if (typeof message.id === 'string') this.byId.set(message.id, this.active) + if (message.usageReported !== false && message.usage != null) { + this.active.usage = object(message.usage) + this.active.inputKnown = message.usageInputReported !== false && hasInputUsage(object(message.usage)) + } + } else if (this.active && event.type === 'content_block_delta') { + const delta = object(event.delta) + for (const key of ['text', 'thinking', 'partial_json']) { + if (typeof delta[key] === 'string') this.active.characters += delta[key].length + } + } else if (this.active && event.type === 'message_delta' && event.usage != null) { + // Usage is cumulative within a response, never an additive delta. + const usage = object(event.usage) + for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) { + this.active.usage[key] = Math.max(number(this.active.usage[key]), number(usage[key])) + } + this.active.inputKnown ||= event.usageInputReported !== false && hasInputUsage(usage) + this.active.outputKnown ||= event.usageOutputReported !== false && validNumber(usage.output_tokens) + this.active.reported = this.active.inputKnown && this.active.outputKnown + } + return + } + if (item.type !== 'assistant' || item.isVirtual || item.isApiErrorMessage) return + const message = object(item.message) + if (message.model === '') return + const id = typeof message.id === 'string' ? message.id : String(item.uuid ?? this.responses.length) + let response = this.byId.get(id) + if (!response) { + response = this.create(false) + this.byId.set(id, response) + } + // Finalized/replayed records carry usage; live content_block_stop records do not yet. + if (message.usageReported !== false && message.usage != null) { + const usage = object(message.usage) + if (!response.streamed) { + for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) { + response.usage[key] = Math.max(number(response.usage[key]), number(usage[key])) + } + response.inputKnown ||= message.usageInputReported !== false && hasInputUsage(usage) + response.outputKnown ||= message.usageOutputReported !== false && validNumber(usage.output_tokens) + response.reported = response.inputKnown && response.outputKnown + } + } + if (!response.streamed && Array.isArray(message.content)) { + for (const [index, raw] of message.content.entries()) { + const block = object(raw) + const key = String(block.id ?? `${item.uuid ?? id}:${index}`) + if (response.blocks.has(key)) continue + response.blocks.add(key) + const text = typeof block.text === 'string' ? block.text : typeof block.thinking === 'string' ? block.thinking : JSON.stringify(block.input ?? '') + response.characters += text.length + } + } + } + + private create(streamed: boolean): ResponseUsage { + const response: ResponseUsage = { usage: {}, inputKnown: false, reported: false, outputKnown: false, characters: 0, inputEstimate: this.inputEstimate, streamed, blocks: new Set() } + this.responses.push(response) + return response + } + + snapshot(): AgentTokenUsage { + return combineAgentUsage(this.responses.map(response => { + const result = emptyAgentUsage() + result.inputTokens = number(response.usage.input_tokens) + result.outputTokens = number(response.usage.output_tokens) + result.cacheReadTokens = number(response.usage.cache_read_input_tokens) + result.cacheCreationTokens = number(response.usage.cache_creation_input_tokens) + result.confirmed = result.inputTokens + result.outputTokens + result.cacheReadTokens + result.cacheCreationTokens + if (!response.reported && response.characters) { + result.estimated = Math.max(0, Math.ceil(response.characters / 4) - result.outputTokens) + (response.inputKnown ? 0 : Math.max(0, response.inputEstimate - result.inputTokens - result.cacheReadTokens - result.cacheCreationTokens)) + } + result.state = response.reported ? 'reported' : result.estimated || result.confirmed ? 'estimated' : 'pending' + return result + })) + } +} + +/** Legacy transcripts can split one response across several assistant records. */ +export function agentUsageFromMessages(messages: readonly unknown[]): AgentTokenUsage { + const accumulator = new AgentUsageAccumulator() + for (const message of messages) accumulator.observe(message) + return accumulator.snapshot() +} + +export function agentUsageDisplay(usage: AgentTokenUsage | undefined, legacyTokens?: number | null, format: (n: number) => string = String): string { + if (!usage) return legacyTokens == null ? 'Awaiting response' : `${format(legacyTokens)} tokens` + if (usage.state === 'pending') return 'Awaiting response' + return `${usage.state === 'estimated' ? '~' : ''}${format(usage.confirmed + usage.estimated)} tokens` +} + +/** Coalesce paints, including the last delta of a stream that then stalls. */ +export function createUsagePublisher(callback: ((update: AgentUsageUpdate) => void) | undefined) { + let timer: ReturnType | undefined + let pending: AgentUsageUpdate | undefined + let previous = '' + const flush = () => { + if (timer) clearTimeout(timer) + timer = undefined + if (!pending) return + const update = pending + pending = undefined + const signature = JSON.stringify(update) + if (signature !== previous) { previous = signature; callback?.(update) } + } + return { + update(value: AgentUsageUpdate, immediate = false) { + pending = value + if (immediate) flush() + else if (!timer) { timer = setTimeout(flush, 100); timer.unref?.() } + }, + flush, + } +} diff --git a/src/utils/agentUsageSchema.ts b/src/utils/agentUsageSchema.ts new file mode 100644 index 0000000000..59e872d035 --- /dev/null +++ b/src/utils/agentUsageSchema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod/v4' +import type { AgentTokenUsage } from './agentUsage.js' + +export const agentTokenUsageSchema: z.ZodType = z.object({ + confirmed: z.number().nonnegative(), + estimated: z.number().nonnegative(), + state: z.enum(['pending', 'estimated', 'reported']), + inputTokens: z.number().nonnegative(), + outputTokens: z.number().nonnegative(), + cacheReadTokens: z.number().nonnegative(), + cacheCreationTokens: z.number().nonnegative(), +}) + +export function parseAgentUsageMetadata(value: string | undefined): AgentTokenUsage | undefined { + if (!value) return undefined + try { const parsed = agentTokenUsageSchema.safeParse(JSON.parse(value)); return parsed.success ? parsed.data : undefined } catch { return undefined } +} diff --git a/src/utils/conversationRecovery.test.ts b/src/utils/conversationRecovery.test.ts index f639ab92ae..e4e69815d9 100644 --- a/src/utils/conversationRecovery.test.ts +++ b/src/utils/conversationRecovery.test.ts @@ -3,6 +3,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +const actualProviders = { ...await import('./model/providers.js') } + const tempDirs: string[] = [] const originalSimple = process.env.CLAUDE_CODE_SIMPLE const providerEnvKeys = [ @@ -76,6 +78,7 @@ afterEach(async () => { async function importFreshConversationRecovery() { mock.restore() mock.module('./model/providers.js', () => ({ + ...actualProviders, getAPIProvider: () => { if (process.env.CLAUDE_CODE_USE_GITHUB) return 'github' if (process.env.CLAUDE_CODE_USE_OPENAI) return 'openai' diff --git a/src/utils/fastMode.test.ts b/src/utils/fastMode.test.ts index 80b5109f36..b0ccbf3f66 100644 --- a/src/utils/fastMode.test.ts +++ b/src/utils/fastMode.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' +const actualAuth = { ...await import('./auth.js') } + const originalEnv = { ...process.env } async function importFreshFastModeModule() { @@ -105,6 +107,7 @@ function installCommonMocks(options?: { })) mock.module('./auth.js', () => ({ + ...actualAuth, isAnthropicAuthEnabled: () => true, getAuthTokenSource: () => 'none', getAnthropicApiKey: () => options?.apiKey ?? null, diff --git a/src/utils/finishInterruptedMessages.ts b/src/utils/finishInterruptedMessages.ts index b0456f53f6..81b9182464 100644 --- a/src/utils/finishInterruptedMessages.ts +++ b/src/utils/finishInterruptedMessages.ts @@ -2,6 +2,8 @@ import type { Message } from '../types/message.js' import type { UUID } from 'crypto' import { createAssistantMessage, createUserInterruptionMessage, createUserMessage } from './messages.js' +export const TOOL_EXECUTION_INTERRUPTED = 'Tool execution interrupted by the user.' + /** Close tool calls before retiring a stream, so the next turn has valid history. */ export function finishInterruptedMessages(messages: Message[], streamingText?: string | null): Message[] { const unresolved = new Map() @@ -18,7 +20,7 @@ export function finishInterruptedMessages(messages: Message[], streamingText?: s for (const [id, assistantUUID] of unresolved) { result.push(createUserMessage({ content: [{ type: 'tool_result', tool_use_id: id, is_error: true, - content: 'Tool execution interrupted by the user.' }], + content: TOOL_EXECUTION_INTERRUPTED }], sourceToolAssistantUUID: assistantUUID, })) } diff --git a/src/utils/geminiAuth.test.ts b/src/utils/geminiAuth.test.ts index 8bb9a5c29f..8acec1b8de 100644 --- a/src/utils/geminiAuth.test.ts +++ b/src/utils/geminiAuth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, mock, test } from 'bun:test' import { getGeminiProjectIdHint, @@ -7,6 +7,9 @@ import { } from './geminiAuth.ts' const existingFilePath = import.meta.path +const actualFs = { ...await import('node:fs') } +// Never discover credentials from the developer machine. +mock.module('node:fs', () => ({ ...actualFs, existsSync: (path: string) => path === existingFilePath })) const originalEnv = { GEMINI_API_KEY: process.env.GEMINI_API_KEY, diff --git a/src/utils/model/model.openai-shim-providers.test.ts b/src/utils/model/model.openai-shim-providers.test.ts index 093dedd949..87b2528c8e 100644 --- a/src/utils/model/model.openai-shim-providers.test.ts +++ b/src/utils/model/model.openai-shim-providers.test.ts @@ -2,9 +2,18 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { saveGlobalConfig } from '../config.js' +// Exercise the retained compatibility implementation explicitly. +const actualOauth = { ...await import('../../constants/oauth.js') } +mock.module('../../constants/oauth.js', () => ({ ...actualOauth, isVerbooMode: () => false })) + +const actualAuth = { ...await import('../auth.js') } + +const actualProviders = { ...await import('./providers.js') } + async function importFreshModelModule() { mock.restore() mock.module('../auth.js', () => ({ + ...actualAuth, getSubscriptionType: () => 'max', isClaudeAISubscriber: () => true, isMaxSubscriber: () => true, @@ -12,6 +21,7 @@ async function importFreshModelModule() { isTeamPremiumSubscriber: () => false, })) mock.module('./providers.js', () => ({ + ...actualProviders, getAPIProvider: () => { if (process.env.NVIDIA_NIM) return 'nvidia-nim' if (process.env.MINIMAX_API_KEY) return 'minimax' diff --git a/src/utils/model/modelOptions.github.test.ts b/src/utils/model/modelOptions.github.test.ts index 2a5dd9bbf3..2e53f397f6 100644 --- a/src/utils/model/modelOptions.github.test.ts +++ b/src/utils/model/modelOptions.github.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { mock } from 'bun:test' import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js' @@ -8,9 +8,16 @@ import { setSessionSettingsCache, } from '../settings/settingsCache.js' +// Exercise the retained compatibility implementation explicitly. +const actualOauth = { ...await import('../../constants/oauth.js') } +mock.module('../../constants/oauth.js', () => ({ ...actualOauth, isVerbooMode: () => false })) + +const actualProviders = { ...await import('./providers.js') } + async function importFreshModelOptionsModule() { mock.restore() mock.module('./providers.js', () => ({ + ...actualProviders, getAPIProvider: () => 'github', getAPIProviderForStatsig: () => 'github', isFirstPartyAnthropicBaseUrl: () => false, diff --git a/src/utils/model/providers.test.ts b/src/utils/model/providers.test.ts index 6600695fe6..089cc00592 100644 --- a/src/utils/model/providers.test.ts +++ b/src/utils/model/providers.test.ts @@ -1,4 +1,9 @@ -import { afterEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' + +const actualOauth = { ...await import('../../constants/oauth.js') } +let verbooMode = false +mock.module('../../constants/oauth.js', () => ({ ...actualOauth, isVerbooMode: () => verbooMode })) +beforeEach(() => { verbooMode = false }) const originalEnv = { CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, @@ -282,3 +287,13 @@ test('isGithubNativeAnthropicMode: false for github:copilot:gpt- model', async ( const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() expect(isGithubNativeAnthropicMode()).toBe(false) }) + +// Product mode must never be redirected by stale compatibility credentials. +test('Verboo mode ignores external provider flags and credentials', async () => { + verbooMode = true + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://untrusted.example/v1' + const { getAPIProvider, usesAnthropicAccountFlow } = await importFreshProvidersModule() + expect(getAPIProvider()).toBe('firstParty') + expect(usesAnthropicAccountFlow()).toBe(true) // legacy name for the native account flow +}) diff --git a/src/utils/openclaudePaths.test.ts b/src/utils/openclaudePaths.test.ts index 6a9edc2dd7..3b9f2cfeb5 100644 --- a/src/utils/openclaudePaths.test.ts +++ b/src/utils/openclaudePaths.test.ts @@ -93,6 +93,7 @@ describe('Verboo paths', () => { }) test('local installation detection matches .verboo path only', async () => { + process.env.VERBOO_CONFIG_DIR = join(homedir(), '.verboo') await acquireEnvMutex() const { isManagedLocalInstallationPath } = await importFreshLocalInstaller() @@ -117,6 +118,7 @@ describe('Verboo paths', () => { }) test('local installs are detected when they expose the verboo binary', async () => { + process.env.VERBOO_CONFIG_DIR = join(homedir(), '.verboo') await acquireEnvMutex() mock.module('fs/promises', () => ({ ...fsPromises, diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index 9688e36f1c..c28949a9fa 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -107,354 +107,25 @@ describe('parseProviderFlag', () => { // --- applyProviderFlag --- -describe('applyProviderFlag - anthropic', () => { - test('sets no env vars for anthropic (default)', () => { - const result = applyProviderFlag('anthropic', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined() +describe('native Verboo provider enforcement', () => { + test.each([...VALID_PROVIDERS, 'unknown'])('rejects %s without mutating routing or credentials', provider => { + process.env.OPENAI_API_KEY = 'fixture-existing-key' + process.env.OPENAI_BASE_URL = 'https://fixture.example/v1' + const before = { ...process.env } + expect(applyProviderFlag(provider, ['--model', 'external-model']).error).toContain('provider nativo Verboo') + expect(process.env).toEqual(before) }) -}) - -describe('VALID_PROVIDERS', () => { - test('includes descriptor-backed preset and route ids', () => { - expect(VALID_PROVIDERS).toContain('deepseek') - expect(VALID_PROVIDERS).toContain('moonshotai') - expect(VALID_PROVIDERS).toContain('openrouter') - expect(VALID_PROVIDERS).toContain('atomic-chat') - expect(VALID_PROVIDERS).toContain('zai') - expect(VALID_PROVIDERS).toContain('venice') - expect(VALID_PROVIDERS).toContain('xiaomi-mimo') - }) -}) - -describe('applyProviderFlag - openai', () => { - test('sets CLAUDE_CODE_USE_OPENAI=1', () => { - const result = applyProviderFlag('openai', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - }) - - test('sets OPENAI_MODEL when --model is provided', () => { - applyProviderFlag('openai', ['--model', 'gpt-4o']) - expect(process.env.OPENAI_MODEL).toBe('gpt-4o') - }) -}) - -describe('applyProviderFlag - gemini', () => { - test('sets CLAUDE_CODE_USE_GEMINI=1', () => { - const result = applyProviderFlag('gemini', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_GEMINI).toBe('1') - }) - - test('sets GEMINI_MODEL when --model is provided', () => { - applyProviderFlag('gemini', ['--model', 'gemini-2.0-flash']) - expect(process.env.GEMINI_MODEL).toBe('gemini-2.0-flash') - }) -}) - -describe('applyProviderFlag - github', () => { - test('sets CLAUDE_CODE_USE_GITHUB=1', () => { - const result = applyProviderFlag('github', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_GITHUB).toBe('1') - }) -}) - -describe('applyProviderFlag - bedrock', () => { - test('sets CLAUDE_CODE_USE_BEDROCK=1', () => { - const result = applyProviderFlag('bedrock', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_BEDROCK).toBe('1') - }) -}) - -describe('applyProviderFlag - vertex', () => { - test('sets CLAUDE_CODE_USE_VERTEX=1', () => { - const result = applyProviderFlag('vertex', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_VERTEX).toBe('1') - }) -}) - -describe('applyProviderFlag - ollama', () => { - test('sets CLAUDE_CODE_USE_OPENAI=1 with Ollama defaults when unset', () => { - delete process.env.OPENAI_BASE_URL - delete process.env.OPENAI_API_KEY - - const result = applyProviderFlag('ollama', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL!).toBe('http://localhost:11434/v1') - expect(process.env.OPENAI_API_KEY!).toBe('ollama') - }) - - test('sets OPENAI_MODEL when --model is provided', () => { - applyProviderFlag('ollama', ['--model', 'llama3.2']) - expect(process.env.OPENAI_MODEL).toBe('llama3.2') - }) - - test('does not override existing OPENAI_BASE_URL when user set a custom one', () => { - process.env.OPENAI_BASE_URL = 'http://my-ollama:11434/v1' - applyProviderFlag('ollama', []) - expect(process.env.OPENAI_BASE_URL).toBe('http://my-ollama:11434/v1') - }) - - test('preserves explicit OPENAI_BASE_URL and OPENAI_API_KEY overrides', () => { - process.env.OPENAI_BASE_URL = 'http://remote-ollama.internal:11434/v1' - process.env.OPENAI_API_KEY = 'secret-token' - - applyProviderFlag('ollama', []) - - expect(process.env.OPENAI_BASE_URL).toBe('http://remote-ollama.internal:11434/v1') - expect(process.env.OPENAI_API_KEY).toBe('secret-token') - }) -}) - -describe('applyProviderFlag - descriptor-backed openai-compatible routes', () => { - test('deepseek applies generic openai-compatible routing with descriptor defaults', () => { - const result = applyProviderFlag('deepseek', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL).toBe('https://api.deepseek.com/v1') - expect(process.env.OPENAI_MODEL).toBe('deepseek-v4-pro') - }) - - test('openrouter applies gateway defaults from descriptors', () => { - const result = applyProviderFlag('openrouter', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') - }) - - test('clears stale NVIDIA_NIM marker when switching to another OpenAI-compatible route', () => { - process.env.NVIDIA_NIM = '1' - - const result = applyProviderFlag('openrouter', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.NVIDIA_NIM).toBeUndefined() - expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') - }) - - test('clears NVIDIA_API_KEY copied into OPENAI_API_KEY when switching routes', () => { - process.env.NVIDIA_API_KEY = 'nvidia-live-key' - - const nvidiaResult = applyProviderFlag('nvidia-nim', []) - expect(nvidiaResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBe('nvidia-live-key') - - process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' - const openrouterResult = applyProviderFlag('openrouter', []) - - expect(openrouterResult.error).toBeUndefined() - expect(process.env.NVIDIA_NIM).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBeUndefined() - expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') - }) - - test('clears BNKR_API_KEY copied into OPENAI_API_KEY when switching routes', () => { - process.env.BNKR_API_KEY = 'bankr-live-key' - - const bankrResult = applyProviderFlag('bankr', []) - expect(bankrResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBe('bankr-live-key') - - process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' - const openrouterResult = applyProviderFlag('openrouter', []) - - expect(openrouterResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBeUndefined() - expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') - }) - - test('clears MIMO_API_KEY copied into OPENAI_API_KEY when switching routes', () => { - process.env.MIMO_API_KEY = 'mimo-live-key' - - const mimoResult = applyProviderFlag('xiaomi-mimo', []) - expect(mimoResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBe('mimo-live-key') - - process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' - const openrouterResult = applyProviderFlag('openrouter', []) - expect(openrouterResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBeUndefined() - expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') - }) - - test('clears XAI_API_KEY copied into OPENAI_API_KEY when switching routes', () => { - process.env.XAI_API_KEY = 'xai-live-key' - - const xaiResult = applyProviderFlag('xai', []) - expect(xaiResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBe('xai-live-key') - - process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' - const openrouterResult = applyProviderFlag('openrouter', []) - - expect(openrouterResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBeUndefined() - expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') - }) - - test('clears MINIMAX_API_KEY copied into OPENAI_API_KEY when switching routes', () => { - process.env.MINIMAX_API_KEY = 'minimax-live-key' - process.env.OPENAI_API_KEY = 'minimax-live-key' - process.env.XAI_API_KEY = 'xai-live-key' - - const xaiResult = applyProviderFlag('xai', []) - - expect(xaiResult.error).toBeUndefined() - expect(process.env.OPENAI_API_KEY).toBe('xai-live-key') - expect(process.env.OPENAI_BASE_URL).toBe('https://api.x.ai/v1') - }) -}) - -describe('applyProviderFlag - minimax', () => { - test('preserves MiniMax default base URL and model semantics', () => { - const result = applyProviderFlag('minimax', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL).toBe('https://api.minimax.io/v1') - expect(process.env.OPENAI_MODEL).toBe('MiniMax-M2.7') - }) -}) - -describe('applyProviderFlag - nvidia-nim', () => { - test('maps NVIDIA_API_KEY into the OPENAI-compatible auth env when present', () => { - process.env.NVIDIA_API_KEY = 'nvidia-live-key' - - const result = applyProviderFlag('nvidia-nim', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.NVIDIA_NIM).toBe('1') - expect(process.env.OPENAI_API_KEY).toBe('nvidia-live-key') - expect(process.env.OPENAI_BASE_URL).toBe('https://integrate.api.nvidia.com/v1') - }) -}) - -describe('applyProviderFlag - zai', () => { - test('preserves Z.AI default base URL and model semantics', () => { - const result = applyProviderFlag('zai', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL).toBe('https://api.z.ai/api/coding/paas/v4') - expect(process.env.OPENAI_MODEL).toBe('GLM-5.1') - }) -}) - -describe('applyProviderFlag - xiaomi-mimo', () => { - test('sets Xiaomi MiMo OpenAI-compatible defaults and mirrors MIMO_API_KEY', () => { - process.env.MIMO_API_KEY = 'mimo-secret-key' - - const result = applyProviderFlag('xiaomi-mimo', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL).toBe('https://api.xiaomimimo.com/v1') - expect(process.env.OPENAI_MODEL).toBe('mimo-v2.5-pro') - expect(process.env.OPENAI_API_KEY).toBe('mimo-secret-key') - }) - - test('sets Xiaomi MiMo OPENAI_MODEL when --model is provided', () => { - applyProviderFlag('xiaomi-mimo', ['--model', 'mimo-v2-flash']) - - expect(process.env.OPENAI_MODEL).toBe('mimo-v2-flash') - }) -}) - -describe('applyProviderFlag - venice', () => { - test('sets Venice OpenAI-compatible defaults and mirrors VENICE_API_KEY', () => { - process.env.VENICE_API_KEY = 'venice-secret-key' - - const result = applyProviderFlag('venice', []) - - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL).toBe('https://api.venice.ai/api/v1') - expect(process.env.OPENAI_MODEL).toBe('venice-uncensored') - expect(process.env.OPENAI_API_KEY).toBe('venice-secret-key') - }) -}) - -describe('applyProviderFlag - xai', () => { - test('sets CLAUDE_CODE_USE_OPENAI=1 with xAI defaults when unset', () => { - delete process.env.OPENAI_BASE_URL - delete process.env.OPENAI_API_KEY - - const result = applyProviderFlag('xai', []) - expect(result.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL as string | undefined).toBe('https://api.x.ai/v1') - expect(process.env.OPENAI_MODEL).toBe('grok-4.3') - }) - - test('sets OPENAI_MODEL when --model is provided', () => { - applyProviderFlag('xai', ['--model', 'grok-3']) - expect(process.env.OPENAI_MODEL).toBe('grok-3') - }) - - test('propagates XAI_API_KEY to OPENAI_API_KEY when only XAI_API_KEY is set', () => { - delete process.env.OPENAI_API_KEY - process.env.XAI_API_KEY = 'xai-secret-key' - - applyProviderFlag('xai', []) - - expect(process.env.OPENAI_API_KEY as string | undefined).toBe('xai-secret-key') - }) - - test('does not override existing OPENAI_API_KEY when both keys are set', () => { - process.env.OPENAI_API_KEY = 'existing-openai-key' - process.env.XAI_API_KEY = 'xai-secret-key' - - applyProviderFlag('xai', []) - - expect(process.env.OPENAI_API_KEY).toBe('existing-openai-key') - }) -}) - -describe('applyProviderFlag - invalid provider', () => { - test('returns error for unknown provider', () => { - const result = applyProviderFlag('unknown-provider', []) - expect(result.error).toContain('unknown-provider') - expect(result.error).toContain(VALID_PROVIDERS.join(', ')) - }) -}) - -describe('applyProviderFlagFromArgs', () => { - test('applies ollama provider and model from argv in one step', () => { - delete process.env.OPENAI_BASE_URL - delete process.env.OPENAI_API_KEY - - const result = applyProviderFlagFromArgs([ - '--provider', - 'ollama', - '--model', - 'qwen2.5:3b', - ]) - - expect(result?.error).toBeUndefined() - expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') - expect(process.env.OPENAI_BASE_URL!).toBe('http://localhost:11434/v1') - expect(process.env.OPENAI_API_KEY!).toBe('ollama') - expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b') + test('argv rejects an external provider and leaves the selected model unchanged', () => { + expect(applyProviderFlagFromArgs(['--provider', 'ollama', '--model', 'external-model'])?.error).toContain('provider nativo Verboo') + expect(process.env.OPENAI_MODEL).toBeUndefined() }) - test('returns undefined when --provider is absent', () => { - expect(applyProviderFlagFromArgs(['--model', 'gpt-4o'])).toBeUndefined() + test('does nothing when --provider is absent', () => { + expect(applyProviderFlagFromArgs(['--model', 'native-model'])).toBeUndefined() }) }) -// --- parseModelFlag --- - describe('parseModelFlag', () => { test('returns model value when --model is present', () => { expect(parseModelFlag(['--model', 'gpt-4o-mini'])).toBe('gpt-4o-mini') diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 4abed12c4f..0016c47d50 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -7,6 +7,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js' import type { ProviderProfile } from './config.js' +// Exercise the retained compatibility implementation explicitly. +const actualOauth = { ...await import('../constants/oauth.js') } +mock.module('../constants/oauth.js', () => ({ ...actualOauth, isVerbooMode: () => false })) + async function importFreshProvidersModule() { return import(`./model/providers.ts?ts=${Date.now()}-${Math.random()}`) } @@ -1256,7 +1260,7 @@ describe('setActiveProviderProfile', () => { configDir, }) const persisted = JSON.parse( - readFileSync(join(tempDir, '.verboo-profile.json'), 'utf8'), + readFileSync(join(configDir, '.verboo-profile.json'), 'utf8'), ) expect(result?.id).toBe('ollama_prof') @@ -1301,7 +1305,7 @@ describe('setActiveProviderProfile', () => { configDir, }) const persisted = JSON.parse( - readFileSync(join(tempDir, '.verboo-profile.json'), 'utf8'), + readFileSync(join(configDir, '.verboo-profile.json'), 'utf8'), ) expect(result?.id).toBe('deepseek_prof') @@ -1387,11 +1391,11 @@ describe('setActiveProviderProfile', () => { configDir, }) const persisted = JSON.parse( - readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + readFileSync(join(configDir, '.verboo-profile.json'), 'utf8'), ) expect(result?.id).toBe('venice_prof') - expect(existsSync(join(tempDir, '.openclaude-profile.json'))).toBe(false) + expect(existsSync(join(tempDir, '.verboo-profile.json'))).toBe(false) expect(persisted.profile).toBe('openai') expect(persisted.env).toEqual({ OPENAI_BASE_URL: 'https://api.venice.ai/api/v1', @@ -1430,11 +1434,11 @@ describe('setActiveProviderProfile', () => { configDir, }) const persisted = JSON.parse( - readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + readFileSync(join(configDir, '.verboo-profile.json'), 'utf8'), ) expect(result?.id).toBe('mimo_prof') - expect(existsSync(join(tempDir, '.openclaude-profile.json'))).toBe(false) + expect(existsSync(join(tempDir, '.verboo-profile.json'))).toBe(false) expect(persisted.profile).toBe('openai') expect(persisted.env).toEqual({ OPENAI_BASE_URL: 'https://api.xiaomimimo.com/v1', diff --git a/src/utils/sdkEventQueue.agentUsage.test.ts b/src/utils/sdkEventQueue.agentUsage.test.ts new file mode 100644 index 0000000000..b78da6e491 --- /dev/null +++ b/src/utils/sdkEventQueue.agentUsage.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from 'bun:test' +import { getIsInteractive, setIsInteractive } from '../bootstrap/state.js' +import { emptyAgentUsage } from './agentUsage.js' +import { parseAgentUsageMetadata } from './agentUsageSchema.js' +import { drainSdkEvents, enqueueSdkEvent, subscribeSdkEvents } from './sdkEventQueue.js' +import { SDKTaskProgressMessageSchema } from '../entrypoints/sdk/coreSchemas.js' + +test('20 agents and 10,000 queued progress updates preserve start, final usage and completion events', () => { + const interactive = getIsInteractive() + setIsInteractive(false) + drainSdkEvents() + try { + for (let id = 0; id < 20; id++) enqueueSdkEvent({ type: 'system', subtype: 'task_started', task_id: String(id), description: 'fixture' }) + for (let event = 0; event < 10_000; event++) enqueueSdkEvent({ type: 'system', subtype: 'task_progress', task_id: String(event % 20), description: 'fixture', usage: { total_tokens: event, tool_uses: 1, duration_ms: event, token_usage: { ...emptyAgentUsage(), state: 'estimated', estimated: event } } }) + for (let id = 0; id < 20; id++) enqueueSdkEvent({ type: 'system', subtype: 'task_notification', task_id: String(id), status: 'completed', output_file: '', summary: 'done', usage: { total_tokens: 9990, tool_uses: 1, duration_ms: 10_000 } }) + const events = drainSdkEvents() + expect(events).toHaveLength(60) + expect(events.filter(event => event.subtype === 'task_started')).toHaveLength(20) + for (const event of events.filter(event => event.subtype === 'task_progress')) { + expect(event.usage.total_tokens).toBeGreaterThanOrEqual(9980) + expect(SDKTaskProgressMessageSchema().parse(event).usage.token_usage?.state).toBe('estimated') + } + expect(events.filter(event => event.subtype === 'task_notification')).toHaveLength(20) + expect(drainSdkEvents()).toEqual([]) + } finally { drainSdkEvents(); setIsInteractive(interactive) } +}) + +test('usage metadata remains optional and malformed notification metadata is ignored', () => { + expect(parseAgentUsageMetadata(undefined)).toBeUndefined() + expect(parseAgentUsageMetadata('{bad')).toBeUndefined() + expect(parseAgentUsageMetadata('{"confirmed":-3}')).toBeUndefined() + expect(parseAgentUsageMetadata(JSON.stringify(emptyAgentUsage()))).toEqual(emptyAgentUsage()) +}) + +test('headless subscribers receive progress before the parent yields, and detach cleanly', () => { + const interactive = getIsInteractive() + setIsInteractive(false) + drainSdkEvents() + const received: string[] = [] + const unsubscribe = subscribeSdkEvents(() => { + received.push(...drainSdkEvents().map(event => event.subtype)) + }) + try { + enqueueSdkEvent({ type: 'system', subtype: 'task_started', task_id: 'live', description: 'fixture' }) + enqueueSdkEvent({ type: 'system', subtype: 'task_progress', task_id: 'live', description: 'fixture', usage: { total_tokens: 0, tool_uses: 0, duration_ms: 100, token_usage: { ...emptyAgentUsage(), state: 'estimated', estimated: 42 } } }) + expect(received).toEqual(['task_started', 'task_progress']) + unsubscribe() + enqueueSdkEvent({ type: 'system', subtype: 'task_notification', task_id: 'live', status: 'completed', output_file: '', summary: 'done' }) + expect(received).toHaveLength(2) + expect(drainSdkEvents()).toHaveLength(1) + } finally { unsubscribe(); drainSdkEvents(); setIsInteractive(interactive) } +}) diff --git a/src/utils/sdkEventQueue.ts b/src/utils/sdkEventQueue.ts index 2cf5ac0859..ec2bdb7c36 100644 --- a/src/utils/sdkEventQueue.ts +++ b/src/utils/sdkEventQueue.ts @@ -2,6 +2,7 @@ import type { UUID } from 'crypto' import { randomUUID } from 'crypto' import { getIsNonInteractiveSession, getSessionId } from '../bootstrap/state.js' import type { SdkWorkflowProgress } from '../types/tools.js' +import type { AgentTokenUsage } from './agentUsage.js' type TaskStartedEvent = { type: 'system' @@ -24,6 +25,7 @@ type TaskProgressEvent = { total_tokens: number tool_uses: number duration_ms: number + token_usage?: AgentTokenUsage } last_tool_name?: string summary?: string @@ -50,6 +52,7 @@ type TaskNotificationSdkEvent = { total_tokens: number tool_uses: number duration_ms: number + token_usage?: AgentTokenUsage } } @@ -73,6 +76,13 @@ export type SdkEvent = const MAX_QUEUE_SIZE = 1000 const queue: SdkEvent[] = [] +const listeners = new Set<() => void>() + +/** Wake the headless output while the parent is awaiting a tool's next message. */ +export function subscribeSdkEvents(listener: () => void): () => void { + listeners.add(listener) + return () => { listeners.delete(listener) } +} export function enqueueSdkEvent(event: SdkEvent): void { // SDK events are only consumed (drained) in headless/streaming mode. @@ -80,10 +90,21 @@ export function enqueueSdkEvent(event: SdkEvent): void { if (!getIsNonInteractiveSession()) { return } + // Agent usage snapshots replace one another while the parent awaits a tool. + // Never coalesce workflow delta batches or terminal notifications. + if (event.subtype === 'task_progress' && !event.workflow_progress) { + const index = queue.findIndex(previous => previous.subtype === 'task_progress' && previous.task_id === event.task_id && !previous.workflow_progress) + if (index !== -1) { + queue[index] = event + for (const listener of listeners) listener() + return + } + } if (queue.length >= MAX_QUEUE_SIZE) { queue.shift() } queue.push(event) + for (const listener of listeners) listener() } export function drainSdkEvents(): Array< diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts index f8a0a40f16..4b4d21cb1b 100644 --- a/src/utils/secureStorage/platformStorage.test.ts +++ b/src/utils/secureStorage/platformStorage.test.ts @@ -11,9 +11,13 @@ import { // Mock execaSync. Keep the call tuple explicit so command assertions stay // type-safe without weakening production code. -type MockExecaCall = [string, string[], { input?: string; reject?: boolean }] +type MockExecaCall = [string, string[], { input?: string; reject?: boolean; timeout?: number }] const mockExecaSync = mock((..._args: unknown[]): { exitCode: number; stdout: string; stderr?: string } => ({ exitCode: 0, stdout: "" })); const execaCalls = (): MockExecaCall[] => mockExecaSync.mock.calls as unknown as MockExecaCall[] +const powershellScript = (index = 0): string => { + const args = execaCalls()[index][1] + return args[args.indexOf('-Command') + 1] +} mock.module("execa", () => ({ execaSync: mockExecaSync, })); @@ -94,7 +98,7 @@ describe("Secure Storage Platform Implementations", () => { windowsCredentialStorage.update(testData); - const script = execaCalls()[0][1][1]; + const script = powershellScript(); const options = execaCalls()[0][2]; expect(script).toContain(expectedName); expect(script).toContain("ProtectedData"); @@ -112,7 +116,7 @@ describe("Secure Storage Platform Implementations", () => { describe("Windows DPAPI write encoding (issue #77)", () => { function updateScript(): string { windowsCredentialStorage.update(testData); - return execaCalls()[0][1][1]; + return powershellScript(); } function writePath(script: string): string { @@ -142,6 +146,22 @@ describe("Secure Storage Platform Implementations", () => { }); describe("Windows PowerShell Escaping", () => { + test("credential reads do not load profiles or wait for terminal input", () => { + windowsCredentialStorage.read(); + const [command, args, options] = execaCalls()[0]; + expect(command).toBe('powershell.exe'); + expect(args.slice(0, 4)).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']); + expect(options.input).toBe(''); + expect(options.timeout).toBe(10_000); + }); + + test("a timed-out credential reader remains an error for classified reads", () => { + mockExecaSync.mockImplementation(() => { throw Object.assign(new Error('Timed out'), { timedOut: true }); }); + expect(windowsCredentialStorage.readResult?.()).toMatchObject({ kind: 'error' }); + expect(windowsCredentialStorage.read()).toBeNull(); + expect(windowsCredentialStorage.update(testData).success).toBe(false); + }); + test("escapes single quotes and prevents $ expansion", () => { const dataWithDollar = { mcpOAuth: { @@ -156,7 +176,7 @@ describe("Secure Storage Platform Implementations", () => { windowsCredentialStorage.update(dataWithDollar); - const script = execaCalls()[0][1][1]; + const script = powershellScript(); const options = execaCalls()[0][2]; expect(script).toContain("[Console]::In.ReadToEnd()"); expect(options.input).toContain("token-with-$env:USERNAME"); @@ -170,23 +190,24 @@ describe("Secure Storage Platform Implementations", () => { test("delete() skips legacy PasswordVault by default", () => { windowsCredentialStorage.delete(); expect(mockExecaSync).toHaveBeenCalledTimes(1); - const script = execaCalls()[0][1][1]; + const script = powershellScript(); expect(script).not.toContain("System.Runtime.WindowsRuntime"); }); test("delete() includes legacy assembly load when explicitly enabled", () => { process.env.VERBOO_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1"; windowsCredentialStorage.delete(); - const script = execaCalls()[1][1][1]; + const script = powershellScript(1); expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime"); }); test("escapes double quotes in username", () => { process.env.VERBOO_ENABLE_LEGACY_WINDOWS_PASSWORDVAULT = "1"; - process.env.USER = 'user"name'; + process.env.USER = 'user"name 日本語'; windowsCredentialStorage.read(); - const script = execaCalls()[1][1][1]; + const script = powershellScript(1); expect(script).toContain('user`"name'); + expect(script).toContain('日本語'); expect(script).not.toContain('user"name'); }); diff --git a/src/utils/secureStorage/windowsCredentialStorage.ts b/src/utils/secureStorage/windowsCredentialStorage.ts index a4d768c501..28c4039a0a 100644 --- a/src/utils/secureStorage/windowsCredentialStorage.ts +++ b/src/utils/secureStorage/windowsCredentialStorage.ts @@ -39,9 +39,12 @@ function runPowerShell( options?: { input?: string }, ): ReturnType | null { try { - return execaSync('powershell.exe', ['-Command', script], { - input: options?.input, + return execaSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { + // Credential operations must not load shell profiles or wait for input + // from the CLI's terminal. An empty input closes stdin for reads/deletes. + input: options?.input ?? '', reject: false, + timeout: 10_000, }) } catch { return null diff --git a/src/utils/sessionStorage.agentUsage.test.ts b/src/utils/sessionStorage.agentUsage.test.ts new file mode 100644 index 0000000000..e45f0c4810 --- /dev/null +++ b/src/utils/sessionStorage.agentUsage.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getSessionId, getSessionProjectDir, switchSession } from '../bootstrap/state.js' +import { asAgentId } from '../types/ids.js' +import { emptyAgentUsage } from './agentUsage.js' +import { readAgentMetadata, writeAgentMetadata, writeAgentUsageMetadata } from './sessionStorage.js' + +test('metadata keeps legacy fields and rejects late writes from a replaced execution', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-metadata-')) + const session = getSessionId() + const projectDir = getSessionProjectDir() + const id = asAgentId('metadata-fixture') + const usage = { ...emptyAgentUsage(), state: 'reported' as const, inputTokens: 120, outputTokens: 24, confirmed: 144 } + try { + switchSession(session, dir) + await writeAgentMetadata(id, { agentType: 'explore', description: 'legacy session' }) + expect(await readAgentMetadata(id)).toEqual({ agentType: 'explore', description: 'legacy session' }) + await Promise.all([ + writeAgentMetadata(id, { agentType: 'explore', executionId: 'old', description: 'old' }), + writeAgentMetadata(id, { agentType: 'general-purpose', executionId: 'new', description: 'resumed' }), + writeAgentUsageMetadata(id, 'old', usage), + ]) + expect(await readAgentMetadata(id)).toEqual({ agentType: 'general-purpose', executionId: 'new', description: 'resumed' }) + await writeAgentUsageMetadata(id, 'new', usage) + expect(await readAgentMetadata(id)).toMatchObject({ executionId: 'new', description: 'resumed', tokenUsage: usage }) + await writeAgentUsageMetadata(id, 'old', emptyAgentUsage()) + expect((await readAgentMetadata(id))?.tokenUsage).toEqual(usage) + } finally { switchSession(session, projectDir); await rm(dir, { recursive: true, force: true }) } +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 73967948fd..45805841ce 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -1,5 +1,6 @@ import { feature } from 'bun:bundle' import type { UUID } from 'crypto' +import type { AgentTokenUsage } from './agentUsage.js' import type { Dirent } from 'fs' // Sync fs primitives for readFileTailSync — separate from fs/promises // imports above. Named (not wildcard) per CLAUDE.md style; no collisions @@ -12,6 +13,7 @@ import { mkdir, readdir, readFile, + rename, stat, unlink, writeFile, @@ -185,6 +187,7 @@ function isLegacyProgressEntry(entry: unknown): entry is LegacyProgressEntry { * by loadTranscriptFile to skip legacy entries from old transcripts. */ const EPHEMERAL_PROGRESS_TYPES = new Set([ + 'agent_usage', 'bash_progress', 'powershell_progress', 'mcp_progress', @@ -283,6 +286,8 @@ function getAgentMetadataPath(agentId: AgentId): string { export type AgentMetadata = { agentType: string + executionId?: string + tokenUsage?: AgentTokenUsage /** Worktree path if the agent was spawned with isolation: "worktree" */ worktreePath?: string /** Original task description from the AgentTool input. Persisted so a @@ -300,13 +305,35 @@ export type AgentMetadata = { * Also stores the worktreePath when the agent was spawned with worktree * isolation, enabling resume to restore the correct cwd. */ +const agentMetadataWrites = new Map>() +function serializeAgentMetadata(path: string, update: () => Promise): Promise { + const writing = (agentMetadataWrites.get(path) ?? Promise.resolve()).catch(() => {}).then(update) + agentMetadataWrites.set(path, writing) + void writing.finally(() => { if (agentMetadataWrites.get(path) === writing) agentMetadataWrites.delete(path) }).catch(() => {}) + return writing +} +async function persistAgentMetadata(path: string, metadata: AgentMetadata): Promise { + await mkdir(dirname(path), { recursive: true }) + const temporary = `${path}.${process.pid}.tmp` + try { + await writeFile(temporary, JSON.stringify(metadata)) + await rename(temporary, path) + } finally { await unlink(temporary).catch(() => {}) } +} export async function writeAgentMetadata( agentId: AgentId, metadata: AgentMetadata, ): Promise { const path = getAgentMetadataPath(agentId) - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(metadata)) + await serializeAgentMetadata(path, () => persistAgentMetadata(path, metadata)) +} + +export async function writeAgentUsageMetadata(agentId: AgentId, executionId: string, tokenUsage: AgentTokenUsage): Promise { + const path = getAgentMetadataPath(agentId) + await serializeAgentMetadata(path, async () => { + const metadata = await readAgentMetadata(agentId) + if (metadata?.executionId === executionId) await persistAgentMetadata(path, { ...metadata, tokenUsage }) + }) } export async function readAgentMetadata( diff --git a/src/utils/swarm/inProcessRunner.ts b/src/utils/swarm/inProcessRunner.ts index c85d959e1a..4c85ee28eb 100644 --- a/src/utils/swarm/inProcessRunner.ts +++ b/src/utils/swarm/inProcessRunner.ts @@ -43,6 +43,7 @@ import { createProgressTracker, getProgressUpdate, updateProgressFromMessage, + updateProgressUsage, } from '../../tasks/LocalAgentTask/LocalAgentTask.js' import type { CustomAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' import { runAgent } from '../../tools/AgentTool/runAgent.js' @@ -1173,6 +1174,11 @@ export async function runInProcessTeammate( // so they CAN show permission prompts (unlike true background agents). // Use currentWorkAbortController so Escape stops this turn only, not the teammate. for await (const message of runAgent({ + onUsageUpdate: update => { + if ((currentWorkAbortController.signal.aborted || abortController.signal.aborted) && !update.final) return; + updateProgressUsage(tracker, update); + updateTaskState(taskId, task => ({ ...task, progress: getProgressUpdate(tracker) }), setAppState); + }, agentDefinition: iterationAgentDefinition, promptMessages, toolUseContext, diff --git a/src/utils/task/sdkProgress.ts b/src/utils/task/sdkProgress.ts index 1430df2955..8144b8b202 100644 --- a/src/utils/task/sdkProgress.ts +++ b/src/utils/task/sdkProgress.ts @@ -1,5 +1,6 @@ import type { SdkWorkflowProgress } from '../../types/tools.js' import { enqueueSdkEvent } from '../sdkEventQueue.js' +import type { AgentTokenUsage } from '../agentUsage.js' /** * Emit a `task_progress` SDK event. Shared by background agents (per tool_use @@ -13,6 +14,7 @@ export function emitTaskProgress(params: { description: string startTime: number totalTokens: number + tokenUsage?: AgentTokenUsage toolUses: number lastToolName?: string summary?: string @@ -28,6 +30,7 @@ export function emitTaskProgress(params: { total_tokens: params.totalTokens, tool_uses: params.toolUses, duration_ms: Date.now() - params.startTime, + ...(params.tokenUsage && { token_usage: params.tokenUsage }), }, last_tool_name: params.lastToolName, summary: params.summary, diff --git a/tsconfig.agent-contracts.json b/tsconfig.agent-contracts.json new file mode 100644 index 0000000000..e4ab670635 --- /dev/null +++ b/tsconfig.agent-contracts.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2023", "lib": ["ES2023", "DOM"], "module": "ESNext", + "moduleResolution": "bundler", "strict": true, "noEmit": true, + "skipLibCheck": true, "types": ["bun"] + }, + "files": ["src/utils/agentUsage.ts", "src/utils/agentUsageSchema.ts", "src/components/agentPresentation.ts", "scripts/run-tests-isolated.ts"] +}