diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 761095f35a..1af1f9bbe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: token: '' @@ -78,6 +78,10 @@ jobs: run: scripts/test-release-ref-contract.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh + - name: OSS desktop promotion contract + run: | + scripts/test-oss-desktop-promotion.sh + scripts/test-oss-desktop-promotion-behavior.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh @@ -317,6 +321,9 @@ jobs: if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' permissions: contents: read + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -341,6 +348,13 @@ jobs: . desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} + # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; + # the bounded PR 5224 trial writes only to its isolated merge-ref scope. + - name: Set up sccache + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. + with: + version: v0.16.0 - name: Install cargo-nextest if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 @@ -348,6 +362,8 @@ jobs: tool: cargo-nextest@0.9.136 - name: Build relay artifacts if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + env: + RUSTC_WRAPPER: sccache run: | cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ @@ -359,7 +375,9 @@ jobs: --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache - if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' + # PR-scoped exact-source entries cannot warm main or other PRs and churn + # the shared cache pool. sccache provides read-only PR reuse instead. + if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | diff --git a/.github/workflows/promote-oss-desktop-release.yml b/.github/workflows/promote-oss-desktop-release.yml new file mode 100644 index 0000000000..f73bbd032b --- /dev/null +++ b/.github/workflows/promote-oss-desktop-release.yml @@ -0,0 +1,45 @@ +name: Promote OSS Desktop Auto-Update +run-name: Promote desktop-v${{ inputs.version }} to auto-update + +on: + workflow_dispatch: + inputs: + version: + description: Stable desktop version to promote (X.Y.Z) + required: true + type: string + +concurrency: + group: oss-desktop-auto-update-promotion + cancel-in-progress: false + +permissions: + contents: read + +jobs: + promote: + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Require the reviewed workflow from main + env: + DISPATCH_REF: ${{ github.ref }} + run: | + if [ "$DISPATCH_REF" != "refs/heads/main" ]; then + echo "::error::OSS desktop promotion must be dispatched from main, not $DISPATCH_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Validate and promote exact release manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: scripts/promote-oss-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da067b74e..2b0eb25c68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -907,6 +907,7 @@ jobs: [ "${#TRIPLES[@]}" -ge 3 ] || { echo "::error::too few platforms (${#TRIPLES[@]})"; exit 1; } bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json + cp latest.json staged/updater-manifest.json - name: Create or verify versioned draft run: | @@ -946,7 +947,3 @@ jobs: - name: Publish complete versioned release if: env.already_published != 'true' run: gh release edit "desktop-v${VERSION}" --draft=false - - - name: Upload latest.json to rolling release last - if: ${{ !contains(needs.setup.outputs.version, '-') }} - run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 843ac0dd7d..d682a4041a 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.8", - "base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f", - "previous_tag": "desktop-v0.5.7", - "previous_base_sha": "74b913cff8512c015dc6f1a7473b253fa803f954", - "previous_merge_sha": "13c9e900c84cac1e2c8eeb7551bd1510ecb544d3", - "tag": "desktop-v0.5.8", - "commit_count": 4 + "version": "0.5.9", + "base_sha": "f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b", + "previous_tag": "desktop-v0.5.8", + "previous_base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f", + "previous_merge_sha": "c815a9c6e1a1d1818a0547f60f894e4a5388761a", + "tag": "desktop-v0.5.9", + "commit_count": 29 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca9525074..178b0475b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## v0.5.9 + +### Desktop and shared changes + +- Polish desktop onboarding flow ([#5310](https://github.com/block/buzz/pull/5310)) ([`3f2f32641f4093d087fd9506bfac1fa0329e8b2e`](https://github.com/block/buzz/commit/3f2f32641f4093d087fd9506bfac1fa0329e8b2e)) +- fix(desktop): quiesce renderer polling while hidden (#3677) ([#5490](https://github.com/block/buzz/pull/5490)) ([`07a3c768d619db31fee3f0590f9433cdd1213e8f`](https://github.com/block/buzz/commit/07a3c768d619db31fee3f0590f9433cdd1213e8f)) +- fix(channels): restore member invitations to private channels ([#5493](https://github.com/block/buzz/pull/5493)) ([`2777189d960fa5b1d863166f36d6e37ff8ce0819`](https://github.com/block/buzz/commit/2777189d960fa5b1d863166f36d6e37ff8ce0819)) +- fix(desktop): bound nine unbounded localStorage stores ([#5454](https://github.com/block/buzz/pull/5454)) ([`9c074bb89b290721f839bbc84fdf4701269e43a0`](https://github.com/block/buzz/commit/9c074bb89b290721f839bbc84fdf4701269e43a0)) +- feat(desktop): time-based sweep for stale localStorage caches ([#5453](https://github.com/block/buzz/pull/5453)) ([`bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead`](https://github.com/block/buzz/commit/bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead)) +- feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 ([#4000](https://github.com/block/buzz/pull/4000)) ([`5e4c05f90b062898e1827ba45cb826c6ff913741`](https://github.com/block/buzz/commit/5e4c05f90b062898e1827ba45cb826c6ff913741)) +- fix(desktop): resolve overlapping member mentions ([#5225](https://github.com/block/buzz/pull/5225)) ([`44456e200e3ca6a5d2882b58b447b80474041347`](https://github.com/block/buzz/commit/44456e200e3ca6a5d2882b58b447b80474041347)) +- chore(deps): update rust crate anyhow to v1.0.104 ([#4447](https://github.com/block/buzz/pull/4447)) ([`e1ff91ecc1269682a50c17da2c0708d1448b336f`](https://github.com/block/buzz/commit/e1ff91ecc1269682a50c17da2c0708d1448b336f)) +- fix(desktop): preserve Welcome banner dismissal ([#5406](https://github.com/block/buzz/pull/5406)) ([`97aa9e31856edb9d8abcdcb33c472027f5588890`](https://github.com/block/buzz/commit/97aa9e31856edb9d8abcdcb33c472027f5588890)) +- fix(agent): retry LLM completion on malformed 2xx JSON body ([#5351](https://github.com/block/buzz/pull/5351)) ([`5bf78671f45178f8de02ba18d3d321cbbf19cd1f`](https://github.com/block/buzz/commit/5bf78671f45178f8de02ba18d3d321cbbf19cd1f)) +- fix(desktop): welcome banner overlap and missing dismiss control ([#5330](https://github.com/block/buzz/pull/5330)) ([`f029deafae6ad3b63e13c29104f3be76122cb1df`](https://github.com/block/buzz/commit/f029deafae6ad3b63e13c29104f3be76122cb1df)) +- fix(desktop): prevent horizontal clipping in Prompt Context modal ([#5324](https://github.com/block/buzz/pull/5324)) ([`fbf89e3bed9adebc033a26b7c43362c004e816a2`](https://github.com/block/buzz/commit/fbf89e3bed9adebc033a26b7c43362c004e816a2)) +- fix(buzz-agent): recover from 400-shaped image rejections; unbound benchmark agent rounds ([#5318](https://github.com/block/buzz/pull/5318)) ([`261c46076166c6de5bb9a71fb4a0fd0b70aa1efa`](https://github.com/block/buzz/commit/261c46076166c6de5bb9a71fb4a0fd0b70aa1efa)) + +### Other repository changes + +- feat(cli): add --visibility flag to channels update ([#5119](https://github.com/block/buzz/pull/5119)) ([`f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b`](https://github.com/block/buzz/commit/f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b)) +- perf(ci): experiment with sccache for relay builds ([#5224](https://github.com/block/buzz/pull/5224)) ([`5a3b3d23226474f835a1cf41d2ecc5f53cacb070`](https://github.com/block/buzz/commit/5a3b3d23226474f835a1cf41d2ecc5f53cacb070)) +- ci(release): gate OSS desktop auto-update promotion ([#5398](https://github.com/block/buzz/pull/5398)) ([`43573d114b5bfaf7cefa75eee7e219dc05cf1cd1`](https://github.com/block/buzz/commit/43573d114b5bfaf7cefa75eee7e219dc05cf1cd1)) +- fix(release): pin desktop PR operations to block/buzz ([#5212](https://github.com/block/buzz/pull/5212)) ([`c1e20a814bf694db2af959adacb375ced27af023`](https://github.com/block/buzz/commit/c1e20a814bf694db2af959adacb375ced27af023)) +- fix(search): surface exact short profile names ([#5480](https://github.com/block/buzz/pull/5480)) ([`3c76f682c3c2dfe2cd296c277c5e63799d3424f9`](https://github.com/block/buzz/commit/3c76f682c3c2dfe2cd296c277c5e63799d3424f9)) +- Reduce repeated ACP session context ([#5423](https://github.com/block/buzz/pull/5423)) ([`563e4346da37d0fb2e9ec1c95e7f1eba79f83040`](https://github.com/block/buzz/commit/563e4346da37d0fb2e9ec1c95e7f1eba79f83040)) +- chore(deps): update react monorepo ([#4441](https://github.com/block/buzz/pull/4441)) ([`119a84897f225c1e3213a09cd149abb37dcb3abc`](https://github.com/block/buzz/commit/119a84897f225c1e3213a09cd149abb37dcb3abc)) +- ci(security): allow retired relay pool advisory ([#5404](https://github.com/block/buzz/pull/5404)) ([`d2ebaa95a7d2565fb217fdfae56bafb9509be444`](https://github.com/block/buzz/commit/d2ebaa95a7d2565fb217fdfae56bafb9509be444)) +- chore(deps): update dependency @tanstack/react-virtual to v3.14.9 ([#4439](https://github.com/block/buzz/pull/4439)) ([`c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888`](https://github.com/block/buzz/commit/c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888)) +- chore(deps): update all non-major dependencies ([#3049](https://github.com/block/buzz/pull/3049)) ([`856cdb848b0a849e33620887b145b7e598dfd95c`](https://github.com/block/buzz/commit/856cdb848b0a849e33620887b145b7e598dfd95c)) +- chore(deps): update rust crate arc-swap to v1.9.2 ([#4448](https://github.com/block/buzz/pull/4448)) ([`08de85c592106ea2ffe22ba16e3a0fc10687db54`](https://github.com/block/buzz/commit/08de85c592106ea2ffe22ba16e3a0fc10687db54)) +- chore(deps): update rust crate async-trait to v0.1.91 ([#4458](https://github.com/block/buzz/pull/4458)) ([`12b1f566480d4feddc171739097f9359d3f255c1`](https://github.com/block/buzz/commit/12b1f566480d4feddc171739097f9359d3f255c1)) +- chore(deps): update rust crate diffy to v0.5.1 ([#4466](https://github.com/block/buzz/pull/4466)) ([`d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4`](https://github.com/block/buzz/commit/d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4)) +- chore(deps): update rust crate async-compression to v0.4.43 ([#4456](https://github.com/block/buzz/pull/4456)) ([`7dd8791d0765e9f15fed3299b6948e2babbfd763`](https://github.com/block/buzz/commit/7dd8791d0765e9f15fed3299b6948e2babbfd763)) +- chore(deps): update rust crate clap to v4.6.6 ([#4465](https://github.com/block/buzz/pull/4465)) ([`e668c6bb4913e36e58d7f947dbaf982e704e9132`](https://github.com/block/buzz/commit/e668c6bb4913e36e58d7f947dbaf982e704e9132)) +- chore(release): release Buzz Relay version 0.2.1 ([#2856](https://github.com/block/buzz/pull/2856)) ([`6e5c462ac524de60d7edb46c66130fd779cc9006`](https://github.com/block/buzz/commit/6e5c462ac524de60d7edb46c66130fd779cc9006)) + +[Compare desktop-v0.5.8...desktop-v0.5.9](https://github.com/block/buzz/compare/desktop-v0.5.8...desktop-v0.5.9) + ## v0.5.8 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index 877972a081..9efd71a763 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -128,14 +128,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "appattest" @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -329,13 +329,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -905,6 +905,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", "urlencoding", "webbrowser", ] @@ -1590,9 +1591,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -1600,9 +1601,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1612,14 +1613,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1670,7 +1671,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2105,9 +2106,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.13" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" dependencies = [ "link-section", "linktime-proc-macro", @@ -2291,7 +2292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -2454,9 +2455,9 @@ checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" [[package]] name = "diffy" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05264ab2aab4fb952fc4b0f3f6eff1ddfb4563064053a4ea174d91537584a769" +checksum = "10aec8f7f9393bd6a4f2762be0ceb012d3cbe2478987258cc9960de148561914" dependencies = [ "hashbrown 0.17.1", ] @@ -2502,7 +2503,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2725,7 +2726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3141,7 +3142,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -4568,15 +4569,15 @@ dependencies = [ [[package]] name = "link-section" -version = "0.19.3" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" +checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" [[package]] name = "linktime-proc-macro" -version = "0.2.3" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" +checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" [[package]] name = "linux-raw-sys" @@ -6002,7 +6003,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8148,7 +8149,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8207,7 +8208,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8490,7 +8491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8996,7 +8997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9508,6 +9509,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -9613,7 +9625,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9626,7 +9638,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10396,7 +10408,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10986,7 +10998,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c79760a1a1..13a56e7b0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,8 +150,8 @@ buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } # release optimization (warm from main's cache; they carry the runtime hot # path: tokio/sqlx/axum). Workspace crates build at opt-level 1 — enough for # stable e2e timing (PR #307 flakiness was opt-0 + debug-assertions) at -# roughly half the codegen cost. `incremental` is irrelevant in CI: -# rust-cache exports CARGO_INCREMENTAL=0 and never caches member artifacts. +# roughly half the codegen cost. Incremental stays disabled: rust-cache exports +# CARGO_INCREMENTAL=0, and the CI compiler cache requires non-incremental units. [profile.ci] inherits = "release" lto = false diff --git a/Justfile b/Justfile index 0a43249d5f..2e62599dac 100644 --- a/Justfile +++ b/Justfile @@ -355,7 +355,6 @@ mesh-dev-fresh: mesh-e2e-hardware: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_serve_client_smoke # Three isolated node processes: trusted member joins and infers; stranger is rejected. @@ -363,14 +362,12 @@ mesh-e2e-hardware: mesh-e2e-admission: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_admission_smoke # Full hardware confidence suite: routing, owner admission, and real agent inference. mesh-e2e-confidence: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo build --release -p buzz-agent -p buzz-dev-mcp cargo run -p buzz-relay --example mesh_serve_client_smoke cargo run -p buzz-relay --example mesh_admission_smoke @@ -457,9 +454,6 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations done fi cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay - if [[ -n "{{mesh}}" ]]; then - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" - fi # Docker Desktop's forwarded MinIO port can stall under the deployment # probe's 32 concurrent writers. Keep the gate enabled in local dev, using # the bounded profile already used by the relay test launcher. @@ -536,7 +530,6 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the @@ -572,7 +565,6 @@ production *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the diff --git a/RELEASING.md b/RELEASING.md index 53d5805561..8d1fad7480 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -173,10 +173,10 @@ for distributable builds or builds from an immutable release tag. `release.yml` has no manual dispatch and cannot build from `main` or another caller-selected ref. If a run for an existing immutable `desktop-v` tag fails, rerun that failed workflow from GitHub Actions -(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also -repairs `buzz-desktop-latest/latest.json` if the original run published the -versioned release but failed during that final rolling-manifest upload. Do not -recreate, move, or push the immutable tag again. +(or use `gh run rerun --failed --repo block/buzz`). A rerun +repairs the versioned draft if publication did not complete. It does not +promote that version to the auto-updater; promotion is a separate manual +action. Do not recreate, move, or push the immutable tag again. Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag. @@ -200,8 +200,25 @@ for the rest of the private pipeline contract. Desktop publishes two GitHub releases: -1. **`desktop-v`**: the user-facing release with installers. -2. **`buzz-desktop-latest`**: the rolling auto-updater release. +1. **`desktop-v`**: the user-facing release with installers and the + exact `updater-manifest.json` promotion candidate. Publishing this release + does not expose it through in-app auto-update. +2. **`buzz-desktop-latest`**: the rolling auto-updater release. Its + `latest.json` changes only through the manual promotion workflow. + +### Promote an OSS desktop release to auto-update + +After installing and testing the published `desktop-v` artifacts, run +**Promote OSS Desktop Auto-Update** from the `main` branch and enter the exact +stable `X.Y.Z` version. The workflow validates the immutable tag and release, +the retained manifest and every referenced updater asset, and requires the +version to be newer than the currently promoted version before replacing +`buzz-desktop-latest/latest.json`. Same-version retries succeed only when the +manifest is identical; downgrades are rejected. + +Withholding promotion leaves existing clients on the previous version. If a +promoted release is bad, ship and promote a higher patch version; changing the +manifest to an older version does not downgrade clients that already updated. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts and rollout records retain the exact tag they used. Mobile does not publish a diff --git a/VISION.md b/VISION.md index 66a106bdeb..900e5a9475 100644 --- a/VISION.md +++ b/VISION.md @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate. | Type | Visibility | Join | Create | |------|-----------|------|--------| | **Open channels** | Searchable by all members | Self-join | Any member | -| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member | +| **Private channels** | Hidden, invite-only | Invited by member | Any member | | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dcf..8460372aba 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -881,6 +881,16 @@ impl AcpClient { self.goose_usage.take() } + /// Notify the usage tracker that buzz-acp just spawned a new session. + /// + /// Seeds a zero baseline so the first usage notification for `session_id` + /// produces `delta_reliable: true` (turn delta == cumulative from zero). + /// Must be called only when buzz-acp created the session via `session/new`; + /// never when attaching to a pre-existing session. + pub(crate) fn notify_session_spawned(&mut self, session_id: &str) { + self.goose_usage.seed_zero_baseline(session_id); + } + /// Install a per-turn steer request channel for goose-native /// non-cancelling mid-turn delivery. /// @@ -1607,7 +1617,9 @@ impl AcpClient { "steer accepted as {STEER_OUTCOME_STARTED_NEW_TURN}: \ awaited turn had ended — hard deadline not renewed" ); - crate::pool::SteerAck::Success + crate::pool::SteerAck::Success { + session_id: session_id.to_owned(), + } } Some(_) => { let renew_now = Instant::now(); @@ -1619,7 +1631,9 @@ impl AcpClient { "steer success: renewed hard deadline ({max_duration:?} from now)" ); } - crate::pool::SteerAck::Success + crate::pool::SteerAck::Success { + session_id: session_id.to_owned(), + } } None => { // Report the raw string when @@ -1849,8 +1863,8 @@ impl AcpClient { tracing::debug!( target: "acp::usage", session_id = %notif.session_id, - input = payload.accumulated_input_tokens, - output = payload.accumulated_output_tokens, + input = ?payload.accumulated_input_tokens, + output = ?payload.accumulated_output_tokens, // A subset of `input`, logged so downstream accounting can // price it at the provider's cached rate. Always emitted, // including as 0, so a parser can tell "no cache hits" @@ -3789,7 +3803,7 @@ mod tests { .await .expect("ack oneshot must have received a SteerAck"); match ack { - crate::pool::SteerAck::Success => {} + crate::pool::SteerAck::Success { .. } => {} other => panic!("expected SteerAck::Success, got {other:?}"), } } @@ -3850,7 +3864,7 @@ mod tests { .await .expect("ack oneshot must have received a SteerAck"); match ack { - crate::pool::SteerAck::Success => {} + crate::pool::SteerAck::Success { .. } => {} other => panic!("expected SteerAck::Success, got {other:?}"), } } @@ -4034,7 +4048,7 @@ mod tests { "_session/steering must not carry expectedRunId; wrote: {written}" ); assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "injected outcome must ack Success, got {ack:?}" ); } @@ -4066,7 +4080,7 @@ mod tests { // no `outcome`) — the OutcomeRejected guard applies only to // `_session/steering`. assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "goose success result must ack Success, got {ack:?}" ); } @@ -4171,7 +4185,7 @@ mod tests { assert_eq!(result.unwrap()["done"], serde_json::json!(true)); let ack = ack_rx.await.expect("ack must be received"); assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "injected must ack Success, got {ack:?}" ); } @@ -4228,7 +4242,7 @@ mod tests { // rather than released — hence Success, not an Err. let ack = ack_rx.await.expect("ack must be received"); assert!( - matches!(ack, crate::pool::SteerAck::Success), + matches!(ack, crate::pool::SteerAck::Success { .. }), "startedNewTurn is a delivery success, got {ack:?}" ); } @@ -4306,8 +4320,8 @@ mod tests { assert_eq!(usage.session_id, "s1"); assert_eq!(usage.turn_seq, 1); assert!(!usage.delta_reliable, "first turn must be unreliable"); - assert_eq!(usage.cumulative_input_tokens, 1000); - assert_eq!(usage.cumulative_output_tokens, 200); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!(usage.cumulative_output_tokens, Some(200)); assert_eq!(usage.cumulative_cost_usd, Some(0.01)); // Second take must be None. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b69a1f453a..fa348eeb3c 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2821,7 +2821,7 @@ async fn tokio_main() -> Result<()> { // treat as PromptCompletedNeutral to avoid leaking // the withheld event in `withheld_native_steer`. let (release_withheld, drop_withheld, signal_fallback) = match &ack { - Ok(pool::SteerAck::Success) => (false, true, false), + Ok(pool::SteerAck::Success { .. }) => (false, true, false), // -32601 = method_not_found: agent does not implement the // steer extension. Fire cancel+merge so the message still // reaches the agent. @@ -2854,8 +2854,19 @@ async fn tokio_main() -> Result<()> { signal_fallback, "non-cancelling steer ack received" ); - if matches!(ack, Ok(pool::SteerAck::Success)) { + if let Ok(pool::SteerAck::Success { session_id }) = &ack { queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); + if !pool.record_successful_steer( + channel_id, + event_id.clone(), + session_id.clone(), + ) { + tracing::warn!( + channel = %channel_id, + event_id = %event_id, + "successful steer lost its in-flight delivery ledger" + ); + } } if drop_withheld { queue.remove_event(channel_id, &event_id); @@ -3318,6 +3329,7 @@ fn dispatch_pending( recoverable_batch, control_tx: Some(control_tx), steer_tx, + successful_steer_deliveries: HashSet::new(), }, ); dispatched_channels.push((channel_id, typing_scope)); @@ -3399,9 +3411,30 @@ fn handle_prompt_result( ) -> LoopAction { let before = pool.task_map().len(); let agent_index = result.agent.index; + let successful_steer_deliveries = pool + .task_map() + .values() + .find(|meta| meta.agent_index == agent_index) + .map(|meta| meta.successful_steer_deliveries.clone()) + .unwrap_or_default(); pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); + if let PromptSource::Channel(channel_id) = &result.source { + // The task may have invalidated this session before returning. Never + // resurrect delivery state for a dead session; its replacement must + // receive fresh standing context and history. + if let Some(live_session_id) = result.agent.state.sessions.get(channel_id).cloned() { + let event_ids = successful_steer_deliveries + .into_iter() + .filter(|delivery| delivery.session_id == live_session_id) + .map(|delivery| delivery.event_id); + result + .agent + .state + .mark_channel_delivery_success(*channel_id, false, event_ids); + } + } // The hard-timeout death_message (below) must describe the batch's // *actual* fate, not just the `recently_active` eligibility flag — a @@ -3932,6 +3965,7 @@ fn dispatch_heartbeat( recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); *heartbeat_in_flight = true; @@ -4574,17 +4608,23 @@ mod heartbeat_base_prompt_tests { // heartbeat user message, composed as `[Base]\n{bp}\n\n{prompt}`. This is // the second half of the round-2 regression (the first being initial_message). + fn heartbeat_standing() -> queue::StandingContext<'static> { + queue::StandingContext { + base_prompt: Some("you are a helpful agent"), + ..Default::default() + } + } + #[test] fn test_heartbeat_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): heartbeat prompt is prefixed // with the [Base] section exactly as the legacy session/new path would. let prompt = "[System: Heartbeat]\nrun feed get"; - let composed = pool::prepend_base_for_legacy(1, Some("you are a helpful agent"), prompt); + let composed = pool::prepend_standing_for_legacy(1, &heartbeat_standing(), prompt); assert_eq!( composed, "[Base]\nyou are a helpful agent\n\n[System: Heartbeat]\nrun feed get" ); - assert!(composed.starts_with("[Base]\nyou are a helpful agent\n\n")); } #[test] @@ -4592,7 +4632,7 @@ mod heartbeat_base_prompt_tests { // protocol_version 2 gets base_prompt via session/new; the heartbeat // prompt is sent verbatim. let prompt = "[System: Heartbeat]\nrun feed get"; - let composed = pool::prepend_base_for_legacy(2, Some("you are a helpful agent"), prompt); + let composed = pool::prepend_standing_for_legacy(2, &heartbeat_standing(), prompt); assert_eq!(composed, prompt); } } @@ -4703,6 +4743,7 @@ mod owner_control_command_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); @@ -6473,6 +6514,263 @@ mod error_outcome_emission_tests { } } + #[tokio::test] + async fn successful_native_steer_is_transferred_to_live_session_delivery_state() { + let channel_id = Uuid::new_v4(); + let steer_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "test-turn-id".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::from([ + crate::pool::SuccessfulSteerDelivery { + event_id: steer_event_id.into(), + session_id: "live-session".into(), + }, + ]), + }, + ); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .contains(steer_event_id)); + } + + #[tokio::test] + async fn in_flight_stale_native_steer_ack_cannot_update_replacement_session() { + let channel_id = Uuid::new_v4(); + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "replacement-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "test-turn-id".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::from([ + crate::pool::SuccessfulSteerDelivery { + event_id: "stale-event".into(), + session_id: "old-session".into(), + }, + ]), + }, + ); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .is_empty()); + } + + #[tokio::test] + async fn successful_native_steer_ack_after_task_return_updates_matching_live_session() { + let channel_id = Uuid::new_v4(); + let steer_event_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert!(pool.record_successful_steer( + channel_id, + steer_event_id.into(), + "live-session".into(), + )); + let returned = pool.agents_mut()[0].as_ref().expect("idle returned agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .contains(steer_event_id)); + } + + #[tokio::test] + async fn late_native_steer_ack_cannot_update_replacement_session() { + let channel_id = Uuid::new_v4(); + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(channel_id, "replacement-session".into()); + agent + .state + .deliveries + .insert(channel_id, Default::default()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert!(!pool.record_successful_steer( + channel_id, + "stale-event".into(), + "old-session".into(), + )); + let returned = pool.agents_mut()[0].as_ref().expect("replacement agent"); + assert!(returned.state.deliveries[&channel_id] + .delivered_event_ids + .is_empty()); + } + + #[tokio::test] + async fn invalidated_session_does_not_resurrect_successful_steer_delivery_state() { + let channel_id = Uuid::new_v4(); + let agent = dummy_agent(0).await; + // No live session: simulates the prompt task invalidating before return. + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "test-turn-id".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::from([ + crate::pool::SuccessfulSteerDelivery { + event_id: "stale-event".into(), + session_id: "invalidated-session".into(), + }, + ]), + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); + assert!(!returned.state.deliveries.contains_key(&channel_id)); + } + /// Drive one error outcome through `handle_prompt_result` and return how /// many `turn_error` events it emitted to the observer feed. async fn turn_errors_emitted_for(outcome: PromptOutcome) -> usize { @@ -6493,6 +6791,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); @@ -6569,6 +6868,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); started_rx.await.unwrap(); @@ -6661,6 +6961,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6752,6 +7053,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6857,6 +7159,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6933,6 +7236,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7027,6 +7331,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let config = test_config(); @@ -7143,6 +7448,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7282,6 +7588,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7470,6 +7777,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7555,6 +7863,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + successful_steer_deliveries: HashSet::new(), }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ddc0330d9f..33bd5507fb 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -50,6 +50,12 @@ const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); // a recoverable copy in TaskMeta for panic recovery in Queue mode. /// Metadata stored per in-flight task for panic recovery. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct SuccessfulSteerDelivery { + pub event_id: String, + pub session_id: String, +} + pub struct TaskMeta { pub agent_index: usize, pub channel_id: Option, @@ -67,6 +73,10 @@ pub struct TaskMeta { /// tasks only — all prompt tasks install a steer channel regardless /// of the agent's name. pub steer_tx: Option>, + /// Successful non-cancelling steers acknowledged while this task owned the + /// live session. The session ID prevents a late ack from contaminating a + /// replacement session after task return. + pub successful_steer_deliveries: HashSet, } /// Agent-level model capabilities. Populated on first session creation. @@ -80,7 +90,17 @@ pub struct AgentModelCapabilities { pub available_models_raw: Option, } -/// Per-channel session IDs and turn counters. +/// Successful deliveries associated with one live channel session. +#[derive(Default)] +pub struct ChannelDeliveryState { + /// Whether a legacy user message has successfully carried standing context. + pub standing_context_sent: bool, + /// Buzz event IDs already delivered to this ACP session, either as trigger + /// events or conversation context. + pub delivered_event_ids: HashSet, +} + +/// Per-channel session IDs, turn counters, and delivery state. /// /// Separated from `OwnedAgent` so the state machine is testable without /// spawning a real agent subprocess. @@ -94,6 +114,8 @@ pub struct SessionState { pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, + /// Whether the live heartbeat session has successfully received `[Base]`. + pub heartbeat_standing_context_sent: bool, /// channel_id → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). pub core_sections: HashMap, @@ -104,6 +126,9 @@ pub struct SessionState { /// fetch fails — all fail open. Cleared on session invalidation alongside /// `core_sections` so the next session picks up any canvas change. pub canvas_sections: HashMap, + /// Per-channel successful-delivery state. Created with the ACP session and + /// cleared atomically with every invalidation path. + pub deliveries: HashMap, } impl SessionState { @@ -116,6 +141,7 @@ impl SessionState { PromptSource::Heartbeat => { self.heartbeat_session = None; self.heartbeat_turn_count = 0; + self.heartbeat_standing_context_sent = false; } } } @@ -126,6 +152,7 @@ impl SessionState { self.turn_counts.remove(channel_id); self.core_sections.remove(channel_id); self.canvas_sections.remove(channel_id); + self.deliveries.remove(channel_id); self.sessions.remove(channel_id).is_some() } @@ -135,8 +162,21 @@ impl SessionState { self.turn_counts.clear(); self.heartbeat_session = None; self.heartbeat_turn_count = 0; + self.heartbeat_standing_context_sent = false; self.core_sections.clear(); self.canvas_sections.clear(); + self.deliveries.clear(); + } + + pub(crate) fn mark_channel_delivery_success( + &mut self, + channel_id: Uuid, + standing_context_sent: bool, + event_ids: impl IntoIterator, + ) { + let delivery = self.deliveries.entry(channel_id).or_default(); + delivery.standing_context_sent |= standing_context_sent; + delivery.delivered_event_ids.extend(event_ids); } #[cfg(test)] @@ -145,6 +185,7 @@ impl SessionState { || self.turn_counts.contains_key(channel_id) || self.core_sections.contains_key(channel_id) || self.canvas_sections.contains_key(channel_id) + || self.deliveries.contains_key(channel_id) } } @@ -408,7 +449,7 @@ pub enum SteerAck { /// The agent returned a successful response to the steer request. /// The main loop must drop the withheld event (`remove_event`) — it /// has been delivered via the non-cancelling path. - Success, + Success { session_id: String }, /// The steer was attempted but failed. Delivery state for the /// underlying message is unknown after prompt completion; the main /// loop must release the withheld event and fall back to the @@ -696,6 +737,40 @@ impl AgentPool { .map_err(|e| SteerError::Transport(e.to_string())) } + /// Durably associate a successful steer with the exact ACP session that + /// accepted it. Acks may arrive before or after the prompt result: while + /// the task is in flight we stage the delivery in `TaskMeta`; after return + /// we write directly to the idle agent's matching live-session ledger. + pub fn record_successful_steer( + &mut self, + channel_id: Uuid, + event_id: String, + session_id: String, + ) -> bool { + if let Some(meta) = self + .task_map + .values_mut() + .find(|meta| meta.channel_id == Some(channel_id)) + { + meta.successful_steer_deliveries + .insert(SuccessfulSteerDelivery { + event_id, + session_id, + }); + return true; + } + + let Some(agent) = self.agents.iter_mut().flatten().find(|agent| { + agent.state.sessions.get(&channel_id).map(String::as_str) == Some(session_id.as_str()) + }) else { + return false; + }; + agent + .state + .mark_channel_delivery_success(channel_id, false, [event_id]); + true + } + pub fn result_tx(&self) -> mpsc::UnboundedSender { self.result_tx.clone() } @@ -1205,42 +1280,30 @@ async fn apply_permission_mode( Ok(()) } -/// Prepend the `[Base]` section to a user-message body for legacy agents. +/// Prepend a legacy agent's standing context to a user-message body. /// -/// Legacy agents (`protocol_version < 2`) don't receive `base_prompt` via the -/// system role in `session/new`, so it must ride along in the user message. -/// Agents with `protocol_version >= 2`, or any agent without a `base_prompt`, -/// get `body` unchanged. The gate lives here so the heartbeat and -/// initial-message dispatch paths can't drift apart again. -pub(crate) fn prepend_base_for_legacy( +/// Legacy agents (`protocol_version < 2`) don't receive standing context via +/// the system role in `session/new`, so it must ride along in the user message +/// — in the session's *first* one, and never again. Agents with +/// `protocol_version >= 2`, or an empty [`StandingContext`], get `body` +/// unchanged. Both legacy dispatch paths (initial message, heartbeat) go +/// through this one gate so they can't drift apart again. +/// +/// A heartbeat passes base only: it has no channel, so there is no core or +/// canvas to carry, and it has never been given the persona. +pub(crate) fn prepend_standing_for_legacy( protocol_version: u32, - base_prompt: Option<&str>, + standing: &crate::queue::StandingContext<'_>, body: &str, ) -> String { - match base_prompt { - Some(bp) if protocol_version < 2 => { - format!("{}\n\n{body}", crate::queue::base_section(bp)) - } - _ => body.to_string(), + if protocol_version >= 2 { + return body.to_string(); } -} - -/// Prepend the `[Channel Canvas]` section to the legacy initial-message body. -/// -/// Protocol-v2 agents already receive the canvas in `systemPrompt`; only -/// legacy (protocol_version < 2) agents need it injected here so it arrives -/// before the first prompt — the same "every turn" semantics as per-turn core. -/// Heartbeats never have an initial_message, so the caller is responsible for -/// not passing a canvas when `source` is `Heartbeat`. -pub(crate) fn prepend_canvas_for_legacy( - protocol_version: u32, - agent_canvas: Option<&str>, - body: &str, -) -> String { - match agent_canvas { - Some(canvas) if protocol_version < 2 => format!("{canvas}\n\n{body}"), - _ => body.to_string(), + let sections = standing.sections(); + if sections.is_empty() { + return body.to_string(); } + format!("{}\n\n{body}", sections.join("\n\n")) } /// Frame the `session/new` `systemPrompt` so each present prompt carries its own @@ -1619,6 +1682,13 @@ pub async fn run_prompt_task( "created session {sid} for channel {cid}" ); agent.state.sessions.insert(*cid, sid.clone()); + agent + .state + .deliveries + .insert(*cid, ChannelDeliveryState::default()); + // Seed a zero usage baseline: buzz-acp spawned this session + // so prior usage is zero by definition — first turn is reliable. + agent.acp.notify_session_spawned(&sid); // Commit canvas only after session creation succeeds (I3). if let Some((pending_cid, section)) = pending_canvas.take() { agent.state.canvas_sections.insert(pending_cid, section); @@ -1667,6 +1737,8 @@ pub async fn run_prompt_task( agent.index ); agent.state.heartbeat_session = Some(sid.clone()); + // Seed a zero usage baseline: buzz-acp spawned this session. + agent.acp.notify_session_spawned(&sid); (sid, true) } Err(AcpError::AgentExited) => { @@ -1713,6 +1785,32 @@ pub async fn run_prompt_task( }), ); + // Standing context is fixed for the life of a session. Agents with + // systemPrompt support already hold it from session/new; legacy agents + // receive it in the session's first user message and never again. + // + // `is_new_session` comes from the session registry, which is cleared + // whenever a session is invalidated — so the replacement session re-delivers + // rather than leaving the agent unbriefed. + let standing = crate::queue::StandingContext { + base_prompt: ctx.base_prompt, + system_prompt: ctx.system_prompt.as_deref(), + team_instructions: ctx.team_instructions.as_deref(), + agent_core: agent_core.as_deref(), + agent_canvas: agent_canvas.as_deref(), + }; + // Delivery state is committed only after ACP confirms success. Existing + // sessions created before this field existed fail safe by behaving as + // undelivered once, rather than silently omitting standing context. + let mut standing_context_sent = match &source { + PromptSource::Channel(cid) => agent + .state + .deliveries + .get(cid) + .is_some_and(|delivery| delivery.standing_context_sent), + PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, + }; + if is_new_session { if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) { @@ -1720,30 +1818,15 @@ pub async fn run_prompt_task( target: "pool::session", "sending initial_message to session {session_id} for channel {cid}" ); - // For agents with systemPrompt support (protocol_version >= 2), - // base_prompt is delivered via the system role in session/new. - // Legacy agents receive it via [Base] in the user message instead. - // Canvas is also injected here for legacy agents: protocol-v2 agents - // already have it in systemPrompt; legacy agents need it before the - // first prompt, matching the "every turn" per-turn delivery semantics. - let init_msg = prepend_base_for_legacy( + let init_msg = prepend_standing_for_legacy( if agent.has_system_prompt_support() { 2 } else { 1 }, - ctx.base_prompt, + &standing, initial_msg, ); - let init_msg = prepend_canvas_for_legacy( - if agent.has_system_prompt_support() { - 2 - } else { - 1 - }, - agent_canvas.as_deref(), - &init_msg, - ); let init_result = agent .acp .session_prompt_with_idle_timeout( @@ -1760,6 +1843,12 @@ pub async fn run_prompt_task( target: "pool::session", "initial_message complete for channel {cid}: {stop_reason:?}" ); + // The legacy agent has its standing context now; the turn + // prompt below must not repeat it. Every other arm returns. + standing_context_sent = true; + if !agent.has_system_prompt_support() { + agent.state.mark_channel_delivery_success(*cid, true, []); + } } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); @@ -1861,18 +1950,31 @@ pub async fn run_prompt_task( // (`prompt[0].text.startsWith("/")`) fires; the wrapped Buzz context // follows as a second block. let mut slash_command: Option = None; + // Event IDs represented by this prompt. Commit only after ACP reports a + // successful turn; failed/cancelled prompts must be retryable without loss. + let mut pending_delivered_event_ids = HashSet::new(); let prompt_sections: Vec = if let Some(text) = prompt_text { // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. - let text = prepend_base_for_legacy( - if agent.has_system_prompt_support() { - 2 - } else { - 1 - }, - ctx.base_prompt, - &text, - ); + // + // Only the first heartbeat of a session carries `[Base]`; later ticks + // reuse the same session, so the agent already has it. + let text = if standing_context_sent { + text + } else { + prepend_standing_for_legacy( + if agent.has_system_prompt_support() { + 2 + } else { + 1 + }, + &crate::queue::StandingContext { + base_prompt: ctx.base_prompt, + ..Default::default() + }, + &text, + ) + }; vec![text] } else if let Some(ref b) = batch { // Build prompt from batch with context enrichment. @@ -1884,6 +1986,31 @@ pub async fn run_prompt_task( } else { None }; + let rendered_batch_ids: HashSet = b + .events + .iter() + .chain(b.cancelled_events.iter()) + .map(|event| event.event.id.to_hex()) + .collect(); + let delivered_ids = agent + .state + .deliveries + .get(&b.channel_id) + .map(|delivery| &delivery.delivered_event_ids) + .cloned() + .unwrap_or_default(); + let conversation_context_had_delivered_events = + conversation_context.as_ref().is_some_and(|context| { + conversation_context_event_ids(Some(context)) + .iter() + .any(|event_id| delivered_ids.contains(event_id)) + }); + let conversation_context = + conversation_context_delta(conversation_context, &delivered_ids, &rendered_batch_ids); + pending_delivered_event_ids.extend(rendered_batch_ids); + pending_delivered_event_ids.extend(conversation_context_event_ids( + conversation_context.as_ref(), + )); let profile_lookup = fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; @@ -1907,15 +2034,17 @@ pub async fn run_prompt_task( crate::queue::format_prompt( b, &crate::queue::FormatPromptArgs { - agent_core: agent_core.as_deref(), + agent_core: standing.agent_core, channel_info: channel_info.as_ref(), conversation_context: conversation_context.as_ref(), + conversation_context_had_delivered_events, profile_lookup: profile_lookup.as_ref(), has_system_prompt_support: agent.has_system_prompt_support(), - base_prompt: ctx.base_prompt, - system_prompt: ctx.system_prompt.as_deref(), - team_instructions: ctx.team_instructions.as_deref(), - agent_canvas: agent_canvas.as_deref(), + base_prompt: standing.base_prompt, + system_prompt: standing.system_prompt, + team_instructions: standing.team_instructions, + agent_canvas: standing.agent_canvas, + standing_context_sent, }, ) } else { @@ -1955,6 +2084,28 @@ pub async fn run_prompt_task( .collect(), None => prompt_sections.iter().map(String::as_str).collect(), }; + let prompt_bytes: usize = prompt_blocks.iter().map(|block| block.len()).sum(); + let has_standing_context = match &source { + PromptSource::Channel(_) => !standing.sections().is_empty(), + PromptSource::Heartbeat => ctx.base_prompt.is_some(), + }; + let standing_context_included = + !agent.has_system_prompt_support() && !standing_context_sent && has_standing_context; + tracing::info!( + target: "pool::prompt", + prompt_bytes, + standing_context_included, + delivered_event_delta = pending_delivered_event_ids.len(), + "prompt context delivery" + ); + agent.acp.observe( + "prompt_context_delivery", + serde_json::json!({ + "promptBytes": prompt_bytes, + "standingContextIncluded": standing_context_included, + "eventDeltaCount": pending_delivered_event_ids.len(), + }), + ); // Turn start, labelled exactly as `log_stop_reason` labels the end, so a // log reads as start/stop pairs. Purely observational: an unpaired start is @@ -2105,6 +2256,14 @@ pub async fn run_prompt_task( "control signal arrived but turn already completed — treating as success" ); } + if let PromptSource::Channel(cid) = &source { + let standing_sent = !agent.has_system_prompt_support(); + agent.state.mark_channel_delivery_success( + *cid, + standing_sent, + pending_delivered_event_ids.iter().cloned(), + ); + } apply_completed_before_control_signal( &mut agent.state, &source, @@ -2139,6 +2298,17 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + if let PromptSource::Channel(cid) = &source { + let standing_sent = !agent.has_system_prompt_support(); + agent.state.mark_channel_delivery_success( + *cid, + standing_sent, + pending_delivered_event_ids.iter().cloned(), + ); + } else if !agent.has_system_prompt_support() { + agent.state.heartbeat_standing_context_sent = true; + } + let should_rotate = matches!( stop_reason, StopReason::MaxTokens | StopReason::MaxTurnRequests @@ -2633,6 +2803,67 @@ pub(crate) fn render_canvas_section(event_id: &str, timestamp: &str, channel_uui ) } +fn conversation_context_event_ids(context: Option<&ConversationContext>) -> HashSet { + match context { + Some(ConversationContext::Thread { messages, .. }) + | Some(ConversationContext::Dm { messages, .. }) => messages + .iter() + .filter(|message| !message.event_id.is_empty()) + .map(|message| message.event_id.clone()) + .collect(), + None => HashSet::new(), + } +} + +/// Remove events already delivered to this live ACP session. Triggering events +/// are also excluded because they are rendered separately in `[Event]`. +/// IDs are compared in Buzz's canonical 64-character lowercase hex form: relay +/// context JSON supplies the same form emitted by `EventId::to_hex()`. A +/// non-canonical or missing ID deliberately fails open and may be re-sent. +fn conversation_context_delta( + context: Option, + delivered: &HashSet, + triggering: &HashSet, +) -> Option { + let filter = |messages: Vec| { + messages + .into_iter() + .filter(|message| { + message.event_id.is_empty() + || (!delivered.contains(&message.event_id) + && !triggering.contains(&message.event_id)) + }) + .collect::>() + }; + + match context? { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + let messages = filter(messages); + (!messages.is_empty()).then_some(ConversationContext::Thread { + messages, + total, + truncated, + }) + } + ConversationContext::Dm { + messages, + total, + truncated, + } => { + let messages = filter(messages); + (!messages.is_empty()).then_some(ConversationContext::Dm { + messages, + total, + truncated, + }) + } + } +} + /// Fetch conversation context (thread or DM) for a batch before prompting. /// /// Returns `None` if: @@ -3145,7 +3376,14 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { }) .unwrap_or_else(|| "unknown".to_string()); + let event_id = obj + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + Some(ContextMessage { + event_id, pubkey: pubkey.to_string(), timestamp, content: content.to_string(), @@ -3682,9 +3920,8 @@ pub(crate) fn build_turn_metric_counts( // Field-local: present when the cumulative counter was monotonic // across this turn. Zero means no cache hits this turn (not absent). cache_read_tokens: usage.turn_cache_read_tokens, - // buzz-agent does not emit a cache-write count on the wire today; - // leave None rather than deriving it from other fields. - cache_write_tokens: None, + // Field-local: same contract as cache_read_tokens. + cache_write_tokens: usage.turn_cache_write_tokens, }) } else { // Defense-in-depth: UsageTracker already sets all turn_* fields to None @@ -3694,8 +3931,8 @@ pub(crate) fn build_turn_metric_counts( None }; let cumulative_counts = Some(TokenCounts { - input_tokens: Some(usage.cumulative_input_tokens), - output_tokens: Some(usage.cumulative_output_tokens), + input_tokens: usage.cumulative_input_tokens, + output_tokens: usage.cumulative_output_tokens, // Present when every turn in the session reported a genuine provider // total. None when the session has never emitted one or any turn lacked // one. Never derived from input+output (NIP-AM MUST NOT). @@ -3706,9 +3943,9 @@ pub(crate) fn build_turn_metric_counts( // Passes through directly — do not wrap in Some() as the field already // carries provenance (None vs Some(0) are distinct meanings). cache_read_tokens: usage.cumulative_cache_read_tokens, - // buzz-agent does not emit a cache-write count on the wire today; - // leave None rather than deriving it from other fields. - cache_write_tokens: None, + // Session-cumulative cache-write tokens; same provenance contract as + // cache_read_tokens. + cache_write_tokens: usage.cumulative_cache_write_tokens, }); (turn_counts, cumulative_counts) } @@ -3749,6 +3986,7 @@ async fn publish_agent_turn_metric( cumulative: cumulative_counts, delta_reliable: usage.delta_reliable, stop_reason, + pricing_identity: usage.pricing_identity.clone(), }; let ciphertext = match buzz_core::agent_turn_metric::encrypt_agent_turn_metric( &ctx.agent_keys, @@ -4078,23 +4316,46 @@ mod tests { // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. + fn base_only(base_prompt: Option<&str>) -> crate::queue::StandingContext<'_> { + crate::queue::StandingContext { + base_prompt, + ..Default::default() + } + } + #[test] fn test_initial_message_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): [Base] rides along in the // user message, composed as `[Base]\n{bp}\n\n{initial_msg}`. - let composed = prepend_base_for_legacy(1, Some("you are a helpful agent"), "hello channel"); + let composed = prepend_standing_for_legacy( + 1, + &base_only(Some("you are a helpful agent")), + "hello channel", + ); assert_eq!(composed, "[Base]\nyou are a helpful agent\n\nhello channel"); - assert!(composed.starts_with("[Base]\nyou are a helpful agent\n\n")); } #[test] fn test_initial_message_modern_agent_omits_base() { // protocol_version 2 receives base_prompt via session/new, so the user // message is left untouched even when a base_prompt is present. - let composed = prepend_base_for_legacy(2, Some("you are a helpful agent"), "hello channel"); + let composed = prepend_standing_for_legacy( + 2, + &base_only(Some("you are a helpful agent")), + "hello channel", + ); assert_eq!(composed, "hello channel"); } + #[test] + fn test_heartbeat_standing_block_is_base_only() { + // A heartbeat has no channel, so core and canvas are absent by + // construction — and it has never carried the persona. Pin that the + // shared helper does not start handing heartbeats [System]. + let composed = prepend_standing_for_legacy(1, &base_only(Some("be helpful")), "tick"); + assert_eq!(composed, "[Base]\nbe helpful\n\ntick"); + } + #[test] fn goose_uses_system_prompt_only_after_custom_method_succeeds() { assert!(!has_system_prompt_support(2, "goose", None)); @@ -4149,82 +4410,75 @@ mod tests { #[test] fn test_initial_message_legacy_agent_without_base_is_unchanged() { // No base_prompt configured: nothing to prepend regardless of version. - let composed = prepend_base_for_legacy(1, None, "hello channel"); + let composed = prepend_standing_for_legacy(1, &base_only(None), "hello channel"); assert_eq!(composed, "hello channel"); } - // ── prepend_canvas_for_legacy ───────────────────────────────────────────── + // ── prepend_standing_for_legacy ─────────────────────────────────────────── + + fn full_standing() -> crate::queue::StandingContext<'static> { + crate::queue::StandingContext { + base_prompt: Some("be helpful"), + system_prompt: Some("you are Eva"), + team_instructions: Some("ship small"), + agent_core: Some("[Agent Memory — core]\nremember this"), + agent_canvas: Some("[Channel Canvas]\ncanvas content"), + } + } #[test] - fn test_initial_message_legacy_agent_gets_canvas_prepended() { - // Legacy agents (protocol_version < 2) receive the canvas section before - // the initial-message body so it arrives before the first prompt. - let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00Z\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; - let composed = prepend_canvas_for_legacy(1, Some(canvas), "do the thing"); + fn test_initial_message_legacy_agent_gets_whole_standing_block() { + // The initial message is the legacy agent's first contact, so it must + // carry every standing section — not just [Base] and the canvas, which + // left the agent acting on its first turn with no persona and no memory. + let composed = prepend_standing_for_legacy(1, &full_standing(), "do the thing"); + let positions: Vec = [ + "[Base]", + "[System]", + "[Team Instructions]", + "[Agent Memory — core]", + "[Channel Canvas]", + "do the thing", + ] + .iter() + .map(|needle| { + composed + .find(needle) + .unwrap_or_else(|| panic!("missing {needle} in: {composed}")) + }) + .collect(); assert!( - composed.starts_with("[Channel Canvas]"), - "canvas must precede the body" - ); - assert!( - composed.ends_with("do the thing"), - "body must follow the canvas" - ); - assert!( - composed.contains("\n\ndo the thing"), - "canvas and body separated by blank line" + positions.windows(2).all(|w| w[0] < w[1]), + "sections must match the per-turn order, body last; got: {composed}" ); } #[test] - fn test_initial_message_modern_agent_omits_canvas_from_body() { - // Protocol-v2 agents receive canvas in systemPrompt; it must NOT be - // duplicated in the initial-message user turn. - let canvas = "[Channel Canvas]\nsome section"; - let composed = prepend_canvas_for_legacy(2, Some(canvas), "do the thing"); + fn test_initial_message_standing_order_matches_per_turn_order() { + // Both legacy paths render through StandingContext, so the initial + // message and a first-turn prompt agree section-for-section. + let standing = full_standing(); + let composed = prepend_standing_for_legacy(1, &standing, "do the thing"); assert_eq!( - composed, "do the thing", - "modern agent initial message must not contain canvas" - ); - assert!( - !composed.contains("[Channel Canvas]"), - "canvas must be absent from modern agent initial message" + composed, + format!("{}\n\ndo the thing", standing.sections().join("\n\n")) ); } #[test] - fn test_initial_message_legacy_agent_no_canvas_is_unchanged() { - // No canvas present: body passes through unmodified. - let composed = prepend_canvas_for_legacy(1, None, "do the thing"); + fn test_initial_message_modern_agent_omits_standing_block() { + // Protocol-v2 agents hold all of this from session/new; repeating it in + // the initial-message user turn would double-render every section. + let composed = prepend_standing_for_legacy(2, &full_standing(), "do the thing"); assert_eq!(composed, "do the thing"); } #[test] - fn test_initial_message_legacy_canvas_and_base_compose_correctly() { - // Verify the full composition order when both base and canvas are present: - // [Base] → canvas section → initial-message body. - let canvas = "[Channel Canvas]\ncanvas content"; - let base_composed = prepend_base_for_legacy(1, Some("be helpful"), "do the thing"); - let full = prepend_canvas_for_legacy(1, Some(canvas), &base_composed); - assert!( - full.starts_with("[Channel Canvas]"), - "canvas must be first in composed message" - ); - assert!( - full.contains("[Base]"), - "base must be present in composed message" - ); - assert!( - full.ends_with("do the thing"), - "body must be last in composed message" - ); - // Order: canvas → base → body - let canvas_pos = full.find("[Channel Canvas]").unwrap(); - let base_pos = full.find("[Base]").unwrap(); - let body_pos = full.find("do the thing").unwrap(); - assert!( - canvas_pos < base_pos && base_pos < body_pos, - "order must be: canvas → base → body" - ); + fn test_initial_message_legacy_agent_without_standing_is_unchanged() { + // Nothing configured: body passes through with no stray blank lines. + let composed = + prepend_standing_for_legacy(1, &crate::queue::StandingContext::default(), "do it"); + assert_eq!(composed, "do it"); } // Pin the session/new systemPrompt framing: each present prompt carries its @@ -5149,6 +5403,7 @@ mod tests { }; let context = ConversationContext::Thread { messages: vec![ContextMessage { + event_id: String::new(), pubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), timestamp: "2026-03-25T05:51:25Z".into(), content: "follow up".into(), @@ -5222,6 +5477,635 @@ mod tests { assert!(parse_kind0_profile_lookup(json!({})).is_none()); } + fn context_message(event_id: &str, content: &str) -> ContextMessage { + ContextMessage { + event_id: event_id.to_string(), + pubkey: "author".into(), + timestamp: "2026-08-09T00:00:00Z".into(), + content: content.into(), + } + } + + #[tokio::test] + async fn run_prompt_task_commits_standing_context_only_after_acp_success() { + let capture = std::env::temp_dir().join(format!( + "buzz-acp-standing-lifecycle-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"count=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> '{quoted_capture}' + count=$((count + 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"error":{{"code":-32000,"message":"retry me"}}}}' + else + printf '%s\n' "{{\"jsonrpc\":\"2.0\",\"id\":$((count - 1)),\"result\":{{\"stopReason\":\"end_turn\"}}}}" + fi +done"# + ); + let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn lifecycle ACP script"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent.state.heartbeat_session = Some("live-session".into()); + + let mut ctx = make_prompt_context_no_owner(); + ctx.base_prompt = Some("standing-once"); + let ctx = Arc::new(ctx); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + for turn in 1..=3 { + run_prompt_task( + agent, + None, + Some(format!("heartbeat-{turn}")), + Arc::clone(&ctx), + result_tx.clone(), + None, + format!("turn-{turn}"), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + match turn { + 1 => assert!(matches!(result.outcome, PromptOutcome::Error(_))), + _ => assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )), + } + assert_eq!( + result.agent.state.heartbeat_standing_context_sent, + turn >= 2, + "failed first delivery must not commit; first success must commit" + ); + agent = result.agent; + } + agent.acp.shutdown().await; + + let requests: Vec = std::fs::read_to_string(&capture) + .expect("read captured ACP requests") + .lines() + .map(|line| serde_json::from_str(line).expect("captured request is JSON")) + .collect(); + std::fs::remove_file(&capture).expect("remove ACP capture"); + assert_eq!(requests.len(), 3); + let prompt_text = |index: usize| { + requests[index]["params"]["prompt"][0]["text"] + .as_str() + .expect("text prompt") + }; + assert_eq!(prompt_text(0), "[Base]\nstanding-once\n\nheartbeat-1"); + assert_eq!( + prompt_text(1), + "[Base]\nstanding-once\n\nheartbeat-2", + "retry after ACP failure must resend standing context" + ); + assert_eq!( + prompt_text(2), + "heartbeat-3", + "turn after ACP success must omit standing context" + ); + } + + #[tokio::test] + async fn channel_prompt_commits_delivery_state_only_after_acp_success() { + let capture = std::env::temp_dir().join(format!( + "buzz-acp-channel-delivery-lifecycle-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"count=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> '{quoted_capture}' + count=$((count + 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"error":{{"code":-32000,"message":"retry me"}}}}' + else + printf '%s\n' "{{\"jsonrpc\":\"2.0\",\"id\":$((count - 1)),\"result\":{{\"stopReason\":\"end_turn\"}}}}" + fi +done"# + ); + let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn channel lifecycle ACP script"); + let channel_id = Uuid::new_v4(); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, ChannelDeliveryState::default()); + + let mut ctx = make_prompt_context_no_owner(); + ctx.base_prompt = Some("standing-once"); + let ctx = Arc::new(ctx); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + for turn in 1..=3 { + let event = EventBuilder::new(Kind::Custom(9), format!("channel-{turn}")) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let event_id = event.id.to_hex(); + let batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + run_prompt_task( + agent, + Some(batch), + None, + Arc::clone(&ctx), + result_tx.clone(), + None, + format!("turn-{turn}"), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + match turn { + 1 => assert!(matches!(result.outcome, PromptOutcome::Error(_))), + _ => assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )), + } + let delivery = &result.agent.state.deliveries[&channel_id]; + assert_eq!( + delivery.standing_context_sent, + turn >= 2, + "failed channel delivery must not commit; first success must commit" + ); + assert_eq!( + delivery.delivered_event_ids.contains(&event_id), + turn >= 2, + "channel event IDs must commit only after ACP success" + ); + agent = result.agent; + } + agent.acp.shutdown().await; + + let requests: Vec = std::fs::read_to_string(&capture) + .expect("read captured ACP requests") + .lines() + .map(|line| serde_json::from_str(line).expect("captured request is JSON")) + .collect(); + std::fs::remove_file(&capture).expect("remove ACP capture"); + let prompt_text = |index: usize| { + requests[index]["params"]["prompt"][0]["text"] + .as_str() + .expect("text prompt") + }; + assert!(prompt_text(0).contains("[Base]\nstanding-once")); + assert!( + prompt_text(1).contains("[Base]\nstanding-once"), + "retry after channel ACP failure must resend standing context" + ); + assert!( + !prompt_text(2).contains("[Base]\nstanding-once"), + "turn after channel ACP success must omit standing context" + ); + } + + #[tokio::test] + async fn merged_cancel_prompt_commits_and_deduplicates_all_rendered_event_ids() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let channel_id = Uuid::new_v4(); + let keys = Keys::generate(); + let carry_over = EventBuilder::new(Kind::Custom(9), "merged carry-over sentinel") + .sign_with_keys(&keys) + .unwrap(); + let carry_over_id = carry_over.id.to_hex(); + let new_event = EventBuilder::new(Kind::Custom(9), "merged new-event sentinel") + .sign_with_keys(&keys) + .unwrap(); + let new_event_id = new_event.id.to_hex(); + let next_event = EventBuilder::new(Kind::Custom(9), "ordinary next-turn sentinel") + .sign_with_keys(&keys) + .unwrap(); + let merged_batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event: new_event.clone(), + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![crate::queue::BatchEvent { + event: carry_over.clone(), + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancel_reason: Some(crate::queue::CancelReason::Steer), + }; + let next_batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event: next_event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + // Return both merged events as DM history. They must be excluded from + // the merged prompt's context and, after success, from the next turn. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind context server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let response_body = serde_json::to_string(&vec![carry_over, new_event]).unwrap(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 16 * 1024]; + let _ = socket.read(&mut request).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), response_body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + + let capture = std::env::temp_dir().join(format!( + "buzz-acp-merged-delivery-wire-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"count=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> '{quoted_capture}' + printf '%s\n' "{{\"jsonrpc\":\"2.0\",\"id\":$count,\"result\":{{\"stopReason\":\"end_turn\"}}}}" + count=$((count + 1)) +done"# + ); + let acp = AcpClient::spawn("bash", &["-c".into(), script], &[], false) + .await + .expect("spawn wire-capture ACP"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, ChannelDeliveryState::default()); + + let mut ctx = make_prompt_context_no_owner(); + ctx.context_message_limit = 10; + ctx.rest_client.base_url = base_url.clone(); + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel_id, + crate::relay::ChannelInfo { + name: "test-dm".into(), + channel_type: "dm".into(), + }, + )]), + RestClient { + http: reqwest::Client::new(), + base_url, + keys: ctx.agent_keys.clone(), + auth_tag_json: None, + }, + ); + let ctx = Arc::new(ctx); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + for (turn_id, batch) in [("merged-turn", merged_batch), ("next-turn", next_batch)] { + run_prompt_task( + agent, + Some(batch), + None, + Arc::clone(&ctx), + result_tx.clone(), + None, + turn_id.into(), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )); + agent = result.agent; + } + let delivery = &agent.state.deliveries[&channel_id]; + assert!(delivery.delivered_event_ids.contains(&carry_over_id)); + assert!(delivery.delivered_event_ids.contains(&new_event_id)); + agent.acp.shutdown().await; + server.abort(); + + let requests: Vec = std::fs::read_to_string(&capture) + .expect("read captured prompts") + .lines() + .map(|line| serde_json::from_str(line).expect("captured prompt JSON")) + .collect(); + std::fs::remove_file(&capture).expect("remove prompt capture"); + assert_eq!(requests.len(), 2); + let wire = |index: usize| { + requests[index]["params"]["prompt"] + .as_array() + .expect("prompt blocks") + .iter() + .filter_map(|block| block["text"].as_str()) + .collect::>() + .join("\n") + }; + let merged_wire = wire(0); + assert_eq!(merged_wire.matches("merged carry-over sentinel").count(), 1); + assert_eq!(merged_wire.matches("merged new-event sentinel").count(), 1); + let next_wire = wire(1); + assert!(next_wire.contains("ordinary next-turn sentinel")); + assert!(!next_wire.contains("merged carry-over sentinel")); + assert!(!next_wire.contains("merged new-event sentinel")); + assert!(!next_wire.contains(&carry_over_id)); + assert!(!next_wire.contains(&new_event_id)); + } + + #[tokio::test] + async fn late_successful_steer_ack_excludes_event_from_next_channel_wire_prompt() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let channel_id = Uuid::new_v4(); + let keys = Keys::generate(); + let steered_event = EventBuilder::new(Kind::Custom(9), "steered context must not replay") + .sign_with_keys(&keys) + .unwrap(); + let steered_event_id = steered_event.id.to_hex(); + let trigger = EventBuilder::new(Kind::Custom(9), "ordinary next turn") + .sign_with_keys(&keys) + .unwrap(); + let batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event: trigger, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + // The local REST bridge returns the already-delivered steer as DM + // history. Profile/reaction requests may also arrive; the same valid + // event array is harmless for those best-effort paths. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind context server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let response_body = serde_json::to_string(&vec![steered_event]).unwrap(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 16 * 1024]; + let _ = socket.read(&mut request).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), response_body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + + let capture = std::env::temp_dir().join(format!( + "buzz-acp-late-steer-wire-{}.ndjson", + Uuid::new_v4() + )); + let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"IFS= read -r line +printf '%s\n' "$line" > '{quoted_capture}' +printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"# + ); + let acp = AcpClient::spawn("bash", &["-c".into(), script], &[], false) + .await + .expect("spawn wire-capture ACP"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + agent + .state + .sessions + .insert(channel_id, "live-session".into()); + agent + .state + .deliveries + .insert(channel_id, ChannelDeliveryState::default()); + + // Model the adversarial ordering: the task result has already retired + // its TaskMeta and returned the agent before the successful ack arrives. + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + assert!(pool.record_successful_steer( + channel_id, + steered_event_id.clone(), + "live-session".into(), + )); + let agent = pool + .try_claim(Some(channel_id)) + .expect("claim returned agent"); + + let mut ctx = make_prompt_context_no_owner(); + ctx.context_message_limit = 10; + ctx.rest_client.base_url = base_url.clone(); + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel_id, + crate::relay::ChannelInfo { + name: "test-dm".into(), + channel_type: "dm".into(), + }, + )]), + RestClient { + http: reqwest::Client::new(), + base_url, + keys: ctx.agent_keys.clone(), + auth_tag_json: None, + }, + ); + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + run_prompt_task( + agent, + Some(batch), + None, + Arc::new(ctx), + result_tx, + None, + "next-turn".into(), + ) + .await; + let mut result = result_rx.recv().await.expect("next prompt result"); + assert!(matches!( + result.outcome, + PromptOutcome::Ok(StopReason::EndTurn) + )); + result.agent.acp.shutdown().await; + server.abort(); + + let request: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&capture).expect("read captured prompt")) + .expect("captured prompt JSON"); + std::fs::remove_file(&capture).expect("remove prompt capture"); + let wire = request["params"]["prompt"] + .as_array() + .expect("prompt blocks") + .iter() + .filter_map(|block| block["text"].as_str()) + .collect::>() + .join("\n"); + assert!(wire.contains("ordinary next turn")); + assert!(!wire.contains("steered context must not replay")); + assert!(!wire.contains(&steered_event_id)); + } + + #[test] + fn delivery_state_commits_only_when_explicitly_marked_successful() { + let channel = Uuid::new_v4(); + let mut state = SessionState::default(); + state + .deliveries + .insert(channel, ChannelDeliveryState::default()); + + // Building or attempting a prompt does not mutate delivery state. + let delivery = state.deliveries.get(&channel).unwrap(); + assert!(!delivery.standing_context_sent); + assert!(delivery.delivered_event_ids.is_empty()); + + state.mark_channel_delivery_success( + channel, + true, + ["trigger".to_string(), "context".to_string()], + ); + let delivery = state.deliveries.get(&channel).unwrap(); + assert!(delivery.standing_context_sent); + assert_eq!(delivery.delivered_event_ids.len(), 2); + } + + #[test] + fn delivery_state_is_cleared_on_rotation_and_restarts_empty() { + let channel = Uuid::new_v4(); + let mut state = SessionState::default(); + state.sessions.insert(channel, "old-session".into()); + state.mark_channel_delivery_success(channel, true, ["old-event".to_string()]); + + assert!(state.invalidate_channel(&channel)); + assert!(!state.deliveries.contains_key(&channel)); + + state.sessions.insert(channel, "new-session".into()); + state + .deliveries + .insert(channel, ChannelDeliveryState::default()); + let delivery = state.deliveries.get(&channel).unwrap(); + assert!(!delivery.standing_context_sent); + assert!(delivery.delivered_event_ids.is_empty()); + } + + #[test] + fn conversation_context_delta_omits_delivered_and_triggering_events() { + let delivered = HashSet::from(["old".to_string()]); + let triggering = HashSet::from(["trigger".to_string()]); + let context = ConversationContext::Thread { + messages: vec![ + context_message("old", "already sent"), + context_message("trigger", "rendered as trigger"), + context_message("new", "new context"), + ], + total: 3, + truncated: false, + }; + + let delta = conversation_context_delta(Some(context), &delivered, &triggering) + .expect("new context remains"); + match delta { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].event_id, "new"); + assert_eq!(total, 3); + assert!(!truncated); + } + _ => panic!("expected thread context"), + } + } + + #[test] + fn conversation_context_delta_returns_none_when_no_new_events_remain() { + let delivered = HashSet::from(["old".to_string()]); + let context = ConversationContext::Dm { + messages: vec![context_message("old", "already sent")], + total: 1, + truncated: false, + }; + + assert!(conversation_context_delta(Some(context), &delivered, &HashSet::new()).is_none()); + } + + #[test] + fn conversation_context_delta_preserves_unidentified_legacy_messages() { + let context = ConversationContext::Dm { + messages: vec![context_message("", "cannot safely deduplicate")], + total: 1, + truncated: false, + }; + + assert!( + conversation_context_delta(Some(context), &HashSet::new(), &HashSet::new()).is_some() + ); + } + #[test] fn test_json_to_context_message_missing_pubkey_uses_default() { let obj = json!({ "content": "hello" }); @@ -5274,8 +6158,23 @@ mod tests { s.turn_counts.insert(ch_b, 3); s.core_sections.insert(ch_a, "core-a".into()); s.core_sections.insert(ch_b, "core-b".into()); + s.deliveries.insert( + ch_a, + ChannelDeliveryState { + standing_context_sent: true, + delivered_event_ids: HashSet::from(["event-a".into()]), + }, + ); + s.deliveries.insert( + ch_b, + ChannelDeliveryState { + standing_context_sent: true, + delivered_event_ids: HashSet::from(["event-b".into()]), + }, + ); s.heartbeat_session = Some("sess-hb".into()); s.heartbeat_turn_count = 7; + s.heartbeat_standing_context_sent = true; (s, ch_a, ch_b) } @@ -5341,6 +6240,7 @@ mod tests { assert!(s.heartbeat_session.is_none()); assert_eq!(s.heartbeat_turn_count, 0); + assert!(!s.heartbeat_standing_context_sent); // channels untouched assert_eq!(s.sessions.len(), 2); assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); @@ -5359,6 +6259,7 @@ mod tests { assert!(s.core_sections.is_empty()); assert!(s.heartbeat_session.is_none()); assert_eq!(s.heartbeat_turn_count, 0); + assert!(!s.heartbeat_standing_context_sent); } #[test] @@ -6162,12 +7063,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 100, - cumulative_output_tokens: 50, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(100), + cumulative_output_tokens: Some(50), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // owner_pubkey = None → early return, no panic. publish_agent_turn_metric( @@ -6198,12 +7102,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: Some(0.001), turn_cache_read_tokens: None, - cumulative_input_tokens: 200, - cumulative_output_tokens: 80, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(200), + cumulative_output_tokens: Some(80), cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // Will try to publish and fail (no real relay) but must not panic. publish_agent_turn_metric( @@ -6235,12 +7142,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 150, - cumulative_output_tokens: 70, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(150), + cumulative_output_tokens: Some(70), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. publish_agent_turn_metric( @@ -6272,12 +7182,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 400, - cumulative_output_tokens: 100, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(400), + cumulative_output_tokens: Some(100), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. publish_agent_turn_metric( @@ -6306,12 +7219,15 @@ mod tests { turn_total_tokens: Some(130), // genuine per-turn total turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 500, - cumulative_output_tokens: 120, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(500), + cumulative_output_tokens: Some(120), cumulative_total_tokens: Some(620), // genuine cumulative total cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); @@ -6355,12 +7271,15 @@ mod tests { turn_total_tokens: None, // provider did not supply a total turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 200, - cumulative_output_tokens: 60, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(200), + cumulative_output_tokens: Some(60), cumulative_total_tokens: None, // session has no total cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); @@ -6471,10 +7390,11 @@ mod tests { Some(5_967), "turn.cacheReadTokens must be the per-turn delta" ); - // cache_write_tokens is always None — buzz-agent doesn't emit it. + // cache_write_tokens: None in this test because the payloads don't + // include accumulatedCacheWriteTokens (Anthropic cache-read only test). assert!( turn2.cache_write_tokens.is_none(), - "cache_write_tokens must be None — not emitted by buzz-agent" + "cache_write_tokens must be None when harness omits the field" ); let cum2 = cum2.expect("cumulative always present"); diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 5c960de202..3bf1962242 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -990,6 +990,9 @@ pub enum ConversationContext { /// A single message in a conversation context section. #[derive(Debug, Clone)] pub struct ContextMessage { + /// Nostr event ID. Legacy REST fixtures may omit it, in which case it is + /// empty and cannot participate in delivery deduplication. + pub event_id: String, pub pubkey: String, pub timestamp: String, pub content: String, @@ -1241,6 +1244,7 @@ fn format_context_hints( thread_tags: &ThreadTags, is_dm: bool, has_conversation_context: bool, + conversation_context_had_delivered_events: bool, reply_anchor: Option<&str>, ) -> String { let channel_display = match channel_info { @@ -1258,6 +1262,10 @@ fn format_context_hints( "Thread context included below. Use `buzz messages thread --channel --event ` for full history if truncated." } else if has_conversation_context { "Conversation context included below. Use `buzz messages get --channel ` for full history if truncated." + } else if conversation_context_had_delivered_events && is_reply { + "Earlier thread context was already delivered in this session. Use `buzz messages thread --channel --event ` to re-read the reply chain." + } else if conversation_context_had_delivered_events { + "Earlier conversation context was already delivered in this session. Use `buzz messages get --channel ` to re-read it." } else if is_reply { "Use `buzz messages thread --channel --event ` to fetch the reply chain." } else { @@ -1285,6 +1293,8 @@ fn format_context_hints( } else if let Some(ref root) = thread_tags.root_event_id { let ctx_hint = if has_conversation_context { "Thread context included below. Use `buzz messages thread --channel --event ` for full history if truncated." + } else if conversation_context_had_delivered_events { + "Earlier thread context was already delivered in this session. Use `buzz messages thread --channel --event ` to re-read it." } else { "Use `buzz messages thread --channel --event ` to fetch thread context." }; @@ -1359,6 +1369,9 @@ pub struct FormatPromptArgs<'a> { pub agent_core: Option<&'a str>, pub channel_info: Option<&'a PromptChannelInfo>, pub conversation_context: Option<&'a ConversationContext>, + /// True when delivery-delta filtering removed at least one event that this + /// live session had already received. Trigger-only context does not set it. + pub conversation_context_had_delivered_events: bool, pub profile_lookup: Option<&'a PromptProfileLookup>, /// When true, base_prompt and system_prompt are delivered via the system /// role (session/new) and omitted from the user message. When false @@ -1374,9 +1387,62 @@ pub struct FormatPromptArgs<'a> { /// /// For modern agents (protocol_version >= 2) the section is delivered via /// the system role in session/new; omit here to avoid duplication. - /// For legacy agents it rides in the user message on every turn of the - /// session, alongside `[Base]`/`[System]`/`[Agent Memory — core]`. pub agent_canvas: Option<&'a str>, + /// Set once this session's standing context has already been delivered — + /// see [`StandingContext`]. Only meaningful for legacy agents; modern + /// agents are gated by `has_system_prompt_support` regardless. + /// + /// Defaults to `false` so a caller that never sets it behaves as if this + /// were the session's first message. + pub standing_context_sent: bool, +} + +/// The prompt sections that do not change for the life of a session: base +/// prompt, persona, team instructions, core memory, and channel canvas. +/// +/// Protocol-v2 agents receive all of this through the system role at +/// `session/new`, once. Legacy agents (`protocol_version < 2`) have no system +/// role, so it has to ride in a user message — but only in the session's +/// *first* one. Re-sending it every turn makes the standing framing the newest +/// and most-repeated text in the window, outweighing the conversation it exists +/// to frame, and evicting real channel history that much sooner. +/// +/// Both legacy dispatch paths (initial message, batch flush) render through +/// this one type so their section set and ordering cannot drift apart. +#[derive(Default)] +pub(crate) struct StandingContext<'a> { + pub base_prompt: Option<&'a str>, + pub system_prompt: Option<&'a str>, + pub team_instructions: Option<&'a str>, + pub agent_core: Option<&'a str>, + pub agent_canvas: Option<&'a str>, +} + +impl StandingContext<'_> { + /// Render the sections in the order legacy agents have always seen them. + pub(crate) fn sections(&self) -> Vec { + let mut sections = Vec::with_capacity(5); + if let Some(bp) = self.base_prompt { + sections.push(base_section(bp)); + } + if let Some(sp) = self.system_prompt { + sections.push(format!("[System]\n{sp}")); + } + if let Some(team) = self + .team_instructions + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sections.push(format!("[Team Instructions]\n{team}")); + } + if let Some(core) = self.agent_core { + sections.push(core.to_string()); + } + if let Some(canvas) = self.agent_canvas { + sections.push(canvas.to_string()); + } + sections + } } /// Format the `[Base]` section for the base prompt. @@ -1391,12 +1457,12 @@ pub(crate) fn base_section(base_prompt: &str) -> String { /// Format a [`FlushBatch`] into the per-section prompt blocks for the agent. /// /// Produces a stable prompt with these sections (in order): -/// 0. `[Base]` — base prompt (only for legacy agents without systemPrompt support) -/// 1. `[System]` — system prompt (only for legacy agents without systemPrompt support) -/// 2. `[Agent Memory — core]` — if agent core memory is set -/// 3. `[Context]` — scope, channel name, and contextual hints for the agent -/// 4. `[Thread Context]` or `[Conversation Context]` — if fetched -/// 5. `[Event]` / `[Buzz events]` — the triggering event(s) +/// 0. [`StandingContext`] — `[Base]`, `[System]`, `[Team Instructions]`, +/// `[Agent Memory — core]`, `[Channel Canvas]`. Legacy agents only, and only +/// on the session's first message (see `standing_context_sent`) +/// 1. `[Context]` — scope, channel name, and contextual hints for the agent +/// 2. `[Thread Context]` or `[Conversation Context]` — if fetched +/// 3. `[Event]` / `[Buzz events]` — the triggering event(s) /// /// Each section is returned as its own block rather than one joined string so /// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides @@ -1428,38 +1494,22 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec = Vec::with_capacity(7); - // For legacy agents (protocol_version < 2), inject base_prompt and - // system_prompt as user-message sections. Modern agents receive these - // via the system role in session/new. - if !args.has_system_prompt_support { - if let Some(bp) = args.base_prompt { - sections.push(base_section(bp)); - } - if let Some(sp) = args.system_prompt { - sections.push(format!("[System]\n{sp}")); - } - if let Some(team) = args - .team_instructions - .map(str::trim) - .filter(|value| !value.is_empty()) - { - sections.push(format!("[Team Instructions]\n{team}")); - } - } - - // NIP-AE agent core memory (rendered by `engram_fetch::build_core_section`). - // For modern agents (protocol_version >= 2), core is delivered via the - // system role in session/new, so it is omitted here to avoid duplication. - // Legacy agents have no system role, so core rides in the user message - // alongside `[Base]`/`[System]`. - if !args.has_system_prompt_support { - if let Some(core) = args.agent_core { - sections.push(core.to_string()); - } - // Channel canvas metadata — same delivery semantics as core for legacy agents. - if let Some(canvas) = args.agent_canvas { - sections.push(canvas.to_string()); - } + // Standing context — base prompt, persona, team instructions, core memory + // and canvas. Modern agents received all of it via the system role in + // session/new. Legacy agents get it here, in the session's first message + // only; `standing_context_sent` means an earlier message in this session + // already carried it. + if !args.has_system_prompt_support && !args.standing_context_sent { + sections.extend( + StandingContext { + base_prompt: args.base_prompt, + system_prompt: args.system_prompt, + team_instructions: args.team_instructions, + agent_core: args.agent_core, + agent_canvas: args.agent_canvas, + } + .sections(), + ); } // 2. Context hints (with a human-aware reply anchor). @@ -1489,6 +1539,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec), #[serde(other)] Other, } @@ -83,8 +83,19 @@ pub(crate) struct UsageUpdatePayload { #[serde(default)] #[allow(dead_code)] pub context_limit: u64, - pub accumulated_input_tokens: u64, - pub accumulated_output_tokens: u64, + /// Session-cumulative inclusive input tokens. + /// + /// `None` when buzz-agent omitted the field — this happens when the + /// session-cumulative sum overflowed `u64::MAX`. Goose always emits this + /// field, so `None` from goose is not expected; `#[serde(default)]` keeps + /// backward compatibility with any producer that omits it. + #[serde(default)] + pub accumulated_input_tokens: Option, + /// Session-cumulative output tokens. + /// + /// Same overflow-omit contract as `accumulated_input_tokens`. + #[serde(default)] + pub accumulated_output_tokens: Option, /// The cache-served subset of `accumulated_input_tokens`. /// /// `None` when the harness did not include the field (e.g. goose, which @@ -95,6 +106,15 @@ pub(crate) struct UsageUpdatePayload { /// Do NOT use `#[serde(default)]` here — that would collapse the absent /// case into `Some(0)` and destroy provenance in the append-only archive. pub accumulated_cached_input_tokens: Option, + /// The cache-written subset of `accumulated_input_tokens`. + /// + /// `None` when the harness did not include the field (e.g. goose or any + /// provider that does not report cache-write tokens). `Some(0)` when the + /// harness explicitly reported zero cache writes. Same absence-vs-zero + /// semantics as `accumulated_cached_input_tokens` above. + /// + /// Do NOT use `#[serde(default)]` here for the same reason. + pub accumulated_cache_write_tokens: Option, pub accumulated_cost: Option, /// Session-cumulative genuine provider total tokens. Optional — only /// emitted by buzz-agent when every turn in the session so far supplied a @@ -108,6 +128,15 @@ pub(crate) struct UsageUpdatePayload { /// predate this field deserialize cleanly as `None`. #[serde(default)] pub model: Option, + /// Billing identity as stamped by the publisher. Optional — absent when + /// the publisher could not prove applicability (unrecognised endpoint, + /// mixed identities within the turn, etc.). Old harnesses that do not emit + /// this field deserialise to `None` cleanly via `#[serde(default)]`. + /// + /// Do NOT use this value directly to advance the session-cumulative + /// baseline: it is per-turn only and must not persist to `SessionState`. + #[serde(default)] + pub pricing_identity: Option, } /// Per-session normalization state: the last cumulative snapshot we saw. @@ -120,9 +149,11 @@ struct SessionState { published_seq: u64, /// Cumulative input tokens at the end of the LAST PUBLISHED turn. /// Advanced only on publish (i.e. in `take()`), not on every notification. - last_input: u64, + /// `None` when the publisher omitted the field in a prior turn. + last_input: Option, /// Cumulative output tokens at the end of the LAST PUBLISHED turn. - last_output: u64, + /// `None` when the publisher omitted the field in a prior turn. + last_output: Option, /// Cumulative cost at the end of the LAST PUBLISHED turn. last_cost: Option, /// Cumulative total tokens at the end of the LAST PUBLISHED turn. @@ -135,6 +166,19 @@ struct SessionState { /// a decrease in this counter taints only the cache-read delta, not /// `delta_reliable` or the input/output deltas. last_cached_input: Option, + /// Cumulative cache-write tokens at the end of the LAST PUBLISHED turn. + /// `None` when the harness has never reported this field. Field-local: + /// a decrease taints only the cache-write delta, not `delta_reliable`. + last_cache_write: Option, + /// Sticky poison flag for the input field: set the first time ACP observes + /// an absent `accumulated_input_tokens` snapshot for this session and never + /// cleared. Once true, `delta_reliable` stays false for every subsequent + /// turn regardless of whether the publisher later resumes emitting the + /// field. ACP cannot trust the producer's permanence guarantee. + input_ever_poisoned: bool, + /// Sticky poison flag for the output field: same contract as + /// `input_ever_poisoned` but for `accumulated_output_tokens`. + output_ever_poisoned: bool, } /// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing. @@ -167,10 +211,16 @@ pub struct TurnUsage { /// a decrease here never flips `delta_reliable` or invalidates the /// input/output deltas. pub turn_cache_read_tokens: Option, + /// Per-turn cache-write token delta (`current − previous`); `None` when no + /// baseline exists, either snapshot is `None`, or the counter decreased. + /// Field-local — same contract as `turn_cache_read_tokens`. + pub turn_cache_write_tokens: Option, /// Session-cumulative input tokens as reported by goose at end of turn. - pub cumulative_input_tokens: u64, + /// `None` when the publisher omitted the field (overflow-poisoned session). + pub cumulative_input_tokens: Option, /// Session-cumulative output tokens as reported by goose at end of turn. - pub cumulative_output_tokens: u64, + /// `None` when the publisher omitted the field (overflow-poisoned session). + pub cumulative_output_tokens: Option, /// Session-cumulative genuine provider total tokens as reported by buzz-agent; /// `None` when the session has never emitted one or any turn lacked one. pub cumulative_total_tokens: Option, @@ -181,9 +231,17 @@ pub struct TurnUsage { /// any harness that omits `accumulatedCachedInputTokens`). /// `Some(0)` when the harness reported zero cache hits. pub cumulative_cache_read_tokens: Option, + /// Session-cumulative cache-write tokens as reported by buzz-agent. + /// `None` when the harness has never reported this field. + /// `Some(0)` when the harness reported zero cache writes. + pub cumulative_cache_write_tokens: Option, /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the /// harness did not include the model in its usage notification. pub model: Option, + /// Billing identity for this turn, as received from the publisher. + /// `None` when the publisher omitted it (unrecognised endpoint, mixed + /// identities, old harness). Per-turn only — not session-cumulative. + pub pricing_identity: Option, } /// Tracks per-session cumulative usage state across turns. @@ -217,6 +275,30 @@ pub(crate) struct UsageTracker { in_flight_session: Option, /// The most recently computed turn usage, ready for `take()`. pending: Option, + /// Per-in-flight-turn identity accumulator — three-state: + /// `None` = no usage notification yet (initial / after begin_turn) + /// `Some(Some(pi))` = all notifications so far carry the same proven identity + /// `Some(None)` = poisoned (mismatch, absent on a token-advancing + /// notification, or first notification had no identity) + /// + /// Folded on every in-flight `record()` call (last-update-wins is the + /// wrong contract for cumulative-snapshot notifications — a later + /// notification that carries A after an unproven/absent one must NOT + /// resurrect the identity). Reset to `None` in `begin_turn()` and `take()`. + pending_identity: Option>, + /// Per-in-flight-turn fold accumulator for input-field absence. + /// + /// Set to `true` the first time any in-flight `record()` call for the + /// current turn observes `accumulated_input_tokens: None`. Monotonically + /// grows (never cleared mid-turn); reset to `false` by `begin_turn()`. + /// At `take()` this value is OR-ed into the session's `input_ever_poisoned` + /// flag, creating or updating the session entry as needed. If `take()` is + /// never called before the next `begin_turn()`, the flush is performed at + /// `begin_turn()` time instead, so no observed absence is ever discarded. + input_absence_observed: bool, + /// Per-in-flight-turn fold accumulator for output-field absence. + /// Symmetric contract to `input_absence_observed`. + output_absence_observed: bool, } impl UsageTracker { @@ -226,9 +308,71 @@ impl UsageTracker { /// in-flight. Must be called before the corresponding `session/prompt` /// request is sent so that setup notifications received before this call /// do not become publishable for this turn. + /// + /// If the previous turn's fold accumulators hold observed absences and + /// `take()` was never called (e.g. the initial-message path calls + /// `begin_turn` twice without a `take()` between them), those absences are + /// committed here into the previous in-flight session's sticky + /// `*_ever_poisoned` state before the accumulators are reset. This + /// prevents the "take-skipped turn" escape: observed absences are + /// impossible to discard regardless of whether `take()` was called. pub(crate) fn begin_turn(&mut self, session_id: &str) { + // Flush any outstanding fold state from the previous in-flight turn + // into the previous session's entry BEFORE resetting the accumulators. + // + // This closes two discard points: + // 1. **Take-skipped same-session** — `begin_turn("s")` called twice + // without a `take()` in between (the initial-message path in + // pool.rs does exactly this). + // 2. **Cross-session** — session A's turn observed absences, then + // `begin_turn("B")` runs next. A's poison must survive. + // + // The fold accumulators can only be non-false when `in_flight_session` + // is Some, because only in-flight `record()` calls set them. The outer + // guard is a performance short-circuit (skip the map lookup on the + // common no-absence path); correctness does not depend on it. + if self.input_absence_observed || self.output_absence_observed { + if let Some(ref prev_session) = self.in_flight_session { + let key = prev_session.clone(); + let existing = self.sessions.get(key.as_str()); + let input_ever_poisoned = + existing.is_some_and(|s| s.input_ever_poisoned) || self.input_absence_observed; + let output_ever_poisoned = existing.is_some_and(|s| s.output_ever_poisoned) + || self.output_absence_observed; + let (published_seq, last_input, last_output, last_cost, last_total, lci, lcw) = + match existing { + Some(s) => ( + s.published_seq, + s.last_input, + s.last_output, + s.last_cost, + s.last_total, + s.last_cached_input, + s.last_cache_write, + ), + None => (0, None, None, None, None, None, None), + }; + self.sessions.insert( + key, + SessionState { + published_seq, + last_input, + last_output, + last_cost, + last_total, + last_cached_input: lci, + last_cache_write: lcw, + input_ever_poisoned, + output_ever_poisoned, + }, + ); + } + } self.in_flight_session = Some(session_id.to_string()); self.pending = None; + self.pending_identity = None; + self.input_absence_observed = false; + self.output_absence_observed = false; } /// Process a `usage_update` notification payload. @@ -238,7 +382,11 @@ impl UsageTracker { /// session produces a publishable `pending` record. A notification that /// arrives outside any turn (e.g. during `session/new` setup) advances the /// committed baseline so the next in-flight turn computes a correct delta. - /// A notification for a *different* in-flight session is ignored entirely. + /// A notification for a *different* in-flight session drops its counters + /// (advancing the baseline would undercount that session's next turn) but + /// latches any observed input/output absence into that session's committed + /// `*_ever_poisoned` state — the sticky-absence contract has no cross-session + /// exemption. /// /// When multiple notifications arrive during the same turn, the **last one /// wins** on the cumulative totals, and the delta is always measured from @@ -253,57 +401,115 @@ impl UsageTracker { /// 2. **Not in-flight at all** (`in_flight_session == None`): advances the /// committed baseline (setup notification path). /// 3. **In-flight for another session** (`in_flight_session == Some(other)`): - /// ignored entirely — touching this session's baseline while another is - /// in-flight would undercount this session's next published delta. + /// counters are dropped (advancing this session's baseline would undercount + /// its next published delta), but any observed input/output absence is + /// latched into this session's `*_ever_poisoned` state — the sticky-absence + /// contract has no cross-session exemption. pub(crate) fn record(&mut self, session_id: &str, payload: &UsageUpdatePayload) { let current_input = payload.accumulated_input_tokens; let current_output = payload.accumulated_output_tokens; let current_cost = payload.accumulated_cost; let current_total = payload.accumulated_total_tokens; let current_cached_input = payload.accumulated_cached_input_tokens; + let current_cache_write = payload.accumulated_cache_write_tokens; // Determine whether this session is currently in-flight so we know // whether to set `pending`. We compute the delta regardless so that // setup notifications (no in-flight turn) still advance the baseline. let is_in_flight = self.in_flight_session.as_deref() == Some(session_id); - let (delta_reliable, turn_input, turn_output, turn_cost, turn_seq) = - match self.sessions.get(session_id) { - None => { - // First notification for this session — no baseline yet. - (false, None, None, None, 1u64) - } - Some(prev) => { - // turn_seq for this pending record is one above the last - // *published* seq — constant for all notifications in this - // turn, advanced only on publish. - let seq = prev.published_seq + 1; - // Token counter decrease → unreliable delta. - if current_input < prev.last_input || current_output < prev.last_output { - (false, None, None, None, seq) - } else { - let di = current_input - prev.last_input; - let dout = current_output - prev.last_output; - // Cost delta: only when both snapshots have cost. - // A cost *decrease* is also unreliable (NIP-AM: negative - // delta ⇒ delta_reliable false, null all turn fields). - let (dc, cost_reliable) = match (current_cost, prev.last_cost) { - (Some(c), Some(p)) if c >= p => (Some(c - p), true), - (Some(_), Some(_)) => { - // Both present but current < prev — counter decreased. - (None, false) + // For in-flight notifications, fold the absence of each field into the + // per-turn accumulators BEFORE computing the delta. This ensures the + // second `record()` call in a turn sees the absence observed by the first, + // even when no session entry exists yet (un-baselined path) and even when + // the second notification reintroduces the field. The fold is monotonic + // (OR — never cleared mid-turn); it is reset by `begin_turn()` and + // committed to the session's sticky flags in `take()`. + // + // Case 3 (in-flight for another session) is handled separately in the + // `else` branch below: counters are dropped, but any observed absence is + // latched directly into that session's committed `*_ever_poisoned` state. + if is_in_flight { + if current_input.is_none() { + self.input_absence_observed = true; + } + if current_output.is_none() { + self.output_absence_observed = true; + } + } + + let (delta_reliable, turn_input, turn_output, turn_cost, turn_seq) = match self + .sessions + .get(session_id) + { + None => { + // First notification for this session — no baseline yet. + (false, None, None, None, 1u64) + } + Some(prev) => { + // turn_seq for this pending record is one above the last + // *published* seq — constant for all notifications in this + // turn, advanced only on publish. + let seq = prev.published_seq + 1; + // Sticky-poison check: if ACP ever observed an absent input + // or output snapshot for this session, delta_reliable is + // permanently false. A later reintroduced value must NOT + // heal the reliability — ACP cannot trust the producer's + // permanence guarantee; the prefix delta is irrecoverably + // unknown. + // + // Three sources of poison — all monotonic (OR): + // 1. The session's committed flag from prior turns. + // 2. The per-turn fold accumulator (captures absences seen + // earlier in THIS turn before take() commits them). + // 3. Whether THIS notification is itself absent. + let this_input_absent = current_input.is_none(); + let this_output_absent = current_output.is_none(); + let input_poisoned = + prev.input_ever_poisoned || self.input_absence_observed || this_input_absent; + let output_poisoned = + prev.output_ever_poisoned || self.output_absence_observed || this_output_absent; + if input_poisoned || output_poisoned { + (false, None, None, None, seq) + } else { + match ( + current_input, + current_output, + prev.last_input, + prev.last_output, + ) { + (Some(ci), Some(co), Some(pi), Some(po)) => { + // Token counter decrease → unreliable delta. + if ci < pi || co < po { + (false, None, None, None, seq) + } else { + let di = ci - pi; + let dout = co - po; + // Cost delta: only when both snapshots have cost. + // A cost *decrease* is also unreliable (NIP-AM: negative + // delta ⇒ delta_reliable false, null all turn fields). + let (dc, cost_reliable) = match (current_cost, prev.last_cost) { + (Some(c), Some(p)) if c >= p => (Some(c - p), true), + (Some(_), Some(_)) => { + // Both present but current < prev — counter decreased. + (None, false) + } + _ => (None, true), // absent on either side: null cost, reliable tokens + }; + if cost_reliable { + (true, Some(di), Some(dout), dc, seq) + } else { + // Cost decrease overrides the whole record to unreliable. + (false, None, None, None, seq) + } } - _ => (None, true), // absent on either side: null cost, reliable tokens - }; - if cost_reliable { - (true, Some(di), Some(dout), dc, seq) - } else { - // Cost decrease overrides the whole record to unreliable. - (false, None, None, None, seq) } + // One or both sides absent (no prior baseline) → unreliable. + _ => (false, None, None, None, seq), } } - }; + } + }; // Total-token delta: field-local — never affects `delta_reliable` or // the input/output deltas. Null when: no baseline exists, either @@ -331,9 +537,46 @@ impl UsageTracker { None => None, // no baseline yet }; + // Cache-write token delta: same field-local contract as cache-read. + let turn_cache_write = match self.sessions.get(session_id) { + Some(prev) => match (current_cache_write, prev.last_cache_write) { + (Some(cur), Some(p)) if cur >= p => Some(cur - p), + (Some(_), Some(_)) => None, // decrease → field-local taint + _ => None, // either snapshot absent → no delta + }, + None => None, // no baseline yet + }; + if is_in_flight { // In-flight-match: update pending with the latest cumulative values. // Baseline is NOT advanced here — it advances only on take(). + // + // Fold the per-notification identity into the per-turn accumulator. + // Last-update-wins is wrong for cumulative-snapshot notifications: a + // later notification that carries a proven identity A after an + // absent/unproven one must NOT resurrect the identity. + // + // Fold contract (mirrors the publisher-side `fold_pricing_identity`): + // - `None` acc (first notification): adopt whatever the payload carries. + // - `Some(Some(pi))` acc: if this notification matches exactly, keep; + // otherwise poison to `Some(None)`. + // - `Some(None)` acc (poisoned): stays poisoned, no healing. + let incoming = payload.pricing_identity.clone(); + self.pending_identity = match self.pending_identity.take() { + // First in-flight notification: adopt the payload identity. + None => Some(incoming), + // Already consistent: keep only if this notification matches exactly. + Some(Some(ref existing)) => { + if Some(existing) == incoming.as_ref() { + Some(incoming) + } else { + // Mismatch (different identity, absent, or unproven) → poison. + Some(None) + } + } + // Already poisoned: stays poisoned regardless of this notification. + poisoned @ Some(None) => poisoned, + }; self.pending = Some(TurnUsage { session_id: session_id.to_string(), turn_seq, @@ -343,18 +586,30 @@ impl UsageTracker { turn_total_tokens: turn_total, turn_cost_usd: turn_cost, turn_cache_read_tokens: turn_cache_read, + turn_cache_write_tokens: turn_cache_write, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, cumulative_cache_read_tokens: current_cached_input, + cumulative_cache_write_tokens: current_cache_write, model: payload.model.clone(), + // The folded identity is written in take() — use a placeholder + // here and replace it before returning the record. + pricing_identity: None, }); } else if self.in_flight_session.is_none() { // Not in-flight at all: advance the committed baseline so the next // in-flight turn computes its delta from this notification. // This handles setup notifications that fire during `session/new` // before the first `begin_turn`. + // + // Carry forward any existing sticky-poison flags (they only grow). + let existing = self.sessions.get(session_id); + let input_ever_poisoned = + existing.is_some_and(|s| s.input_ever_poisoned) || current_input.is_none(); + let output_ever_poisoned = + existing.is_some_and(|s| s.output_ever_poisoned) || current_output.is_none(); self.sessions.insert( session_id.to_string(), SessionState { @@ -367,12 +622,94 @@ impl UsageTracker { last_cost: current_cost, last_total: current_total, last_cached_input: current_cached_input, + last_cache_write: current_cache_write, + input_ever_poisoned, + output_ever_poisoned, }, ); + } else { + // In-flight-for-another-session — counters are dropped; absence is + // latched. Advancing X's baseline while Y is in-flight would + // undercount X's next published delta, so counters stay unchanged. + // But the sticky-absence contract has no cross-session exemption: if + // this notification is absent, that observation must survive into X's + // next in-flight turn even though the record is otherwise discarded. + let input_absent = current_input.is_none(); + let output_absent = current_output.is_none(); + if input_absent || output_absent { + let existing = self.sessions.get(session_id); + let input_ever_poisoned = + existing.is_some_and(|s| s.input_ever_poisoned) || input_absent; + let output_ever_poisoned = + existing.is_some_and(|s| s.output_ever_poisoned) || output_absent; + let (published_seq, last_input, last_output, last_cost, last_total, lci, lcw) = + match existing { + Some(s) => ( + s.published_seq, + s.last_input, + s.last_output, + s.last_cost, + s.last_total, + s.last_cached_input, + s.last_cache_write, + ), + None => (0, None, None, None, None, None, None), + }; + self.sessions.insert( + session_id.to_string(), + SessionState { + published_seq, + last_input, + last_output, + last_cost, + last_total, + last_cached_input: lci, + last_cache_write: lcw, + input_ever_poisoned, + output_ever_poisoned, + }, + ); + } } - // else: in-flight-for-another-session — ignore. A late notification - // for session X while session Y is in-flight must NOT advance X's - // committed baseline; doing so would undercount X's next published delta. + } + + /// Seed a zero baseline for a session that buzz-acp just spawned. + /// + /// When buzz-acp creates a session itself via `session/new`, the session's + /// prior token usage is zero by definition — no provider calls have been + /// made yet. Seeding a zero baseline here means the first usage + /// notification for this session will see `current − 0 == cumulative` and + /// can emit `delta_reliable: true` with `turn.* == cumulative.*`. + /// + /// This must be called **only** from the code path that issues `session/new` + /// (i.e. `create_session_and_apply_model` in `pool.rs`). It must **not** be + /// called when attaching to a pre-existing session whose prior usage is + /// genuinely unknown — that case correctly stays fail-closed with the + /// existing no-baseline behavior. + /// + /// No-op if a baseline for this session already exists (guards against + /// accidental double-seeding across session rotation). + pub(crate) fn seed_zero_baseline(&mut self, session_id: &str) { + self.sessions + .entry(session_id.to_string()) + .or_insert(SessionState { + published_seq: 0, + last_input: Some(0), + last_output: Some(0), + last_cost: Some(0.0), + // At spawn all counters are zero — seed known-zero baselines so + // the first real turn delta is computed exactly, not discarded as + // "no prior baseline". Cache values use the same argument as + // input/output: a freshly-spawned session has accumulated nothing, + // so the provider-reported cumulative IS the turn delta. + last_total: Some(0), + last_cached_input: Some(0), + last_cache_write: Some(0), + // A freshly-spawned session has no prior absence — poison flags + // start clear and are set only if a subsequent snapshot is absent. + input_ever_poisoned: false, + output_ever_poisoned: false, + }); } /// Consume and return the most recently computed turn usage record, then @@ -384,9 +721,37 @@ impl UsageTracker { #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn take(&mut self) -> Option { self.in_flight_session = None; - let record = self.pending.take()?; + // Consume the folded identity accumulator: emit the proven identity when + // every in-flight notification carried the same one; emit `None` when + // any notification was absent/unproven or they disagreed. + let folded_identity = self.pending_identity.take().and_then(|inner| inner); + // Consume and reset the per-turn fold accumulators before returning. + // These must be reset even on the None path (no pending record) so a + // subsequent begin_turn/take cycle starts clean. + let input_absence_this_turn = std::mem::replace(&mut self.input_absence_observed, false); + let output_absence_this_turn = std::mem::replace(&mut self.output_absence_observed, false); + let mut record = self.pending.take()?; + record.pricing_identity = folded_identity; // Advance the committed baseline to this published record so the // *next* turn measures its delta from here. + // + // Compute sticky-poison flags by combining three sources — all monotonic: + // 1. Any prior session-level flag (from a previous turn). + // 2. `input_absence_this_turn` / `output_absence_this_turn` — whether any + // in-flight notification this turn observed an absent field. This is + // the fold accumulator that closes the un-baselined escape: for sessions + // on the attach-to-existing path (no `seed_zero_baseline`), no session + // entry exists yet, so a `get_mut`-based latch would be a no-op — the + // fold captures the absence regardless and commits it here. + // 3. Whether the final published record's cumulative field is None (the + // last-notification check that was already present). + let existing = self.sessions.get(&record.session_id); + let input_ever_poisoned = existing.is_some_and(|s| s.input_ever_poisoned) + || input_absence_this_turn + || record.cumulative_input_tokens.is_none(); + let output_ever_poisoned = existing.is_some_and(|s| s.output_ever_poisoned) + || output_absence_this_turn + || record.cumulative_output_tokens.is_none(); self.sessions.insert( record.session_id.clone(), SessionState { @@ -396,6 +761,9 @@ impl UsageTracker { last_cost: record.cumulative_cost_usd, last_total: record.cumulative_total_tokens, last_cached_input: record.cumulative_cache_read_tokens, + last_cache_write: record.cumulative_cache_write_tokens, + input_ever_poisoned, + output_ever_poisoned, }, ); Some(record) @@ -421,7 +789,7 @@ mod tests { })) .expect("payload must deserialize"); assert_eq!(p.accumulated_cached_input_tokens, Some(5_033)); - assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens); + assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens.unwrap()); } /// goose does not send the field; its payloads must deserialize with None — @@ -463,12 +831,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, + pricing_identity: None, } } @@ -476,12 +846,14 @@ mod tests { UsageUpdatePayload { used: 0, context_limit: 0, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, + pricing_identity: None, } } @@ -548,8 +920,9 @@ mod tests { // publishing a metric, so A's next turn (2000/250) would see a delta of // only 500/100 instead of the correct 1000/150. // - // With the fixed three-way branch, the cross-session notification is - // ignored entirely and A's baseline stays at its last published state. + // With the fixed three-way branch, the cross-session notification drops + // its counters (advancing A's baseline would undercount A's next turn) + // and latches any observed absence into A's committed poison flags. let mut tracker = UsageTracker::default(); // ── Turn A1 — establish A's committed baseline at 1000/100, seq=1 ── @@ -558,7 +931,7 @@ mod tests { let a1 = tracker.take().expect("A turn 1"); assert_eq!(a1.turn_seq, 1); assert!(!a1.delta_reliable, "first turn is unreliable"); - assert_eq!(a1.cumulative_input_tokens, 1000); + assert_eq!(a1.cumulative_input_tokens, Some(1000)); // ── B is now in-flight; A late notification arrives ── tracker.begin_turn("sess-b"); @@ -591,8 +964,8 @@ mod tests { late cross-session advance (500)" ); assert_eq!(a2.turn_output_tokens, Some(150)); - assert_eq!(a2.cumulative_input_tokens, 2000); - assert_eq!(a2.cumulative_output_tokens, 250); + assert_eq!(a2.cumulative_input_tokens, Some(2000)); + assert_eq!(a2.cumulative_output_tokens, Some(250)); } // ── Delta computation: non-happy paths ───────────────────────────────── @@ -614,8 +987,8 @@ mod tests { assert!(usage.turn_output_tokens.is_none()); assert!(usage.turn_cost_usd.is_none()); // Cumulative is still populated. - assert_eq!(usage.cumulative_input_tokens, 1000); - assert_eq!(usage.cumulative_output_tokens, 200); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!(usage.cumulative_output_tokens, Some(200)); assert_eq!(usage.cumulative_cost_usd, Some(0.01)); } @@ -671,8 +1044,8 @@ mod tests { assert!(usage.turn_output_tokens.is_none()); assert!(usage.turn_cost_usd.is_none()); // Cumulative values are unaffected. - assert_eq!(usage.cumulative_input_tokens, 1500); - assert_eq!(usage.cumulative_output_tokens, 350); + assert_eq!(usage.cumulative_input_tokens, Some(1500)); + assert_eq!(usage.cumulative_output_tokens, Some(350)); assert_eq!(usage.cumulative_cost_usd, Some(0.05)); } @@ -743,8 +1116,8 @@ mod tests { // cost delta: 0.018 - 0.01 = 0.008 (floating-point; use approx check) let dc = usage.turn_cost_usd.expect("cost delta present"); assert!((dc - 0.008).abs() < 1e-9, "cost delta: {dc}"); - assert_eq!(usage.cumulative_input_tokens, 1800); - assert_eq!(usage.cumulative_output_tokens, 450); + assert_eq!(usage.cumulative_input_tokens, Some(1800)); + assert_eq!(usage.cumulative_output_tokens, Some(450)); } #[test] @@ -781,8 +1154,8 @@ mod tests { let usage = tracker.take().expect("turn 2"); // Cumulative from the last notification. - assert_eq!(usage.cumulative_input_tokens, 2000); - assert_eq!(usage.cumulative_output_tokens, 250); + assert_eq!(usage.cumulative_input_tokens, Some(2000)); + assert_eq!(usage.cumulative_output_tokens, Some(250)); // Delta is from committed baseline (1000, 100) → (2000, 250) = 1000/150. assert_eq!(usage.turn_input_tokens, Some(1000)); assert_eq!(usage.turn_output_tokens, Some(150)); @@ -819,8 +1192,8 @@ mod tests { assert_eq!(notif.session_id, "abc-123"); match notif.update { GooseSessionUpdateVariant::UsageUpdate(p) => { - assert_eq!(p.accumulated_input_tokens, 40000); - assert_eq!(p.accumulated_output_tokens, 10000); + assert_eq!(p.accumulated_input_tokens, Some(40000)); + assert_eq!(p.accumulated_output_tokens, Some(10000)); assert_eq!(p.accumulated_cost, Some(0.42)); } GooseSessionUpdateVariant::Other => panic!("expected UsageUpdate"), @@ -842,8 +1215,8 @@ mod tests { serde_json::from_value(raw).expect("deserialization"); match notif.update { GooseSessionUpdateVariant::UsageUpdate(p) => { - assert_eq!(p.accumulated_input_tokens, 500); - assert_eq!(p.accumulated_output_tokens, 100); + assert_eq!(p.accumulated_input_tokens, Some(500)); + assert_eq!(p.accumulated_output_tokens, Some(100)); assert_eq!(p.used, 0); assert_eq!(p.context_limit, 0); assert!(p.accumulated_cost.is_none()); @@ -919,7 +1292,7 @@ mod tests { } let t1 = tracker.take().expect("turn 1"); assert!(!t1.delta_reliable, "first turn: unreliable"); - assert_eq!(t1.cumulative_input_tokens, 300); + assert_eq!(t1.cumulative_input_tokens, Some(300)); // Turn 2 — delta reliable. tracker.begin_turn("buzz-s1"); @@ -974,12 +1347,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: model.map(str::to_string), + pricing_identity: None, } } @@ -1038,12 +1413,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, accumulated_cost: None, accumulated_total_tokens: total, model: None, + pricing_identity: None, } } @@ -1206,12 +1583,14 @@ mod tests { UsageUpdatePayload { used: input + output, context_limit: 200_000, - accumulated_input_tokens: input, - accumulated_output_tokens: output, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), accumulated_cached_input_tokens: cached_input, + accumulated_cache_write_tokens: None, accumulated_cost: None, accumulated_total_tokens: None, model: None, + pricing_identity: None, } } @@ -1449,12 +1828,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, - cumulative_input_tokens: 700, - cumulative_output_tokens: 200, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(700), + cumulative_output_tokens: Some(200), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, // harness did not report the field + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); @@ -1487,12 +1869,15 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: Some(300), - cumulative_input_tokens: 700, - cumulative_output_tokens: 200, + turn_cache_write_tokens: None, + cumulative_input_tokens: Some(700), + cumulative_output_tokens: Some(200), cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: Some(600), + cumulative_cache_write_tokens: None, model: None, + pricing_identity: None, }; let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); @@ -1511,4 +1896,1442 @@ mod tests { "nonzero cumulative cache: must appear in kind:44200 cumulative counts" ); } + + // ── seed_zero_baseline / first-turn fix ───────────────────────────────── + + /// (a) Self-spawned session: first notification must be delta_reliable=true, + /// turn deltas equal to the cumulative values (baseline was zero). + #[test] + fn spawned_session_first_turn_is_reliable_with_zero_baseline() { + let mut tracker = UsageTracker::default(); + // Simulate what pool.rs does immediately after create_session_and_apply_model. + tracker.seed_zero_baseline("sess-spawned"); + + tracker.begin_turn("sess-spawned"); + tracker.record("sess-spawned", &payload(1000, 200, Some(0.01))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "spawned session first turn must be reliable" + ); + assert_eq!(usage.turn_seq, 1); + // Turn deltas == cumulative (baseline was zero). + assert_eq!(usage.turn_input_tokens, Some(1000)); + assert_eq!(usage.turn_output_tokens, Some(200)); + let dc = usage.turn_cost_usd.expect("cost delta present"); + assert!((dc - 0.01).abs() < 1e-9, "cost delta: {dc}"); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert_eq!(usage.cumulative_output_tokens, Some(200)); + } + + /// (b) Re-attach session (no seed): first notification must remain + /// fail-closed (delta_reliable=false, turn.*=None). + #[test] + fn reattach_session_first_turn_stays_fail_closed() { + let mut tracker = UsageTracker::default(); + // No seed_zero_baseline call — simulates re-attach to pre-existing session. + + tracker.begin_turn("sess-reattach"); + tracker.record("sess-reattach", &payload(5000, 1000, Some(0.05))); + let usage = tracker.take().expect("pending"); + + assert!( + !usage.delta_reliable, + "re-attach first turn must remain fail-closed (delta_reliable=false)" + ); + assert_eq!(usage.turn_seq, 1); + assert!( + usage.turn_input_tokens.is_none(), + "no turn delta on re-attach" + ); + assert!(usage.turn_output_tokens.is_none()); + assert!(usage.turn_cost_usd.is_none()); + // Cumulative still passes through. + assert_eq!(usage.cumulative_input_tokens, Some(5000)); + assert_eq!(usage.cumulative_output_tokens, Some(1000)); + } + + /// (c) Second turn and beyond are unaffected in both modes. + #[test] + fn second_turn_reliable_in_both_spawned_and_reattach_paths() { + // Spawned path: turn 2 must be reliable (baseline from turn 1's take()). + let mut spawned = UsageTracker::default(); + spawned.seed_zero_baseline("sess-s"); + spawned.begin_turn("sess-s"); + spawned.record("sess-s", &payload(1000, 100, None)); + let _ = spawned.take(); + + spawned.begin_turn("sess-s"); + spawned.record("sess-s", &payload(1800, 250, None)); + let t2_s = spawned.take().expect("spawned turn 2"); + assert!(t2_s.delta_reliable, "spawned path: turn 2 reliable"); + assert_eq!(t2_s.turn_seq, 2); + assert_eq!(t2_s.turn_input_tokens, Some(800)); + assert_eq!(t2_s.turn_output_tokens, Some(150)); + + // Re-attach path: turn 2 must also be reliable. + let mut reattach = UsageTracker::default(); + reattach.begin_turn("sess-r"); + reattach.record("sess-r", &payload(5000, 1000, None)); + let _ = reattach.take(); // turn 1: unreliable (no baseline), but take() seeds it + + reattach.begin_turn("sess-r"); + reattach.record("sess-r", &payload(6000, 1200, None)); + let t2_r = reattach.take().expect("reattach turn 2"); + assert!(t2_r.delta_reliable, "re-attach path: turn 2 reliable"); + assert_eq!(t2_r.turn_seq, 2); + assert_eq!(t2_r.turn_input_tokens, Some(1000)); + assert_eq!(t2_r.turn_output_tokens, Some(200)); + } + + /// (d) Wire-frame assertions on the emitted TurnUsage payload fields for + /// the spawned-session first turn (not just internal delta_reliable). + #[test] + fn spawned_session_first_turn_payload_fields_are_correct() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-wire"); + + tracker.begin_turn("sess-wire"); + tracker.record( + "sess-wire", + &UsageUpdatePayload { + used: 12345, + context_limit: 200_000, + accumulated_input_tokens: Some(10000), + accumulated_output_tokens: Some(2345), + accumulated_cached_input_tokens: Some(500), + accumulated_cache_write_tokens: None, + accumulated_cost: Some(0.042), + accumulated_total_tokens: Some(12345), + model: Some("claude-opus-4-5".to_string()), + pricing_identity: None, + }, + ); + let usage = tracker.take().expect("pending"); + + // Wire payload fields — every field checked. + assert_eq!(usage.session_id, "sess-wire"); + assert_eq!(usage.turn_seq, 1); + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, Some(10000)); + assert_eq!(usage.turn_output_tokens, Some(2345)); + // turn_total: cumulative_total(12345) - baseline_total(Some(0)) = 12345. + // seed_zero_baseline now seeds last_total = Some(0) — same known-zero + // argument as input/output: a freshly-spawned session has accumulated nothing. + assert_eq!( + usage.turn_total_tokens, + Some(12345), + "turn_total_tokens must be Some(12345) on seeded first turn (baseline = Some(0))" + ); + let dc = usage.turn_cost_usd.expect("cost delta present"); + assert!((dc - 0.042).abs() < 1e-9, "cost delta: {dc}"); + assert_eq!(usage.cumulative_input_tokens, Some(10000)); + assert_eq!(usage.cumulative_output_tokens, Some(2345)); + assert_eq!(usage.cumulative_total_tokens, Some(12345)); + assert_eq!(usage.cumulative_cost_usd, Some(0.042)); + assert_eq!(usage.model.as_deref(), Some("claude-opus-4-5")); + // Cache: baseline seeded with last_cached_input = Some(0), so first turn + // delta = snapshot(500) - baseline(0) = Some(500). + assert_eq!( + usage.turn_cache_read_tokens, + Some(500), + "turn_cache_read_tokens: seeded baseline = Some(0) → delta = Some(500)" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "cumulative_cache_read_tokens passes through from payload" + ); + } + + /// seed_zero_baseline is a no-op when a baseline already exists — guards + /// against accidental double-seeding across session rotation. + #[test] + fn seed_zero_baseline_is_noop_when_baseline_already_exists() { + let mut tracker = UsageTracker::default(); + // Establish a real baseline via turn 1. + tracker.seed_zero_baseline("sess-noop"); + tracker.begin_turn("sess-noop"); + tracker.record("sess-noop", &payload(1000, 200, None)); + let _ = tracker.take(); + + // A second seed call (e.g. a bug in pool.rs) must not reset the baseline. + tracker.seed_zero_baseline("sess-noop"); + + // Turn 2 delta must still measure from the real baseline (1000/200), not zero. + tracker.begin_turn("sess-noop"); + tracker.record("sess-noop", &payload(1500, 300, None)); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_input_tokens, + Some(500), + "baseline must not have been reset to zero by the second seed call" + ); + assert_eq!(usage.turn_output_tokens, Some(100)); + } + + // ── PricingIdentity wire threading ────────────────────────────────────── + + fn make_pricing_identity_payload( + input: u64, + output: u64, + authority: &str, + model: &str, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: Some(input), + accumulated_output_tokens: Some(output), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: Some(model.to_string()), + pricing_identity: Some(buzz_core::agent_turn_metric::PricingIdentity { + authority: authority.to_string(), + model: model.to_string(), + cache_class: None, + }), + } + } + + /// A payload with a well-formed `pricingIdentity` field must thread + /// it through to `TurnUsage.pricing_identity`. The field is per-turn + /// only (not session-cumulative) and must not affect other deltas. + #[test] + fn pricing_identity_threads_from_payload_to_turn_usage() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-pi"); + + tracker.begin_turn("sess-pi"); + tracker.record( + "sess-pi", + &make_pricing_identity_payload(1000, 200, "api.anthropic.com", "claude-opus-4-5"), + ); + let usage = tracker.take().expect("pending"); + + let pi = usage + .pricing_identity + .expect("pricing_identity must be Some"); + assert_eq!(pi.authority, "api.anthropic.com"); + assert_eq!(pi.model, "claude-opus-4-5"); + assert!(pi.cache_class.is_none()); + // Other fields must be unaffected. + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, Some(1000)); + } + + /// Old harnesses (goose, older buzz-agent) that do not emit `pricingIdentity` + /// must produce `TurnUsage.pricing_identity = None` — no default injection. + #[test] + fn old_harness_no_pricing_identity_field_yields_none() { + // Deserialize a payload with no pricingIdentity field. + let raw = serde_json::json!({ + "used": 1200, + "contextLimit": 200_000, + "accumulatedInputTokens": 1000, + "accumulatedOutputTokens": 200, + "model": "claude-opus-4", + }); + let p: UsageUpdatePayload = + serde_json::from_value(raw).expect("must deserialize without pricingIdentity field"); + assert!( + p.pricing_identity.is_none(), + "old harness compat: absent field must deserialize to None, not inject a default" + ); + + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-old"); + tracker.begin_turn("sess-old"); + tracker.record("sess-old", &p); + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "old harness: pricing_identity must be None in TurnUsage" + ); + } + + /// `pricingIdentity` in the JSON wire format uses camelCase keys as + /// required by the NIP-AM wire contract (`#[serde(rename_all = "camelCase")]`). + #[test] + fn pricing_identity_deserializes_from_camel_case_wire_key() { + let raw = serde_json::json!({ + "used": 1500, + "contextLimit": 0, + "accumulatedInputTokens": 1200, + "accumulatedOutputTokens": 300, + "pricingIdentity": { + "authority": "api.openai.com", + "model": "gpt-4o", + } + }); + let p: UsageUpdatePayload = serde_json::from_value(raw).expect("payload must deserialize"); + let pi = p.pricing_identity.expect("pricingIdentity must parse"); + assert_eq!(pi.authority, "api.openai.com"); + assert_eq!(pi.model, "gpt-4o"); + assert!(pi.cache_class.is_none()); + } + + /// `pricingIdentity` with a `cacheClass` field threads through correctly. + #[test] + fn pricing_identity_cache_class_threads_through() { + let raw = serde_json::json!({ + "used": 800, + "contextLimit": 0, + "accumulatedInputTokens": 600, + "accumulatedOutputTokens": 200, + "pricingIdentity": { + "authority": "api.anthropic.com", + "model": "claude-3-5-haiku", + "cacheClass": "ephemeral", + } + }); + let p: UsageUpdatePayload = serde_json::from_value(raw).expect("payload must deserialize"); + let pi = p.pricing_identity.expect("pricingIdentity must parse"); + assert_eq!(pi.cache_class.as_deref(), Some("ephemeral")); + } + + /// `pricing_identity` is per-turn only — it must NOT be stored in or + /// influence the session-cumulative baseline (`SessionState`). A second + /// turn must still carry its own `pricing_identity` from the latest payload. + #[test] + fn pricing_identity_is_not_session_cumulative() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-perturn"); + + // Turn 1: identity present. + tracker.begin_turn("sess-perturn"); + tracker.record( + "sess-perturn", + &make_pricing_identity_payload(1000, 200, "api.anthropic.com", "claude-opus-4-5"), + ); + let t1 = tracker.take().expect("turn 1"); + assert!( + t1.pricing_identity.is_some(), + "turn 1 must carry pricing_identity" + ); + + // Turn 2: no identity in payload. + tracker.begin_turn("sess-perturn"); + tracker.record("sess-perturn", &payload(1500, 300, None)); + let t2 = tracker.take().expect("turn 2"); + assert!( + t2.pricing_identity.is_none(), + "turn 2 must NOT inherit turn 1's pricing_identity" + ); + } + + // ── First-turn cache deltas via seed_zero_baseline ─────────────────────── + // + // When `seed_zero_baseline` is called before the first turn (the normal path + // for freshly-spawned sessions), the zero-seeded baselines mean the first + // snapshot's cumulative values equal the per-turn deltas — no data is lost. + + #[test] + fn seed_zero_baseline_first_turn_cache_read_and_write_produce_exact_deltas() { + // A freshly-spawned session seeds last_cached_input = Some(0) and + // last_cache_write = Some(0). The first turn's snapshot values ARE the + // deltas; both categories must surface as exact non-None values. + let mut tracker = UsageTracker::default(); + + // Simulate session spawn: seed before the first turn. + tracker.seed_zero_baseline("sess-seed1"); + tracker.begin_turn("sess-seed1"); + + // First snapshot: cache-read = 500, cache-write = 120. + let payload = UsageUpdatePayload { + used: 1200, + context_limit: 200_000, + accumulated_input_tokens: Some(1000), + accumulated_output_tokens: Some(200), + accumulated_cached_input_tokens: Some(500), + accumulated_cache_write_tokens: Some(120), + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-seed1", &payload); + let usage = tracker + .take() + .expect("first seeded turn must produce a record"); + + // Input/output deltas are always reliable on the seeded path. + assert!( + usage.delta_reliable, + "seeded first turn must be delta_reliable" + ); + assert_eq!( + usage.turn_cache_read_tokens, + Some(500), + "seeded first turn: cache-read delta must equal the snapshot value" + ); + assert_eq!( + usage.turn_cache_write_tokens, + Some(120), + "seeded first turn: cache-write delta must equal the snapshot value" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "seeded first turn: cumulative cache-read must pass through" + ); + assert_eq!( + usage.cumulative_cache_write_tokens, + Some(120), + "seeded first turn: cumulative cache-write must pass through" + ); + } + + // ── ACP identity fold across multiple notifications ────────────────────── + // + // The publisher fold (agent.rs `fold_pricing_identity`) covers the case where + // a single cumulative snapshot has no provable identity within the agent loop. + // The ACP tracker has a DISTINCT multi-notification path: buzz-agent sends + // multiple `usage_update` notifications per turn (one per round), and the ACP + // tracker must fold identity across those notifications — not last-update-wins. + // + // Three acceptance tests per the dispatch contract (Paul event 4ad5390e): + + fn pi_payload(input: u64, output: u64, authority: &str, model: &str) -> UsageUpdatePayload { + make_pricing_identity_payload(input, output, authority, model) + } + + fn no_identity_payload(input: u64, output: u64) -> UsageUpdatePayload { + payload(input, output, None) + } + + /// ACP identity fold — case A→B: two notifications with different proven + /// identities in one turn must poison; published `AgentTurnMetricPayload` + /// (i.e. `TurnUsage.pricing_identity`) must be absent. + #[test] + fn acp_identity_fold_different_identities_poisons() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-acp-ab"); + tracker.begin_turn("sess-acp-ab"); + + // Notification 1: identity A (anthropic / claude-opus). + tracker.record( + "sess-acp-ab", + &pi_payload(1000, 200, "api.anthropic.com", "claude-opus-4-5"), + ); + // Notification 2: identity B (openai / gpt-4o). + tracker.record( + "sess-acp-ab", + &pi_payload(2000, 400, "api.openai.com", "gpt-4o"), + ); + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "A→B in one turn: pricing_identity must be absent (poisoned by mismatch)" + ); + } + + /// ACP identity fold — case A→absent: proven identity followed by a + /// notification with no identity must poison; published identity must be absent. + #[test] + fn acp_identity_fold_absent_notification_poisons() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-acp-aabs"); + tracker.begin_turn("sess-acp-aabs"); + + // Notification 1: identity A. + tracker.record( + "sess-acp-aabs", + &pi_payload(1000, 200, "api.anthropic.com", "claude-3-5-haiku"), + ); + // Notification 2: no identity (e.g. unpairable cumulative snapshot). + tracker.record("sess-acp-aabs", &no_identity_payload(2000, 400)); + + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "A→absent in one turn: pricing_identity must be absent (poisoned by missing identity)" + ); + } + + /// ACP identity fold — case A→absent→A: proven identity, then an absent + /// notification, then the original identity again — must NOT heal; + /// published identity must still be absent. + #[test] + fn acp_identity_fold_never_heals_after_poison() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-acp-heal"); + tracker.begin_turn("sess-acp-heal"); + + // Notification 1: identity A. + tracker.record( + "sess-acp-heal", + &pi_payload(1000, 200, "api.openai.com", "gpt-4o"), + ); + // Notification 2: absent identity — poisons. + tracker.record("sess-acp-heal", &no_identity_payload(2000, 400)); + // Notification 3: identity A again — must NOT resurrect it. + tracker.record( + "sess-acp-heal", + &pi_payload(3000, 600, "api.openai.com", "gpt-4o"), + ); + + let usage = tracker.take().expect("pending"); + + assert!( + usage.pricing_identity.is_none(), + "A→absent→A in one turn: pricing_identity must remain absent (no healing after poison)" + ); + } + + // ── Overflow-poison ACP consumer tests ─────────────────────────────────── + + /// A payload with absent `accumulatedInputTokens` (publisher overflow-poisoned) + /// must produce `delta_reliable: false`, null turn fields, and null cumulative + /// input/output in `TurnUsage`. + #[test] + fn absent_input_tokens_produces_unreliable_delta_and_null_cumulative() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-poison-in"); + tracker.begin_turn("sess-poison-in"); + + // Publisher omitted accumulatedInputTokens (overflow-poisoned). + let p = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, // overflow-poisoned + accumulated_output_tokens: Some(200), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-in", &p); + let usage = tracker.take().expect("pending"); + + assert!( + !usage.delta_reliable, + "absent input: delta_reliable must be false" + ); + assert!( + usage.turn_input_tokens.is_none(), + "absent input: turn_input_tokens must be None" + ); + assert!( + usage.turn_output_tokens.is_none(), + "absent input: turn_output_tokens must be None" + ); + assert!( + usage.cumulative_input_tokens.is_none(), + "absent input: cumulative_input_tokens must be None" + ); + // cumulative_output_tokens passes through as-is (it's separate). + assert_eq!(usage.cumulative_output_tokens, Some(200)); + } + + /// A payload with absent `accumulatedOutputTokens` (publisher overflow-poisoned) + /// must produce `delta_reliable: false`, null turn fields, and null cumulative output. + #[test] + fn absent_output_tokens_produces_unreliable_delta_and_null_cumulative() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-poison-out"); + tracker.begin_turn("sess-poison-out"); + + let p = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: Some(1000), + accumulated_output_tokens: None, // overflow-poisoned + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-out", &p); + let usage = tracker.take().expect("pending"); + + assert!( + !usage.delta_reliable, + "absent output: delta_reliable must be false" + ); + assert!(usage.turn_input_tokens.is_none()); + assert!(usage.turn_output_tokens.is_none()); + assert_eq!(usage.cumulative_input_tokens, Some(1000)); + assert!( + usage.cumulative_output_tokens.is_none(), + "absent output: cumulative_output_tokens must be None" + ); + } + + /// A goose-shaped payload with both input and output present must produce + /// the same behavior as before — delta_reliable true on seeded sessions, + /// cumulative values passed through exactly. + #[test] + fn goose_shaped_payload_both_present_unchanged_behavior() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-goose"); + tracker.begin_turn("sess-goose"); + + tracker.record("sess-goose", &payload(1500, 300, None)); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "goose payload: delta_reliable must be true" + ); + assert_eq!(usage.turn_input_tokens, Some(1500)); + assert_eq!(usage.turn_output_tokens, Some(300)); + assert_eq!(usage.cumulative_input_tokens, Some(1500)); + assert_eq!(usage.cumulative_output_tokens, Some(300)); + } + + /// Once a session emits a poisoned snapshot (absent fields), subsequent turns + /// stay unknown — not advancing is correct since publisher poison is permanent. + #[test] + fn poison_mid_session_subsequent_turns_stay_unknown() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-poison-mid"); + + // Turn 1: normal. + tracker.begin_turn("sess-poison-mid"); + tracker.record("sess-poison-mid", &payload(1000, 200, None)); + let t1 = tracker.take().expect("t1"); + assert!(t1.delta_reliable); + assert_eq!(t1.cumulative_input_tokens, Some(1000)); + + // Turn 2: overflow-poisoned (publisher omits input). + tracker.begin_turn("sess-poison-mid"); + let poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, + accumulated_output_tokens: Some(500), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-mid", &poisoned); + let t2 = tracker.take().expect("t2"); + assert!(!t2.delta_reliable, "poisoned turn: delta_reliable false"); + assert!(t2.cumulative_input_tokens.is_none()); + + // Turn 3: subsequent snapshot also absent → still unreliable. + tracker.begin_turn("sess-poison-mid"); + let also_poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, + accumulated_output_tokens: Some(700), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-poison-mid", &also_poisoned); + let t3 = tracker.take().expect("t3"); + assert!( + !t3.delta_reliable, + "turn after poison: delta_reliable still false" + ); + assert!( + t3.cumulative_input_tokens.is_none(), + "turn after poison: cumulative_input_tokens stays None" + ); + } + + /// Wes's P1 reproducer: once ACP has observed an absent input cumulative, + /// a later turn that resumes emitting the field must NOT heal + /// `delta_reliable`. The prefix delta is irrecoverably unknown; sticky + /// poison persists for the rest of the session. + #[test] + fn sticky_poison_input_absent_then_present_stays_unreliable() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-sticky-input"); + + // Turn 1: normal — establishes a baseline. + tracker.begin_turn("sess-sticky-input"); + tracker.record("sess-sticky-input", &payload(500, 100, None)); + let t1 = tracker.take().expect("t1"); + assert!(t1.delta_reliable, "pre-poison turn must be reliable"); + + // Turn 2: publisher poisons (absent input). + tracker.begin_turn("sess-sticky-input"); + let poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: None, + accumulated_output_tokens: Some(300), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-sticky-input", &poisoned); + let t2 = tracker.take().expect("t2"); + assert!(!t2.delta_reliable, "poisoned turn must be unreliable"); + + // Turn 3: publisher resumes emitting input — but poison must be sticky. + tracker.begin_turn("sess-sticky-input"); + tracker.record("sess-sticky-input", &payload(100, 400, None)); + let t3 = tracker.take().expect("t3"); + assert!( + !t3.delta_reliable, + "turn after absent→present must stay unreliable (sticky poison)" + ); + assert!( + t3.turn_input_tokens.is_none(), + "turn_input_tokens must be None after sticky poison" + ); + assert!( + t3.turn_output_tokens.is_none(), + "turn_output_tokens must be None after sticky poison" + ); + + // Turn 4: publisher continues emitting — poison persists. + tracker.begin_turn("sess-sticky-input"); + tracker.record("sess-sticky-input", &payload(150, 500, None)); + let t4 = tracker.take().expect("t4"); + assert!( + !t4.delta_reliable, + "delta_reliable stays false for the remainder of the session" + ); + } + + /// Symmetric to the input test: once ACP has observed an absent *output* + /// cumulative, subsequent turns that resume emitting output must NOT heal + /// `delta_reliable`. + #[test] + fn sticky_poison_output_absent_then_present_stays_unreliable() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-sticky-output"); + + // Turn 1: normal. + tracker.begin_turn("sess-sticky-output"); + tracker.record("sess-sticky-output", &payload(500, 100, None)); + let t1 = tracker.take().expect("t1"); + assert!(t1.delta_reliable); + + // Turn 2: absent output poisons the session. + tracker.begin_turn("sess-sticky-output"); + let poisoned = UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: Some(600), + accumulated_output_tokens: None, // <-- absent output + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + }; + tracker.record("sess-sticky-output", &poisoned); + let t2 = tracker.take().expect("t2"); + assert!( + !t2.delta_reliable, + "absent output must make delta unreliable" + ); + + // Turn 3: output resumes — sticky poison holds. + tracker.begin_turn("sess-sticky-output"); + tracker.record("sess-sticky-output", &payload(700, 200, None)); + let t3 = tracker.take().expect("t3"); + assert!( + !t3.delta_reliable, + "output absent→present must stay unreliable (sticky poison)" + ); + assert!(t3.turn_input_tokens.is_none()); + assert!(t3.turn_output_tokens.is_none()); + + // Turn 4: persists. + tracker.begin_turn("sess-sticky-output"); + tracker.record("sess-sticky-output", &payload(800, 250, None)); + let t4 = tracker.take().expect("t4"); + assert!( + !t4.delta_reliable, + "delta_reliable stays false for the remainder of the session" + ); + } + + /// Convenience helper: build a payload with optional input and output. + /// Used by within-turn sticky-poison tests that need to inject absence + /// mid-turn without building the full struct every time. + fn payload_opt(input: Option, output: Option) -> UsageUpdatePayload { + UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + pricing_identity: None, + } + } + + /// Rich payload for cross-session baseline-preservation tests. + /// + /// Carries all six counter fields so that `take()` commits a fully-populated + /// `SessionState` with every baseline `Some(…)` and distinct. The + /// before/after comparisons then exercise every field in the preservation + /// assertion, not just the input/output pair. + fn rich_payload( + input: Option, + output: Option, + cost: Option, + total: Option, + cached_input: Option, + cache_write: Option, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: 0, + context_limit: 0, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: cached_input, + accumulated_cache_write_tokens: cache_write, + accumulated_cost: cost, + accumulated_total_tokens: total, + model: None, + pricing_identity: None, + } + } + + /// Once ACP observes an absent *input* snapshot mid-turn, a later + /// notification in the SAME turn that reintroduces the field must NOT + /// heal `delta_reliable`. The poison must also persist to subsequent turns. + /// + /// Wes's finding: his reproducer was stated at snapshot level ("a later + /// producer snapshot reintroduces the field"), not turn level. This test + /// pins the within-turn case that the turn-boundary latch missed. + #[test] + fn within_turn_input_absent_then_present_stays_unreliable() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("wt-input"); + t.begin_turn("wt-input"); + // First notification is normal — establishes a seeded baseline turn. + t.record("wt-input", &payload_opt(Some(50), Some(10))); + let t0 = t.take().expect("t0"); + assert!(t0.delta_reliable, "pre-poison turn must be reliable"); + + t.begin_turn("wt-input"); + t.record("wt-input", &payload_opt(None, Some(10))); // poison: input absent + t.record("wt-input", &payload_opt(Some(100), Some(20))); // reintroduced + let t1 = t.take().expect("t1"); + assert!( + !t1.delta_reliable, + "within-turn absent→present must stay unreliable (input)" + ); + + t.begin_turn("wt-input"); + t.record("wt-input", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "poison must persist to next turn (input)" + ); + } + + /// Symmetric to the input case: once ACP observes an absent *output* + /// snapshot mid-turn, subsequent same-turn reintroductions and subsequent + /// turns must both stay unreliable. + #[test] + fn within_turn_output_absent_then_present_stays_unreliable() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("wt-output"); + t.begin_turn("wt-output"); + t.record("wt-output", &payload_opt(Some(50), Some(10))); + let t0 = t.take().expect("t0"); + assert!(t0.delta_reliable, "pre-poison turn must be reliable"); + + t.begin_turn("wt-output"); + t.record("wt-output", &payload_opt(Some(60), None)); // poison: output absent + t.record("wt-output", &payload_opt(Some(100), Some(20))); // reintroduced + let t1 = t.take().expect("t1"); + assert!( + !t1.delta_reliable, + "within-turn absent→present must stay unreliable (output)" + ); + + t.begin_turn("wt-output"); + t.record("wt-output", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "poison must persist to next turn (output)" + ); + } + + /// Un-baselined session (attach-to-existing path, no seed_zero_baseline): + /// an absent input snapshot observed mid-turn must poison the session even + /// though no session entry exists yet — a later reintroduced value must not + /// heal delta_reliable in the next turn. + /// + /// This is Paul's probe that FAILED at eb24590e2e — the get_mut latch was a + /// no-op for un-baselined sessions. The fold accumulator on UsageTracker + /// captures the absence and commits it at take() regardless of whether a + /// session entry already exists. + #[test] + fn unbaselined_within_turn_input_absence_poisons_next_turn() { + let mut t = UsageTracker::default(); + // NO seed_zero_baseline — attach-to-existing path + t.begin_turn("s"); + t.record("s", &payload_opt(None, Some(10))); // poisoned snapshot + t.record("s", &payload_opt(Some(100), Some(20))); // reintroduced same turn + let t1 = t.take().expect("t1"); + assert!(!t1.delta_reliable, "t1: no baseline — must be unreliable"); + t.begin_turn("s"); + t.record("s", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "t2: absence was observed in t1 — sticky poison must hold" + ); + } + + /// Symmetric output-field case for the un-baselined escape: + /// absent output snapshot mid-turn must poison the session and persist to + /// the next turn, even when no session entry existed at record() time. + #[test] + fn unbaselined_within_turn_output_absence_poisons_next_turn() { + let mut t = UsageTracker::default(); + // NO seed_zero_baseline — attach-to-existing path + t.begin_turn("s"); + t.record("s", &payload_opt(Some(10), None)); // poisoned snapshot: output absent + t.record("s", &payload_opt(Some(100), Some(20))); // reintroduced same turn + let t1 = t.take().expect("t1"); + assert!(!t1.delta_reliable, "t1: no baseline — must be unreliable"); + t.begin_turn("s"); + t.record("s", &payload_opt(Some(150), Some(30))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "t2: absence was observed in t1 — sticky poison must hold" + ); + } + + /// Take-skipped same-session: `begin_turn("s")` is called twice without + /// a `take()` in between (the initial-message path in pool.rs does this). + /// An absence observed in the skipped turn must NOT be discarded — the + /// next real turn must stay unreliable. + /// + /// This is Paul's probe that FAILED at 762e47bd31. The fold accumulators + /// were only committed in `take()`, so a skipped `take()` silently dropped + /// the observed absence. The fix flushes in `begin_turn()` instead. + #[test] + fn take_skipped_turn_input_absence_survives_to_next_turn() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("s"); + t.begin_turn("s"); + t.record("s", &payload_opt(None, Some(10))); // absence observed in init turn + // NO take() — init-message path goes straight to the next begin_turn + t.begin_turn("s"); + t.record("s", &payload_opt(Some(100), Some(20))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "absence must survive a skipped take() (input)" + ); + } + + /// Symmetric output-field case for the take-skipped escape. + #[test] + fn take_skipped_turn_output_absence_survives_to_next_turn() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("s"); + t.begin_turn("s"); + t.record("s", &payload_opt(Some(10), None)); // absence observed in init turn: output absent + // NO take() — init-message path goes straight to the next begin_turn + t.begin_turn("s"); + t.record("s", &payload_opt(Some(100), Some(20))); + let t2 = t.take().expect("t2"); + assert!( + !t2.delta_reliable, + "absence must survive a skipped take() (output)" + ); + } + + /// Cross-session take-skipped: session A's turn observed an absence, then + /// `begin_turn("B")` runs next (no take() for A). A's poison must survive + /// — when A is next in-flight its delta must still be unreliable. + #[test] + fn cross_session_take_skipped_input_absence_survives() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + // Session A's turn: observe absence (no take) + t.begin_turn("a"); + t.record("a", &payload_opt(None, Some(10))); // input absence observed for A + // Session B starts — no take() for A + t.begin_turn("b"); + t.record("b", &payload_opt(Some(50), Some(5))); + let tb = t.take().expect("tb"); + assert!(tb.delta_reliable, "session B must still be reliable"); + // Session A resumes — poison must hold + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let ta = t.take().expect("ta"); + assert!( + !ta.delta_reliable, + "session A: absence observed before cross-session begin_turn must hold" + ); + } + + /// Symmetric output-field cross-session case. + #[test] + fn cross_session_take_skipped_output_absence_survives() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + // Session A's turn: observe output absence (no take) + t.begin_turn("a"); + t.record("a", &payload_opt(Some(10), None)); // output absence observed for A + // Session B starts — no take() for A + t.begin_turn("b"); + t.record("b", &payload_opt(Some(50), Some(5))); + let tb = t.take().expect("tb"); + assert!(tb.delta_reliable, "session B must still be reliable"); + // Session A resumes — poison must hold + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let ta = t.take().expect("ta"); + assert!( + !ta.delta_reliable, + "session A: output absence observed before cross-session begin_turn must hold" + ); + } + + /// Wes's reproducer (round-5 review): a cross-session absent notification + /// arrives while a different session is in-flight. The absence must latch + /// into the notified session's `*_ever_poisoned` state even though the + /// notification's counters are otherwise dropped. + /// + /// Round-7 upgrade: rich fixture with all six baselines populated and + /// distinct so every "must not change" assertion is discriminating. The + /// cross-session payload carries different values for every present counter + /// (including non-target output) so advance-to-current corruption is also + /// visible. Pre-flag false, post-flag true is explicitly asserted. + /// + /// Scenario: + /// - A publishes a reliable turn with full counters (turn 1). + /// - B becomes in-flight. + /// - A late A notification: input=None, all other counters present but + /// with different values from A's committed baseline. + /// - B's turn publishes normally (must be unaffected). + /// - A's turn 2 must be `!delta_reliable`. + #[test] + fn cross_session_absent_notification_latches_poison_input() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + + // ── A turn 1: commit a fully-populated baseline with all six fields ── + // + // Values chosen to be distinct so every baseline field is discriminating: + // last_input=50, last_output=10, last_cost=1.5, last_total=70, + // last_cached_input=5, last_cache_write=3. + t.begin_turn("a"); + t.record( + "a", + &rich_payload(Some(50), Some(10), Some(1.5), Some(70), Some(5), Some(3)), + ); + let a1 = t.take().expect("a1"); + assert!(a1.delta_reliable, "A turn 1 must be reliable"); + + // ── Snapshot A's SessionState BEFORE the cross-session record ── + let state_before = t + .sessions + .get("a") + .expect("A entry must exist after turn 1") + .clone(); + // Sanity: all six baselines are populated and have the expected values. + assert_eq!(state_before.last_input, Some(50)); + assert_eq!(state_before.last_output, Some(10)); + assert_eq!(state_before.last_cost, Some(1.5)); + assert_eq!(state_before.last_total, Some(70)); + assert_eq!(state_before.last_cached_input, Some(5)); + assert_eq!(state_before.last_cache_write, Some(3)); + // Pre-flag: input_ever_poisoned must be false before the latch. + assert!( + !state_before.input_ever_poisoned, + "input_ever_poisoned must be false before the cross-session record" + ); + + // ── B in-flight; late A notification: input absent, all other fields + // present with DIFFERENT values from A's committed baseline ── + // (output=20, cost=2.5, total=90, cached_input=8, cache_write=6) + t.begin_turn("b"); + t.record( + "a", + &rich_payload(None, Some(20), Some(2.5), Some(90), Some(8), Some(6)), + ); + + // ── Snapshot A's SessionState AFTER the cross-session record ── + let state_after = t + .sessions + .get("a") + .expect("A entry must still exist") + .clone(); + + // Every non-poison field must be byte-for-byte unchanged. + assert_eq!( + state_after.published_seq, state_before.published_seq, + "published_seq must not be advanced by a dropped cross-session notification" + ); + assert_eq!( + state_after.last_input, state_before.last_input, + "last_input baseline must not change" + ); + assert_eq!( + state_after.last_output, state_before.last_output, + "last_output baseline must not change" + ); + assert_eq!( + state_after.last_cost, state_before.last_cost, + "last_cost baseline must not change" + ); + assert_eq!( + state_after.last_total, state_before.last_total, + "last_total baseline must not change" + ); + assert_eq!( + state_after.last_cached_input, state_before.last_cached_input, + "last_cached_input baseline must not change" + ); + assert_eq!( + state_after.last_cache_write, state_before.last_cache_write, + "last_cache_write baseline must not change" + ); + // Poison: input flag grows from false (asserted above) to true. + assert!( + state_after.input_ever_poisoned, + "input_ever_poisoned must be latched by the cross-session absent notification" + ); + assert_eq!( + state_after.output_ever_poisoned, state_before.output_ever_poisoned, + "output_ever_poisoned must not change when only input is absent" + ); + + t.record( + "b", + &rich_payload( + Some(200), + Some(30), + Some(4.0), + Some(250), + Some(15), + Some(10), + ), + ); + let b1 = t.take().expect("b1"); + assert!( + b1.delta_reliable, + "B turn 1 must be unaffected by the late A notification" + ); + assert_eq!(b1.session_id, "b"); + + // ── A turn 2: record at 100/20 ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A turn 2 must be poisoned: input absence observed in dropped cross-session notification" + ); + } + + /// Symmetric output-absent case for the cross-session absence latch. + /// + /// Round-7 upgrade: same rich-fixture approach as the input variant — all + /// six baselines populated with distinct values, cross-session payload + /// carries different present counters (including non-target input) while + /// output is absent, pre-flag false → post-flag true explicitly asserted. + #[test] + fn cross_session_absent_notification_latches_poison_output() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("a"); + t.seed_zero_baseline("b"); + + // ── A turn 1: commit a fully-populated baseline ── + // last_input=50, last_output=10, last_cost=1.5, last_total=70, + // last_cached_input=5, last_cache_write=3. + t.begin_turn("a"); + t.record( + "a", + &rich_payload(Some(50), Some(10), Some(1.5), Some(70), Some(5), Some(3)), + ); + let a1 = t.take().expect("a1"); + assert!(a1.delta_reliable, "A turn 1 must be reliable"); + + // ── Snapshot A's SessionState BEFORE the cross-session record ── + let state_before = t + .sessions + .get("a") + .expect("A entry must exist after turn 1") + .clone(); + // Sanity: all six baselines populated with expected values. + assert_eq!(state_before.last_input, Some(50)); + assert_eq!(state_before.last_output, Some(10)); + assert_eq!(state_before.last_cost, Some(1.5)); + assert_eq!(state_before.last_total, Some(70)); + assert_eq!(state_before.last_cached_input, Some(5)); + assert_eq!(state_before.last_cache_write, Some(3)); + // Pre-flag: output_ever_poisoned must be false before the latch. + assert!( + !state_before.output_ever_poisoned, + "output_ever_poisoned must be false before the cross-session record" + ); + + // ── B in-flight; late A notification: output absent, all other fields + // present with DIFFERENT values from A's committed baseline ── + // (input=80, cost=2.5, total=90, cached_input=8, cache_write=6) + t.begin_turn("b"); + t.record( + "a", + &rich_payload(Some(80), None, Some(2.5), Some(90), Some(8), Some(6)), + ); + + // ── Snapshot A's SessionState AFTER the cross-session record ── + let state_after = t + .sessions + .get("a") + .expect("A entry must still exist") + .clone(); + + // Every non-poison field must be byte-for-byte unchanged. + assert_eq!( + state_after.published_seq, state_before.published_seq, + "published_seq must not be advanced" + ); + assert_eq!( + state_after.last_input, state_before.last_input, + "last_input baseline must not change" + ); + assert_eq!( + state_after.last_output, state_before.last_output, + "last_output baseline must not change" + ); + assert_eq!( + state_after.last_cost, state_before.last_cost, + "last_cost baseline must not change" + ); + assert_eq!( + state_after.last_total, state_before.last_total, + "last_total baseline must not change" + ); + assert_eq!( + state_after.last_cached_input, state_before.last_cached_input, + "last_cached_input baseline must not change" + ); + assert_eq!( + state_after.last_cache_write, state_before.last_cache_write, + "last_cache_write baseline must not change" + ); + // Poison: output flag grows from false (asserted above) to true. + assert!( + state_after.output_ever_poisoned, + "output_ever_poisoned must be latched by the cross-session absent notification" + ); + assert_eq!( + state_after.input_ever_poisoned, state_before.input_ever_poisoned, + "input_ever_poisoned must not change when only output is absent" + ); + + t.record( + "b", + &rich_payload( + Some(200), + Some(30), + Some(4.0), + Some(250), + Some(15), + Some(10), + ), + ); + let b1 = t.take().expect("b1"); + assert!(b1.delta_reliable, "B turn 1 must be unaffected"); + assert_eq!(b1.session_id, "b"); + + // ── A turn 2 ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(100), Some(20))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A turn 2 must be poisoned: output absence observed in dropped cross-session notification" + ); + } + + /// Un-baselined variant (input-absent): A has NO session entry when the + /// cross-session absent notification arrives. The latch must CREATE an entry + /// with only `input_ever_poisoned = true` and zero-baseline fields (all six + /// `last_*` baselines remain `None`, `published_seq` = 0). The poison must + /// then survive into A's second real turn after A establishes its own baseline. + /// + /// Round-7 upgrade: the cross-session payload carries nonzero cost/total/ + /// cached-input/cache-write values (plus present non-target output) so the + /// created-entry shape assertions actually prove the latch did NOT initialize + /// baselines from the incoming payload. + /// + /// (A's first turn is unreliable regardless because it has no prior baseline; + /// the second turn is where the latch matters — without it, `take()` would + /// see `input_ever_poisoned: false` and flip `delta_reliable: true`.) + #[test] + fn cross_session_absent_notification_latches_poison_unbaselined() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("b"); + // A has NO entry at all. + assert!( + !t.sessions.contains_key("a"), + "A must have no entry before the cross-session record" + ); + + // ── B in-flight; A notification: input absent, but ALL other counter + // fields present with nonzero values ── + // output=15, cost=3.0, total=80, cached_input=7, cache_write=4. + // A has no prior entry, so the latch must CREATE one with all six + // baselines None — not initialized from these payload values. + t.begin_turn("b"); + t.record( + "a", + &rich_payload(None, Some(15), Some(3.0), Some(80), Some(7), Some(4)), + ); + + // Entry must now exist with exactly the right shape. + let created = t + .sessions + .get("a") + .expect("latch must create an entry for A"); + assert_eq!(created.published_seq, 0, "created entry has zero seq"); + assert!( + created.last_input.is_none(), + "created entry must have no input baseline" + ); + assert!( + created.last_output.is_none(), + "created entry must have no output baseline" + ); + assert!( + created.last_cost.is_none(), + "created entry must have no cost baseline" + ); + assert!( + created.last_total.is_none(), + "created entry must have no total baseline" + ); + assert!( + created.last_cached_input.is_none(), + "created entry must have no cache-read baseline" + ); + assert!( + created.last_cache_write.is_none(), + "created entry must have no cache-write baseline" + ); + assert!( + created.input_ever_poisoned, + "input_ever_poisoned must be set on the newly created entry" + ); + assert!( + !created.output_ever_poisoned, + "output_ever_poisoned must NOT be set (only input was absent)" + ); + + t.record("b", &payload_opt(Some(100), Some(20))); + let b1 = t.take().expect("b1"); + assert!(b1.delta_reliable, "B must be unaffected"); + + // ── A's first real turn (unreliable regardless — no prior baseline) ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(80), Some(15))); + let _a1 = t.take().expect("a1"); + + // ── A's second turn: with the fix, `input_ever_poisoned` was committed + // by take() above; without it, the flag would be false and delta heals. ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(150), Some(25))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A second turn must be poisoned: input absence from cross-session notification must hold even with no prior entry" + ); + } + + /// Un-baselined variant (output-absent): symmetric mirror of the input-absent + /// case above. A has no entry; a cross-session notification with output absent + /// (and all other counters present and nonzero) creates an entry with only + /// `output_ever_poisoned = true`; the poison holds through A's second real turn. + #[test] + fn cross_session_absent_notification_latches_poison_unbaselined_output() { + let mut t = UsageTracker::default(); + t.seed_zero_baseline("b"); + assert!( + !t.sessions.contains_key("a"), + "A must have no entry before the cross-session record" + ); + + // ── B in-flight; A notification: output absent, ALL other counter + // fields present with nonzero values ── + // input=15, cost=3.0, total=80, cached_input=7, cache_write=4. + // A has no prior entry, so the latch must CREATE one with all six + // baselines None — not initialized from these payload values. + t.begin_turn("b"); + t.record( + "a", + &rich_payload(Some(15), None, Some(3.0), Some(80), Some(7), Some(4)), + ); + + // Entry must now exist with exactly the right shape. + let created = t + .sessions + .get("a") + .expect("latch must create an entry for A"); + assert_eq!(created.published_seq, 0, "created entry has zero seq"); + assert!(created.last_input.is_none()); + assert!(created.last_output.is_none()); + assert!(created.last_cost.is_none()); + assert!(created.last_total.is_none()); + assert!(created.last_cached_input.is_none()); + assert!(created.last_cache_write.is_none()); + assert!( + !created.input_ever_poisoned, + "input_ever_poisoned must NOT be set (only output was absent)" + ); + assert!( + created.output_ever_poisoned, + "output_ever_poisoned must be set on the newly created entry" + ); + + t.record("b", &payload_opt(Some(100), Some(20))); + let b1 = t.take().expect("b1"); + assert!(b1.delta_reliable, "B must be unaffected"); + + // ── A first and second real turns ── + t.begin_turn("a"); + t.record("a", &payload_opt(Some(80), Some(15))); + let _a1 = t.take().expect("a1"); + + t.begin_turn("a"); + t.record("a", &payload_opt(Some(150), Some(25))); + let a2 = t.take().expect("a2"); + assert!( + !a2.delta_reliable, + "A second turn must be poisoned: output absence from cross-session notification must hold even with no prior entry" + ); + } } diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index f1a1089046..fabf75754e 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -41,6 +41,7 @@ axum = { workspace = true } base64 = "0.22" hex = { workspace = true } sha2 = { workspace = true } +url = { workspace = true } urlencoding = "2" webbrowser = "1" dirs = "6" diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index f3fbabdcda..0805fddb12 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -6,7 +6,9 @@ use tokio::task::JoinSet; use tracing::Instrument as _; use crate::builtin; -use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES}; +use crate::config::{ + pricing_authority, Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES, +}; use crate::handoff::{ContextRecovery, HandoffOutcome}; use crate::hints::SkillEntry; use crate::llm::Llm; @@ -14,8 +16,9 @@ use crate::mcp::McpRegistry; use crate::mcp::ResultBudget; use crate::types::{ - AgentError, ContentBlock, HistoryItem, ProviderStop, SessionUsageBaseline, StopReason, - ToolCall, ToolResult, ToolResultContent, TurnTotalState, + AgentError, CacheTotalState, ContentBlock, HistoryItem, PricingIdentity, ProviderStop, + SessionUsageBaseline, StopReason, ToolCall, ToolResult, ToolResultContent, TurnIOState, + TurnTotalState, }; use crate::wire::{self, WireSender}; @@ -175,16 +178,38 @@ pub struct RunCtx<'a> { /// preserved in lockstep with `last_request_input_tokens`. pub last_request_history_bytes: &'a mut Option, /// Accumulated input tokens across all LLM rounds in this turn, for - /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. - pub turn_input_tokens: &'a mut Option, + /// NIP-AM metric publishing. Reset to `Unseen` at turn start in `run()`. + pub turn_input_tokens: &'a mut TurnIOState, /// Accumulated output tokens across all LLM rounds in this turn, for - /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. - pub turn_output_tokens: &'a mut Option, + /// NIP-AM metric publishing. Reset to `Unseen` at turn start in `run()`. + pub turn_output_tokens: &'a mut TurnIOState, /// The cache-served subset of `turn_input_tokens`, accumulated across all - /// LLM rounds in this turn. Reset to `None` at turn start in `run()`. + /// LLM rounds in this turn. Reset to `Unseen` at turn start in `run()`. /// Consumers price this slice at the provider's cached rate; without it /// every round of a growing conversation is billed at full price. - pub turn_cached_input_tokens: &'a mut Option, + /// + /// `CacheTotalState` enforces the D1 rule: any usage-bearing round that + /// omits this category poisons the accumulator permanently for the turn. + pub turn_cached_input_tokens: &'a mut CacheTotalState, + /// The cache-written subset of `turn_input_tokens`, accumulated across all + /// LLM rounds in this turn. Reset to `Unseen` at turn start in `run()`. + /// Consumers need this to price cache-creation at the provider's write rate + /// (distinct from both the standard input rate and the cached-read rate). + /// + /// Same D1 tri-state contract as `turn_cached_input_tokens`. + pub turn_cache_write_tokens: &'a mut CacheTotalState, + /// Per-turn billing identity accumulator. + /// + /// - `None`: no usage-bearing response observed yet this turn (initial state). + /// - `Some(Some(pi))`: all usage-bearing responses so far carry the same + /// proven identity `pi`. If a subsequent response carries a different or + /// unproven identity, this transitions to `Some(None)` (poisoned). + /// - `Some(None)`: poisoned — mixed identities, unproven response, mesh + /// retry across models, or no identity derived. Never heals within the turn. + /// + /// Reset to `None` at turn start in `run()`. The wire payload emits the + /// proven identity when `Some(Some(pi))`, omits it otherwise. + pub turn_pricing_identity: &'a mut Option>, /// Tri-state total-token accumulator for this turn. /// /// - `Unseen`: no usage-bearing response observed yet this turn (initial state). @@ -202,6 +227,41 @@ pub struct RunCtx<'a> { pub usage_baseline: SessionUsageBaseline, } +/// Fold one round's proven identity into the per-turn identity accumulator. +/// +/// Accumulator tri-state (NIP-AM §pricingIdentity): +/// - `None`: no usage-bearing round observed yet this turn. +/// - `Some(Some(pi))`: every usage-bearing round so far carries the same +/// proven identity `pi`. +/// - `Some(None)`: poisoned — mixed identities or unproven round seen. +/// Never heals within the turn. +/// +/// `round`: `Some(pi)` when this round's `(base_url, request_model)` resolve +/// to a proven identity; `None` when the endpoint is unallowlisted or the +/// model is unknown. +#[inline] +fn fold_pricing_identity( + acc: Option>, + round: Option, +) -> Option> { + match acc { + // First usage-bearing round: record whatever was derived. + None => Some(round), + // Already consistent: keep only if this round matches exactly. + Some(Some(ref existing)) => { + if Some(existing) == round.as_ref() { + Some(round) + } else { + // Mismatch (different model, different authority, + // or this round had no proven identity) → poison. + Some(None) + } + } + // Already poisoned: stays poisoned forever this turn. + poisoned @ Some(None) => poisoned, + } +} + impl RunCtx<'_> { /// Send a session-cumulative `usage_update` reflecting everything observed /// up to and including the most recent LLM response. @@ -213,15 +273,30 @@ impl RunCtx<'_> { /// everything but its final in-flight request. async fn emit_usage_update(&self) { let base = self.usage_baseline; + // Combine session baseline CacheTotalState with the per-turn delta: + // merge_session produces Exact when both sides are Exact, Unknown when + // either is Unknown, and leaves Unseen when both sides are Unseen. + let cached_total = base + .cached_input_tokens + .merge_session(*self.turn_cached_input_tokens); + let write_total = base + .cache_write_tokens + .merge_session(*self.turn_cache_write_tokens); let payload = wire::usage_update_payload( base.input_tokens - .saturating_add(self.turn_input_tokens.unwrap_or(0)), + .merge_session(*self.turn_input_tokens) + .exact_value(), base.output_tokens - .saturating_add(self.turn_output_tokens.unwrap_or(0)), - base.cached_input_tokens - .saturating_add(self.turn_cached_input_tokens.unwrap_or(0)), + .merge_session(*self.turn_output_tokens) + .exact_value(), + cached_total.exact_value(), + write_total.exact_value(), base.total_state.merge_session(*self.turn_total_state), self.effective_model, + // Extract the proven identity if this turn is consistent so far. + self.turn_pricing_identity + .as_ref() + .and_then(|inner| inner.as_ref()), ); wire::send( self.wire, @@ -243,9 +318,11 @@ impl RunCtx<'_> { self.history.push(HistoryItem::User(user_text)); // Reset per-turn token accumulators for this prompt. - *self.turn_input_tokens = None; - *self.turn_output_tokens = None; - *self.turn_cached_input_tokens = None; + *self.turn_input_tokens = TurnIOState::Unseen; + *self.turn_output_tokens = TurnIOState::Unseen; + *self.turn_cached_input_tokens = CacheTotalState::Unseen; + *self.turn_cache_write_tokens = CacheTotalState::Unseen; + *self.turn_pricing_identity = None; *self.turn_total_state = TurnTotalState::Unseen; // Per-turn handoff-attempt counter. Scoped here (not persisted in the // session) so `BUZZ_AGENT_MAX_HANDOFFS` bounds compactions per @@ -423,7 +500,17 @@ impl RunCtx<'_> { // a response omits usage (`None`) rather than clobbering — a // one-off missing field shouldn't blind the gate or zero the // growth baseline. - if let Some(tokens) = response.input_tokens { + if response.input_tokens_overflowed { + // The Anthropic-style inclusive sum (input_tokens + + // cache_read_input_tokens + cache_creation_input_tokens) + // overflowed u64::MAX during parsing. Permanently poison the + // turn accumulator so wire emission omits this value and ACP + // marks the delta unreliable. Do NOT update + // last_request_input_tokens — freeze the context-gate + // baseline at its prior reading rather than poisoning it with + // a clamped value, exactly as the absent-usage path does. + *self.turn_input_tokens = TurnIOState::Poisoned; + } else if let Some(tokens) = response.input_tokens { *self.last_request_input_tokens = Some(tokens); *self.last_request_history_bytes = Some( self.history @@ -432,29 +519,30 @@ impl RunCtx<'_> { .sum(), ); // Accumulate per-turn input tokens for NIP-AM metric publishing. - *self.turn_input_tokens = - Some(self.turn_input_tokens.unwrap_or(0).saturating_add(tokens)); + // fold_round uses checked_add; overflow permanently poisons the + // turn accumulator (and, via merge_session, the session cumulative). + *self.turn_input_tokens = self.turn_input_tokens.fold_round(tokens); } // Accumulate per-turn output tokens for NIP-AM metric publishing. if let Some(out) = response.output_tokens { - *self.turn_output_tokens = - Some(self.turn_output_tokens.unwrap_or(0).saturating_add(out)); + *self.turn_output_tokens = self.turn_output_tokens.fold_round(out); } - // Accumulate the cache-served subset of this turn's input. Tracked - // separately from `turn_input_tokens` rather than subtracted from - // it: the input total must stay inclusive for the handoff gate, - // which cares how much context was sent, not what it cost. - if let Some(cached) = response.cached_input_tokens { - *self.turn_cached_input_tokens = Some( - self.turn_cached_input_tokens - .unwrap_or(0) - .saturating_add(cached), - ); - } - // Fold the provider-reported total into the turn tri-state, but only - // when this response was usage-bearing (had input or output tokens). - // A response with no usage at all is not evidence of a missing total - // and must not poison the accumulator. + // Fold the provider-reported total, cache subsets, and billing + // identity — only when this response was usage-bearing (had input + // or output tokens). A response with no usage at all is not + // evidence of a missing cache field or total and must not poison + // either accumulator. + // + // `input_tokens_overflowed` counts as usage-bearing: the provider + // reported an input total (which overflowed) so cache fields and + // the total are meaningful and must be folded. + // + // D1: absent cache field on a usage-bearing round permanently + // poisons the turn accumulator. Some(0) stays Exact(0) (explicit + // zero is distinct from absent). Cache-read and cache-write are + // tracked separately from `turn_input_tokens` rather than + // subtracted from it: the input total must stay inclusive for the + // handoff gate, which cares how much context was sent, not cost. // // Shape assumption: documented OpenAI-compatible responses that carry // `total_tokens` always co-report at least one of `prompt_tokens` / @@ -462,8 +550,45 @@ impl RunCtx<'_> { // with neither category is therefore not a supported shape and would // be silently ignored here. If that shape is ever encountered, extend // this gate rather than representing absent categories as zero. - if response.input_tokens.is_some() || response.output_tokens.is_some() { + if response.input_tokens.is_some() + || response.input_tokens_overflowed + || response.output_tokens.is_some() + { *self.turn_total_state = self.turn_total_state.fold(response.total_tokens); + // Cache-read: the cache-served subset of input tokens. + *self.turn_cached_input_tokens = self + .turn_cached_input_tokens + .fold(response.cached_input_tokens); + // Cache-write: the cache-creation subset of input tokens. + *self.turn_cache_write_tokens = self + .turn_cache_write_tokens + .fold(response.cache_write_tokens); + + // Derive billing identity for this round and fold it into the + // per-turn accumulator. Rules (NIP-AM §pricingIdentity): + // + // 1. Attempt to derive identity from (base_url, request_model). + // - base_url must canonically match an official allowlisted host. + // - request_model must be Some (mesh-auto with unknown model + // cannot prove identity). + // 2. Fold the round identity into the turn accumulator: + // - Unseen (None): record this round's identity (or poison if None). + // - Consistent: if it matches, keep; otherwise poison. + // - Poisoned: stays poisoned forever this turn. + { + let round_identity: Option = + response.request_model.as_deref().and_then(|model| { + pricing_authority(&self.cfg.base_url).map(|auth| PricingIdentity { + authority: auth.to_string(), + model: model.to_string(), + cache_class: None, // no cache-class derivation yet + }) + }); + + *self.turn_pricing_identity = + fold_pricing_identity(self.turn_pricing_identity.take(), round_identity); + } + // Report what the turn has burned SO FAR, before running the // next round. A turn is many provider round-trips over many // minutes, and until this point the only report was the one @@ -1378,4 +1503,99 @@ mod tests { "under budget must not evict anything" ); } + + // ── fold_pricing_identity: turn discipline ──────────────────────────────── + + fn pi(authority: &str, model: &str) -> PricingIdentity { + PricingIdentity { + authority: authority.to_string(), + model: model.to_string(), + cache_class: None, + } + } + + /// Case 1: two usage-bearing rounds with different proven identities in one + /// turn must poison the accumulator. The wire payload omits `pricingIdentity` + /// when `Some(None)`. + #[test] + fn fold_pricing_identity_mismatch_poisons() { + let round_a = Some(pi("api.anthropic.com", "claude-opus-4-5")); + let round_b = Some(pi("api.openai.com", "gpt-4o")); + + // Start: unseen. + let acc = None; + // After round A: consistent — Some(Some(claude-opus-4-5)). + let acc = fold_pricing_identity(acc, round_a); + assert!( + matches!(acc, Some(Some(_))), + "after one round must be consistent" + ); + // After round B (different authority + model): poisoned. + let acc = fold_pricing_identity(acc, round_b); + assert_eq!( + acc, + Some(None), + "different proven identities in one turn must poison" + ); + } + + /// Case 2: proven identity followed by a usage-bearing round with no proven + /// identity (request_model absent or non-allowlisted endpoint) must poison. + /// An unpaired cumulative snapshot also produces round=None (no model known) + /// and hits this same path — case 4 collapses into case 2. + #[test] + fn fold_pricing_identity_unproven_round_poisons() { + let round_a = Some(pi("api.anthropic.com", "claude-3-7-sonnet")); + let round_unproven: Option = None; // absent request_model or custom endpoint + + let acc = None; + let acc = fold_pricing_identity(acc, round_a); + assert!( + matches!(acc, Some(Some(_))), + "after one proven round must be consistent" + ); + let acc = fold_pricing_identity(acc, round_unproven); + assert_eq!( + acc, + Some(None), + "an unproven round after a proven round must poison (no-model / custom-endpoint path)" + ); + } + + /// Case 3: a poisoned accumulator must not heal, even if a later round + /// carries an identity matching the original. + #[test] + fn fold_pricing_identity_poisoned_never_heals() { + let round_a = Some(pi("api.openai.com", "gpt-4o")); + let round_unproven: Option = None; + let round_a_again = Some(pi("api.openai.com", "gpt-4o")); // identical to round_a + + let acc = None; + let acc = fold_pricing_identity(acc, round_a); + let acc = fold_pricing_identity(acc, round_unproven); // poisons + assert_eq!(acc, Some(None), "must be poisoned before heal attempt"); + let acc = fold_pricing_identity(acc, round_a_again); // must not heal + assert_eq!( + acc, + Some(None), + "poisoned accumulator must stay poisoned even when the next round matches the original identity" + ); + } + + /// Baseline: a turn where every round carries the same proven identity + /// stays consistent and emits the identity on the wire. + #[test] + fn fold_pricing_identity_consistent_rounds_stay_proven() { + let identity = pi("api.openrouter.ai", "meta-llama/llama-4-scout"); + + let acc = None; + let acc = fold_pricing_identity(acc, Some(identity.clone())); + let acc = fold_pricing_identity(acc, Some(identity.clone())); + let acc = fold_pricing_identity(acc, Some(identity.clone())); + assert_eq!( + acc, + Some(Some(identity)), + "three identical rounds must remain consistently proven" + ); + } } diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 3f43925de3..a78a499bdd 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -16,7 +16,8 @@ //! calls hit the cache and silently refresh when expired. use std::fs; -use std::path::PathBuf; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -188,15 +189,16 @@ impl PkceOAuthTokenSource { } /// Persist a token to disk and the in-memory cell. + /// + /// The cache holds both the access and refresh tokens, so the on-disk + /// file is written owner-only (`0o600` on Unix) via an atomic + /// inode-swapping rename — see [`write_private_cache`]. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { let body = serde_json::to_vec_pretty(&token) .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - // Atomic rename so a concurrent reader never sees a partial write. - let tmp = self.cache_path.with_extension("json.tmp"); - fs::write(&tmp, &body) - .map_err(|e| AgentError::Llm(format!("oauth cache write {tmp:?}: {e}")))?; - fs::rename(&tmp, &self.cache_path) - .map_err(|e| AgentError::Llm(format!("oauth cache rename: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; *state = Some(token); Ok(()) } @@ -463,11 +465,162 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { Ok(dir.join(format!("{hash}.json"))) } -fn read_cache(path: &PathBuf) -> Option { - let body = fs::read(path).ok()?; +/// Load a cached token, enforcing the owner-only invariant on load. +/// +/// Owner-only permissions are a cache *lifecycle* invariant, not just a +/// write-path property: a world-readable cache left by an older buzz-agent +/// (or any tampering) must be tightened the moment we touch it, before the +/// tokens are used — otherwise a file that never expires stays exposed until +/// some future refresh happens to rewrite it. Every load path (initial and +/// cross-process re-reads) funnels through here, so the repair covers them +/// all. Returns `None` when the cache is absent, unreadable, unparseable, or +/// cannot be secured; the caller then falls through to refresh/browser. +fn read_cache(path: &Path) -> Option { + let body = read_private_cache(path).ok()?; serde_json::from_slice(&body).ok() } +/// Open the cache, reject symlinks, tighten loose permissions to `0o600`, and +/// return its bytes. +/// +/// On Unix `O_NOFOLLOW` rejects a symlinked cache path at the kernel level +/// (no stat/open TOCTOU), and `fchmod` on the already-open handle repairs a +/// loose mode against the pinned inode rather than re-resolving the path. +/// A cache that exists but cannot be secured is an error, so the caller fails +/// closed instead of using an exposed file. +#[cfg(unix)] +fn read_private_cache(path: &Path) -> io::Result> { + use std::io::Read; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + + let meta = file.metadata()?; + if !meta.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oauth cache is not a regular file", + )); + } + // Tighten in place on the open fd if any group/other bit is set. fchmod + // targets the inode we already hold, so no attacker can swap the path + // between the check and the repair. + if meta.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + + let mut body = Vec::new(); + file.read_to_end(&mut body)?; + Ok(body) +} + +/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the +/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +#[cfg(not(unix))] +fn read_private_cache(path: &Path) -> io::Result> { + fs::read(path) +} + +/// Removes a temp file on drop unless it was already renamed away. Keeps a +/// failed/partial write from leaving a stray token file behind. +struct TmpFileGuard<'a>(&'a Path); + +impl Drop for TmpFileGuard<'_> { + fn drop(&mut self) { + let _ = fs::remove_file(self.0); + } +} + +/// A per-write-unique temp suffix so concurrent savers — sibling threads or +/// separate processes sharing `$HOME` — never collide on one temp path. +/// Falls back to a timestamp if the RNG is unavailable rather than panicking +/// mid-auth. +fn unique_suffix() -> String { + let mut bytes = [0u8; 8]; + if getrandom::fill(&mut bytes).is_ok() { + return hex::encode(bytes); + } + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +/// Write `body` to `path` as an owner-only file via an atomic rename. +/// +/// The cache holds both the refresh and access tokens, so it must never be +/// readable by other users. We create a uniquely-named temp file in the same +/// directory with owner-only protection at creation time — mode `0o600` on +/// Unix (see [`create_private_temp_file`]) — so it is never briefly +/// world/other readable, write and fsync it, then rename over the +/// destination. The rename swaps the inode/entry wholesale, so a pre-existing +/// cache file with loose permissions is *replaced* by the new private one; +/// its old mode never survives. `fs::rename` maps to +/// `MOVEFILE_REPLACE_EXISTING` on Windows, so the atomic replace holds on +/// both platforms; the Windows owner-only DACL is pending the unsafe-FFI +/// decision noted at the seam. +fn write_private_cache(path: &Path, body: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "oauth cache path has no parent directory", + ) + })?; + fs::create_dir_all(parent)?; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("oauth-cache"); + let tmp = parent.join(format!(".{file_name}.{}.tmp", unique_suffix())); + let guard = TmpFileGuard(&tmp); + + let mut f = create_private_temp_file(&tmp)?; + f.write_all(body)?; + f.sync_all()?; + drop(f); + + fs::rename(&tmp, path)?; + // The rename consumed the temp path; nothing left to clean up. + std::mem::forget(guard); + Ok(()) +} + +/// Create `tmp` for writing with owner-only permissions from the moment it +/// exists. Fails if the file already exists (`create_new`), which the +/// per-write-unique suffix makes effectively impossible. +#[cfg(unix)] +fn create_private_temp_file(tmp: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(tmp) +} + +/// Non-Unix fallback: create the temp file if it does not already exist. +/// +/// On Windows the owner-only equivalent is an explicit DACL set at creation +/// (`CreateFileW` with SDDL `D:P(A;;FA;;;OW)`, matching goose's +/// `private_file.rs`), but that FFI needs `unsafe`, which this crate forbids. +/// Reconciling the two — an isolated helper crate, a vetted safe dependency, +/// or descoping Windows — is an open decision escalated to the maintainer, so +/// this interim relies on the default per-user ACLs and drops the owner-only +/// implementation in behind this seam once the decision lands. `create_new` +/// fails if the file already exists. +#[cfg(not(unix))] +fn create_private_temp_file(tmp: &Path) -> io::Result { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(tmp) +} + /// Parse a token-endpoint JSON response. Fails loudly when `access_token` /// is missing or empty — without this, a malformed server response would /// be cached and `bearer()` would silently return `""` until the entry @@ -518,6 +671,47 @@ fn random_state() -> Result { Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) } +/// Decide the OAuth callback result and the HTML page to serve. +/// +/// Returns `(result, page)`: `result` carries the auth code (or a detail +/// string on failure) to the waiting flow via the oneshot channel; `page` is +/// the *static* HTML shown in the browser. The page never embeds any request +/// parameter — the `error` query value is attacker-influenceable, so +/// reflecting it would be an XSS sink on the localhost callback. Failure +/// detail travels only through `result`, which surfaces in the process error +/// and logs, never in the served markup. +fn callback_outcome( + params: &std::collections::HashMap, + expected_state: &str, +) -> (Result, String) { + let result = match (params.get("code"), params.get("state")) { + (Some(code), Some(st)) if st == expected_state => Ok(code.clone()), + (Some(_), Some(_)) => Err("state mismatch".to_string()), + _ => Err(params + .get("error") + .map(|e| sanitize_callback_detail(e)) + .unwrap_or_else(|| "missing code".into())), + }; + let page = match result { + Ok(_) => "

Buzz: signed in

You can close this window.

", + Err(_) => "

Buzz auth failed

You can close this window and try again.

", + } + .to_string(); + (result, page) +} + +/// Neutralize an attacker-controllable OAuth `error` value before it enters +/// an error string that later reaches the logs. Control characters (CR/LF in +/// particular) enable log-line injection, and an unbounded value could flood +/// the logs — replace control chars with spaces and cap the length. +fn sanitize_callback_detail(raw: &str) -> String { + const MAX: usize = 200; + raw.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(MAX) + .collect() +} + /// Spin up a localhost callback server, open the authorize URL in a /// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then /// exchange the code for a token. @@ -544,23 +738,11 @@ async fn browser_pkce_flow( let tx = Arc::clone(&tx); let expected = expected_state.clone(); async move { - let result = match (params.get("code"), params.get("state")) { - (Some(code), Some(st)) if st == &expected => Ok(code.clone()), - (Some(_), Some(_)) => Err("state mismatch".to_string()), - _ => Err(params - .get("error") - .cloned() - .unwrap_or_else(|| "missing code".into())), - }; + let (result, page) = callback_outcome(¶ms, &expected); if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(result.clone()); - } - match result { - Ok(_) => Html( - "

Buzz: signed in

You can close this window.

".to_string(), - ), - Err(e) => Html(format!("

Buzz auth failed

{e}
")), + let _ = sender.send(result); } + Html(page) } }), ); @@ -844,4 +1026,320 @@ mod tests { ), } } + + // ---- callback HTML must never reflect input -------------------------- + + #[test] + fn test_callback_failure_page_omits_reflected_error_param() { + // A hostile `error` query value carrying markup must not appear in + // the served HTML — otherwise the localhost callback is an XSS sink. + let payload = ""; + let mut params = std::collections::HashMap::new(); + params.insert("error".to_string(), payload.to_string()); + + let (result, page) = callback_outcome(¶ms, "expected-state"); + + // The failure detail still reaches the waiting flow via `result`... + assert_eq!(result.as_ref().err().map(String::as_str), Some(payload)); + // ...but the browser page is static and inert. + assert!( + !page.contains(payload), + "callback page reflected the raw error param: {page}" + ); + assert!( + !page.contains("