diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a832c0a0aff..59b9a73ec9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Format check @@ -123,7 +123,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Install cargo-nextest @@ -147,7 +147,7 @@ jobs: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} @@ -340,7 +340,7 @@ jobs: key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' with: workspaces: | @@ -763,7 +763,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} # Reuse the relay + git-credential-nostr built by Desktop E2E Relay @@ -952,7 +952,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: cross-${{ matrix.target }} save-if: ${{ github.event_name != 'pull_request' }} @@ -993,7 +993,7 @@ jobs: # toolchain (1.95.0 + clippy via profile = default) comes from the # repo-root rust-toolchain.toml, which the runner's preinstalled rustup # honors on demand; the host triple already is x86_64-pc-windows-msvc. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: | . @@ -1070,7 +1070,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml index cf9c8e78275..71436d00c36 100644 --- a/.github/workflows/desktop-release-cache-proof.yml +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -67,7 +67,7 @@ jobs: name: Prove Linux cache visibility if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea timeout-minutes: 15 defaults: run: diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index d8b10032b2a..cdf8fe3bda7 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -21,7 +21,7 @@ jobs: name: Build Linux canary if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea timeout-minutes: 60 permissions: contents: read diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml index 4780083ba49..b5f9660dfe3 100644 --- a/.github/workflows/mesh-lifecycle.yml +++ b/.github/workflows/mesh-lifecycle.yml @@ -55,7 +55,7 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b0eb25c688..cc8147515a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -428,7 +428,7 @@ jobs: if: github.repository == 'block/buzz' runs-on: ubuntu-latest # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. - container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea needs: setup timeout-minutes: 60 permissions: @@ -511,7 +511,7 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: desktop/src-tauri lookup-only: true diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 3920dfeb44f..784725b2e6f 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.17", - "base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", - "previous_tag": "desktop-v0.5.16", - "previous_base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386", - "previous_merge_sha": "978e585e8df893fe55aded854de07996b9412678", - "tag": "desktop-v0.5.17", - "commit_count": 5 + "version": "0.5.18", + "base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", + "previous_tag": "desktop-v0.5.17", + "previous_base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", + "previous_merge_sha": "8232299cbe6d90692fac3de46cde0ec123edd6c1", + "tag": "desktop-v0.5.18", + "commit_count": 66 } diff --git a/CHANGELOG.md b/CHANGELOG.md index bec9b9324e5..42fc482515e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## v0.5.18 + +### Desktop and shared changes + +- fix(desktop): simplify duplicate agent provenance ([#6401](https://github.com/block/buzz/pull/6401)) ([`aea0ef8df9fc24d9aa8bf5c761ab2910026a601b`](https://github.com/block/buzz/commit/aea0ef8df9fc24d9aa8bf5c761ab2910026a601b)) +- fix(desktop): sender names in notifications + macOS click-through routing ([#6427](https://github.com/block/buzz/pull/6427)) ([`4e3c9e619c93dd26677b392ad1f8cf0d12c8f855`](https://github.com/block/buzz/commit/4e3c9e619c93dd26677b392ad1f8cf0d12c8f855)) +- polish(desktop): finish Projects navigation and context chrome ([#6429](https://github.com/block/buzz/pull/6429)) ([`9b32e055fed45864e1982f3d99c5402ba35cd8a6`](https://github.com/block/buzz/commit/9b32e055fed45864e1982f3d99c5402ba35cd8a6)) +- fix(desktop): clarify add agents channel action ([#6374](https://github.com/block/buzz/pull/6374)) ([`9891e64f6b8358d78aa85f2ba248310d58b51ec0`](https://github.com/block/buzz/commit/9891e64f6b8358d78aa85f2ba248310d58b51ec0)) +- feat(desktop-messages): show compact Buzz link metadata ([#6252](https://github.com/block/buzz/pull/6252)) ([`de8a2741c7598c27e0d27cd4562d76b947934e2f`](https://github.com/block/buzz/commit/de8a2741c7598c27e0d27cd4562d76b947934e2f)) +- feat(workflows): reply in-thread from send_message action ([#6178](https://github.com/block/buzz/pull/6178)) ([`c6e3fe7dfa751096d8c4083aaf15d6f6af296572`](https://github.com/block/buzz/commit/c6e3fe7dfa751096d8c4083aaf15d6f6af296572)) +- perf(desktop): split discover_acp_providers into cheap and forced paths ([#6330](https://github.com/block/buzz/pull/6330)) ([`c63ac746cd4622e043574c305ff54021b07b847b`](https://github.com/block/buzz/commit/c63ac746cd4622e043574c305ff54021b07b847b)) +- fix(desktop): restore recent channel sorting ([#6402](https://github.com/block/buzz/pull/6402)) ([`569308c23c9c2bf620dd3a9a5e4baecbcfa22e16`](https://github.com/block/buzz/commit/569308c23c9c2bf620dd3a9a5e4baecbcfa22e16)) +- fix(desktop): isolate main timeline stacking context from focus drawer ([#6398](https://github.com/block/buzz/pull/6398)) ([`b298a175c4b9da1638f84c1f287e42d16a690a61`](https://github.com/block/buzz/commit/b298a175c4b9da1638f84c1f287e42d16a690a61)) +- fix(desktop): make reconnect repair lossless ([#6415](https://github.com/block/buzz/pull/6415)) ([`06e8be9098f099ff9036cfbe637390d5ac982809`](https://github.com/block/buzz/commit/06e8be9098f099ff9036cfbe637390d5ac982809)) +- perf(desktop): resolve references without directory scans ([#6328](https://github.com/block/buzz/pull/6328)) ([`54bbe7305b9ec82a6ac03f84ff5887f77858c0b6`](https://github.com/block/buzz/commit/54bbe7305b9ec82a6ac03f84ff5887f77858c0b6)) +- feat(llm): stamp thinking effort on call-completed log line ([#6424](https://github.com/block/buzz/pull/6424)) ([`b728a2af3197db52e2d847b095f27209f99dc977`](https://github.com/block/buzz/commit/b728a2af3197db52e2d847b095f27209f99dc977)) +- Fix cross-owner relay agent mentions in owner-only builds ([#6338](https://github.com/block/buzz/pull/6338)) ([`ee0c7076af83fe9e2aafdc1baf4113ad988f53bd`](https://github.com/block/buzz/commit/ee0c7076af83fe9e2aafdc1baf4113ad988f53bd)) +- feat(workflows): add workflow editor ([#6248](https://github.com/block/buzz/pull/6248)) ([`1934e83bf5a5d8cd00f0cf28b558547b8d0dffb0`](https://github.com/block/buzz/commit/1934e83bf5a5d8cd00f0cf28b558547b8d0dffb0)) +- fix(desktop): preserve huddle speech boundaries ([#6397](https://github.com/block/buzz/pull/6397)) ([`7ebe3ea699a24b2f95573b88db8f8fe5f1187eb4`](https://github.com/block/buzz/commit/7ebe3ea699a24b2f95573b88db8f8fe5f1187eb4)) +- test(desktop): use a wordlist-safe separator in passphrase word-count test ([#6356](https://github.com/block/buzz/pull/6356)) ([`886cef7f723a539c4026d12e6a0605062bf2208b`](https://github.com/block/buzz/commit/886cef7f723a539c4026d12e6a0605062bf2208b)) +- fix(models): curate Databricks alias-aware labels for 5 missing endpoints ([#6360](https://github.com/block/buzz/pull/6360)) ([`2ce8df8533d8c8598ab3d7a2faa797f8b5ee2eea`](https://github.com/block/buzz/commit/2ce8df8533d8c8598ab3d7a2faa797f8b5ee2eea)) +- feat(desktop): refine context-aware Projects collaboration ([#6396](https://github.com/block/buzz/pull/6396)) ([`3c228b1082a93aca302c7b6a67ec274c51ed5eaf`](https://github.com/block/buzz/commit/3c228b1082a93aca302c7b6a67ec274c51ed5eaf)) +- fix(desktop): distinguish duplicate agent devices ([#6337](https://github.com/block/buzz/pull/6337)) ([`2e7583bf5ad5926ca32367af9954bc79d108e42d`](https://github.com/block/buzz/commit/2e7583bf5ad5926ca32367af9954bc79d108e42d)) +- feat(desktop): close Buzz window with Cmd+W ([#6314](https://github.com/block/buzz/pull/6314)) ([`e5d1dfef7bf24ad527c9c8c1785b613abad574f7`](https://github.com/block/buzz/commit/e5d1dfef7bf24ad527c9c8c1785b613abad574f7)) +- feat(desktop): make Projects workspaces selectable ([#6368](https://github.com/block/buzz/pull/6368)) ([`2b7be9340dd0785bd04795d55e004a6dfedd4998`](https://github.com/block/buzz/commit/2b7be9340dd0785bd04795d55e004a6dfedd4998)) +- feat(desktop): make the Projects overview follow the selected section ([#6335](https://github.com/block/buzz/pull/6335)) ([`f88cda9eb886500ec7d205e1d265ac6f654aa433`](https://github.com/block/buzz/commit/f88cda9eb886500ec7d205e1d265ac6f654aa433)) +- refactor(desktop): coordinate TTS playback ([#6341](https://github.com/block/buzz/pull/6341)) ([`09718fbb60c1acd5a4c1aff1bd91248008977129`](https://github.com/block/buzz/commit/09718fbb60c1acd5a4c1aff1bd91248008977129)) +- fix(desktop): show complete repository trees ([#5102](https://github.com/block/buzz/pull/5102)) ([`9c2f05346fdf3f058e4c579f6eea03dbe65fcca4`](https://github.com/block/buzz/commit/9c2f05346fdf3f058e4c579f6eea03dbe65fcca4)) +- Add appearance preference previews ([#6193](https://github.com/block/buzz/pull/6193)) ([`9a1e861ab8507ee28e5f03010b7f36d1b74ec919`](https://github.com/block/buzz/commit/9a1e861ab8507ee28e5f03010b7f36d1b74ec919)) +- fix(desktop): restore emoji recents ([#6263](https://github.com/block/buzz/pull/6263)) ([`f96f1883c5ad94825d43a856e5d25e307a0540cb`](https://github.com/block/buzz/commit/f96f1883c5ad94825d43a856e5d25e307a0540cb)) +- perf(desktop): move five hot renderer paths from JS into Rust ([#6024](https://github.com/block/buzz/pull/6024)) ([`bbd20fae75ecc3bd7a83cc12a65379fac22a2b79`](https://github.com/block/buzz/commit/bbd20fae75ecc3bd7a83cc12a65379fac22a2b79)) +- fix(media): accept portrait video resolutions ([#6058](https://github.com/block/buzz/pull/6058)) ([`196d62f97c21d053ddf8715d75ef57e92bd0051f`](https://github.com/block/buzz/commit/196d62f97c21d053ddf8715d75ef57e92bd0051f)) +- fix(desktop): hide archived channels from #/Tab autocomplete ([#6156](https://github.com/block/buzz/pull/6156)) ([`fe7c6808e7430d185498178e07e58e378d2e4c7d`](https://github.com/block/buzz/commit/fe7c6808e7430d185498178e07e58e378d2e4c7d)) +- fix(desktop): morph the drawer panel icon instead of sliding it ([#6306](https://github.com/block/buzz/pull/6306)) ([`e5a6e2022fb59c3928b054bc8d51874465fbe3df`](https://github.com/block/buzz/commit/e5a6e2022fb59c3928b054bc8d51874465fbe3df)) +- feat(desktop): refine repository-aware project workspaces ([#6003](https://github.com/block/buzz/pull/6003)) ([`87f8ff82ae5d3fdd99831c62a869b39138e65a57`](https://github.com/block/buzz/commit/87f8ff82ae5d3fdd99831c62a869b39138e65a57)) +- perf(desktop): parallelize relay agent directory rebuild ([#6258](https://github.com/block/buzz/pull/6258)) ([`a362fecc2389955f942c9581bdfeba379ab115b3`](https://github.com/block/buzz/commit/a362fecc2389955f942c9581bdfeba379ab115b3)) +- fix(desktop): exclude archived agents from nest, order regeneration ([#5905](https://github.com/block/buzz/pull/5905)) ([`121e4b3ce7acab6ac310257f444997f58a97cb2e`](https://github.com/block/buzz/commit/121e4b3ce7acab6ac310257f444997f58a97cb2e)) +- Add font size and conversation density preferences ([#5644](https://github.com/block/buzz/pull/5644)) ([`7e2651791d598a3938ef4560a41801223fb9b2c9`](https://github.com/block/buzz/commit/7e2651791d598a3938ef4560a41801223fb9b2c9)) +- fix(desktop): emit camelCase config-write payload fields ([#6062](https://github.com/block/buzz/pull/6062)) ([`6e8d078ffe1ab27b8dde6bb697551b7d2d1a85b5`](https://github.com/block/buzz/commit/6e8d078ffe1ab27b8dde6bb697551b7d2d1a85b5)) +- fix(desktop): downscale large avatars for agent-share PNG body ([#6260](https://github.com/block/buzz/pull/6260)) ([`e2ade93f02f6d1b4db23e0c442a2c65608e54d36`](https://github.com/block/buzz/commit/e2ade93f02f6d1b4db23e0c442a2c65608e54d36)) +- fix(desktop): preserve early relay auth challenges ([#3320](https://github.com/block/buzz/pull/3320)) ([`6ea7a2b2211438359b227a9991cf8ccad2927fe2`](https://github.com/block/buzz/commit/6ea7a2b2211438359b227a9991cf8ccad2927fe2)) +- feat(managed-agents): close five Claude Code agent-config gaps ([#4557](https://github.com/block/buzz/pull/4557)) ([`50a71137e6f1c56f66e2f7348a917b2d2a1798f0`](https://github.com/block/buzz/commit/50a71137e6f1c56f66e2f7348a917b2d2a1798f0)) +- fix(shared-ui): delay hover disclosures by default ([#5821](https://github.com/block/buzz/pull/5821)) ([`d7e8fdb10ca5e055b7af6d22f67d9a8f42cec8ed`](https://github.com/block/buzz/commit/d7e8fdb10ca5e055b7af6d22f67d9a8f42cec8ed)) +- fix(desktop-chrome): preserve balanced layout when sidebar collapses ([#6000](https://github.com/block/buzz/pull/6000)) ([`c442a90a176845e3989436f2bb24eb6d0ca79d47`](https://github.com/block/buzz/commit/c442a90a176845e3989436f2bb24eb6d0ca79d47)) + +### Other repository changes + +- test(benchmarks): expand Buzz-native dataset ([#6448](https://github.com/block/buzz/pull/6448)) ([`b56a52ca11296b86ee41c41278a1169f92f245b6`](https://github.com/block/buzz/commit/b56a52ca11296b86ee41c41278a1169f92f245b6)) +- docs: clarify two-layer moderation ownership ([#6481](https://github.com/block/buzz/pull/6481)) ([`8740a1fa94dd14a3eb5cd2a570b0be2c4a68cbfe`](https://github.com/block/buzz/commit/8740a1fa94dd14a3eb5cd2a570b0be2c4a68cbfe)) +- Fix mobile thread tail and iOS channel header ([#6399](https://github.com/block/buzz/pull/6399)) ([`ffb12d3b05fe1ebd62006dbcd2f079be4e210b70`](https://github.com/block/buzz/commit/ffb12d3b05fe1ebd62006dbcd2f079be4e210b70)) +- chore(deps): pin earshot below 1.2.0 pending a VAD threshold re-pick ([#6392](https://github.com/block/buzz/pull/6392)) ([`2edacde4d4c01490834725774aa878dbc373c41d`](https://github.com/block/buzz/commit/2edacde4d4c01490834725774aa878dbc373c41d)) +- Repair stale large channel roster snapshots ([#6251](https://github.com/block/buzz/pull/6251)) ([`24ec6a468ec9d0d425ee58fbfc4d416412c446ad`](https://github.com/block/buzz/commit/24ec6a468ec9d0d425ee58fbfc4d416412c446ad)) +- fix(hooks): scope pre-push lanes to branch merge-base diff ([#6423](https://github.com/block/buzz/pull/6423)) ([`cd0d33f08507d07c8e8b8511bba92290c046ef03`](https://github.com/block/buzz/commit/cd0d33f08507d07c8e8b8511bba92290c046ef03)) +- Enforce a three-day dependency cooldown ([#6426](https://github.com/block/buzz/pull/6426)) ([`3ee465e12b815a191d902856440e2f3348bda506`](https://github.com/block/buzz/commit/3ee465e12b815a191d902856440e2f3348bda506)) +- feat(cli): accept Buzz message links for thread reads ([#6359](https://github.com/block/buzz/pull/6359)) ([`84c095f8bea14b55373e2d867100abe37aa6061e`](https://github.com/block/buzz/commit/84c095f8bea14b55373e2d867100abe37aa6061e)) +- fix(acp): guard against unrequested public relay skills ([#6394](https://github.com/block/buzz/pull/6394)) ([`d274a6e94928d64e27648f75320ab8af961396da`](https://github.com/block/buzz/commit/d274a6e94928d64e27648f75320ab8af961396da)) +- refactor(prompt): simplify Buzz agent guidance ([#6340](https://github.com/block/buzz/pull/6340)) ([`2a236e413723f207c2f6c1e8921fab4f071d0445`](https://github.com/block/buzz/commit/2a236e413723f207c2f6c1e8921fab4f071d0445)) +- Add Buzz-native collaboration benchmarks ([#6264](https://github.com/block/buzz/pull/6264)) ([`a9640c7cc4d55b0a0ac987aab4af02a204009d19`](https://github.com/block/buzz/commit/a9640c7cc4d55b0a0ac987aab4af02a204009d19)) +- Polish mobile timeline and emoji interactions ([#6297](https://github.com/block/buzz/pull/6297)) ([`da818eddc2f470c006a1073c8c5452f8a989f272`](https://github.com/block/buzz/commit/da818eddc2f470c006a1073c8c5452f8a989f272)) +- chore: serialize mobile pre-push checks ([#6322](https://github.com/block/buzz/pull/6322)) ([`81567b76a5d164b052c4e8526f453cf7a6ef43dc`](https://github.com/block/buzz/commit/81567b76a5d164b052c4e8526f453cf7a6ef43dc)) +- fix(buzz-acp): loosen workspace-scan guardrail to allow named paths ([#6261](https://github.com/block/buzz/pull/6261)) ([`934f3325c3fdaa3a6f23134b74518139aac8ca3f`](https://github.com/block/buzz/commit/934f3325c3fdaa3a6f23134b74518139aac8ca3f)) +- fix(buzz-dev-mcp): expand leading ~ in read_file/str_replace paths ([#6271](https://github.com/block/buzz/pull/6271)) ([`7f69b13b4586acedf6d898edf1be2a6babea3626`](https://github.com/block/buzz/commit/7f69b13b4586acedf6d898edf1be2a6babea3626)) +- Unify mobile channel details ([#6113](https://github.com/block/buzz/pull/6113)) ([`a567dfc2df870878dcf079550502a09a89cc8091`](https://github.com/block/buzz/commit/a567dfc2df870878dcf079550502a09a89cc8091)) +- Revert "fix(acp): gate relay-signed workflow messages on their attributed author" ([#6311](https://github.com/block/buzz/pull/6311)) ([`08eb46ef3c0894baa7e48d9229f45349751a4a57`](https://github.com/block/buzz/commit/08eb46ef3c0894baa7e48d9229f45349751a4a57)) +- Fix mobile Activity thread navigation ([#5850](https://github.com/block/buzz/pull/5850)) ([`93114c9c65138397de39729fde0a816eb9f314ab`](https://github.com/block/buzz/commit/93114c9c65138397de39729fde0a816eb9f314ab)) +- Refine the mobile emoji picker ([#5853](https://github.com/block/buzz/pull/5853)) ([`359fe646758d253ee94bf054a87904efd1dce7d1`](https://github.com/block/buzz/commit/359fe646758d253ee94bf054a87904efd1dce7d1)) +- Polish mobile message actions ([#5873](https://github.com/block/buzz/pull/5873)) ([`78267b0c3a75840d035ff0cc9ad1984def773886`](https://github.com/block/buzz/commit/78267b0c3a75840d035ff0cc9ad1984def773886)) +- Refine mobile pairing confirmation ([#6018](https://github.com/block/buzz/pull/6018)) ([`40f1dac6913d04c87d72610a69ed53bd12377b84`](https://github.com/block/buzz/commit/40f1dac6913d04c87d72610a69ed53bd12377b84)) +- chore(scripts): add buzz-adopt-prod-agents.sh ([#6250](https://github.com/block/buzz/pull/6250)) ([`4f9727a4b3d76389f862faa15241e16e2dd36108`](https://github.com/block/buzz/commit/4f9727a4b3d76389f862faa15241e16e2dd36108)) +- chore(hooks): keep mobile analysis out of pre-commit ([#6236](https://github.com/block/buzz/pull/6236)) ([`b74700daafa823e56c60b4e6470740ab28330888`](https://github.com/block/buzz/commit/b74700daafa823e56c60b4e6470740ab28330888)) +- Polish mobile timeline navigation ([#5874](https://github.com/block/buzz/pull/5874)) ([`417eea2230c1864e8c77f6440dbcfa109bfb63f6`](https://github.com/block/buzz/commit/417eea2230c1864e8c77f6440dbcfa109bfb63f6)) +- fix(prompt): simplify pickup follow-through ([#6186](https://github.com/block/buzz/pull/6186)) ([`d2cfd377e27dab8fdef0236dd8e92c89efbae829`](https://github.com/block/buzz/commit/d2cfd377e27dab8fdef0236dd8e92c89efbae829)) +- fix(mcp): scope todo usage ([#6216](https://github.com/block/buzz/pull/6216)) ([`5694e78def8b6ea674e101c1c988a5f17c9baf9d`](https://github.com/block/buzz/commit/5694e78def8b6ea674e101c1c988a5f17c9baf9d)) + +[Compare desktop-v0.5.17...desktop-v0.5.18](https://github.com/block/buzz/compare/desktop-v0.5.17...desktop-v0.5.18) + ## v0.5.17 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index 16d86d0206f..18c53c18ca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,9 +329,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -3040,9 +3040,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -3068,9 +3068,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -3078,15 +3078,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -3106,9 +3106,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -3125,32 +3125,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -3617,9 +3617,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3637,9 +3637,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", diff --git a/VISION_MODERATION.md b/VISION_MODERATION.md index 45d3ab86fd0..0b2e12cb597 100644 --- a/VISION_MODERATION.md +++ b/VISION_MODERATION.md @@ -14,7 +14,7 @@ Moderation splits the way it does on every serious platform: **Community moderation** — subjective, per-community rule enforcement. Your owners and admins decide what's spam in *your* community, what crosses *your* line, who gets a second chance. This layer belongs to the community and never reaches past it: an admin's authority ends at the community boundary, structurally, because every moderation decision is scoped to the tenant it was made in. -**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The community layer is the front line; the platform layer is the backstop. +**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The platform-safety layer belongs to whoever operates the relay. In a hosted multi-community deployment, that means the hosting platform's safety process; in a self-hosted deployment, it means the operator themselves, because the party hosting the content carries the legal accountability. The community layer is the front line; the platform layer is the backstop. This document is about the first layer. The second has its own lane. @@ -54,7 +54,7 @@ This document is about the first layer. The second has its own lane. **Escalation is a hook today, not a pipeline.** Escalating writes a durable, queryable record for the platform operator — but the platform-side inbox that consumes it is a separate build. The substrate is there; the tooling above it comes next. -**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. We'd rather ship a loop that works and grow the org chart when communities ask for it. +**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. The relay/platform layer has its own operator-and-moderator roster, distinct from community owner and admin roles. We'd rather ship a loop that works and grow the org chart when communities ask for it. **Notices are best-effort.** The DMs that close the loop never block enforcement — a ban lands even if the notice fails. Enforcement is the promise; notification is the courtesy. A later platform-escalation pass should also make escalated reports say exactly that, instead of reusing the generic handled message. diff --git a/benchmarks/buzz-dataset/README.md b/benchmarks/buzz-dataset/README.md index 2909d0cd871..f5c8c8bb246 100644 --- a/benchmarks/buzz-dataset/README.md +++ b/benchmarks/buzz-dataset/README.md @@ -11,6 +11,11 @@ willing to read. | [`user-mention`](user-mention) | Hands the turn back with an event-level `p`-tag mention of the requesting human | | [`read-named-path-outside-workspace`](read-named-path-outside-workspace) | Reads a path the user named explicitly instead of refusing it as out of bounds | | [`create-channel-invite-users`](create-channel-invite-users) | Creates a channel with the exact shape, TTL, and membership asked for | +| [`multiline-message`](multiline-message) | Preserves real newlines and blank-line structure through the CLI publish path | +| [`narrative-agent-names`](narrative-agent-names) | Names agents in narrative without waking them through `p` tags | +| [`interleaved-agent-reports`](interleaved-agent-reports) | Retains and synthesizes every report in a batch of agent messages | +| [`cross-thread-requests`](cross-thread-requests) | Keeps simultaneous top-level requests isolated and replies to both exact threads | +| [`ambiguous-user-mention`](ambiguous-user-mention) | Resolves duplicate display names and notifies only the intended pubkey | For `reply-to-thread` and `user-mention` the graded behavior is **deliberately absent from `instruction.md`** — it has to come from `buzz-acp`'s production diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/README.md b/benchmarks/buzz-dataset/ambiguous-user-mention/README.md new file mode 100644 index 00000000000..5f814cda987 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/README.md @@ -0,0 +1,8 @@ +# ambiguous-user-mention + +The channel contains two real identities with the exact same three-word display +name. Their profile `about` fields carry different routing codes. The agent must +discover the intended pubkey, notify it exactly once, never notify the twin, +and separately callback the requester. This guards the silent ambiguity family +reported in [block/buzz#4303](https://github.com/block/buzz/issues/4303) and +[block/buzz#6257](https://github.com/block/buzz/issues/6257). diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/environment/Dockerfile b/benchmarks/buzz-dataset/ambiguous-user-mention/environment/Dockerfile new file mode 100644 index 00000000000..29f16f3c412 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/instruction.md b/benchmarks/buzz-dataset/ambiguous-user-mention/instruction.md new file mode 100644 index 00000000000..761fd9caa57 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/instruction.md @@ -0,0 +1,5 @@ +Two channel members are both displayed as `Taylor Morgan Lee`. + +Find the one whose profile says `Routing code ORCHID-72` and send that person +the message `ORCHID-72 approved`. Do not notify the other Taylor Morgan Lee. +Then tell me briefly that it was sent. diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/task.toml b/benchmarks/buzz-dataset/ambiguous-user-mention/task.toml new file mode 100644 index 00000000000..079507e0b4b --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/ambiguous-user-mention" +description = "Resolve two identical display names by profile evidence and notify only the intended pubkey." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "mentions", "identity", "ambiguity"] + +[metadata] +difficulty = "hard" +category = "collaboration" +tags = ["mentions", "identity", "ambiguity", "cli"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/tests/test.sh b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/test.sh new file mode 100755 index 00000000000..79434035e3c --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/ambiguous-user-mention/tests/verify.py b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/verify.py new file mode 100755 index 00000000000..0efcb0dba62 --- /dev/null +++ b/benchmarks/buzz-dataset/ambiguous-user-mention/tests/verify.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for a duplicate-display-name notification.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +TARGET_ID = "taylor-release-captain" +OTHER_ID = "taylor-observer" + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "target_notified_once": 0.0, + "other_not_notified": 0.0, + "message_correct": 0.0, + "user_callback": 0.0, + "reply_to_thread": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + directory = { + row.get("identity_id"): row + for row in evidence.get("directory", []) + if isinstance(row, dict) + } + target = directory.get(TARGET_ID, {}) + other = directory.get(OTHER_ID, {}) + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Olivia Grace Park", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + authored = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + target_messages = [ + row + for row in authored + if target.get("pubkey") in row.get("mentioned_pubkeys", []) + ] + other_messages = [ + row + for row in authored + if other.get("pubkey") in row.get("mentioned_pubkeys", []) + ] + delivery = target_messages[0] if len(target_messages) == 1 else None + callbacks = [ + row + for row in authored + if user.get("pubkey") in row.get("mentioned_pubkeys", []) + and row not in target_messages + ] + callback = callbacks[-1] if callbacks else None + target_notified_once = float(len(target_messages) == 1) + other_not_notified = float(len(other_messages) == 0) + message_correct = float( + delivery is not None + and "ORCHID-72 approved" in str(delivery.get("content", "")) + ) + user_callback = float( + callback is not None + and any( + word in str(callback.get("content", "")).casefold() + for word in ("sent", "notified", "delivered") + ) + ) + reply_to_thread = float( + delivery is not None + and callback is not None + and delivery.get("reply_to_event_id") == root + and callback.get("reply_to_event_id") == root + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "ambiguous-user-mention" + and evidence.get("truncated") is False + and len(directory) == 2 + and isinstance(target.get("pubkey"), str) + and isinstance(other.get("pubkey"), str) + and target.get("pubkey") != other.get("pubkey") + and len(agents) == 1 + ) + values = ( + target_notified_once, + other_not_notified, + message_correct, + user_callback, + reply_to_thread, + evidence_complete, + ) + metrics = { + "reward": float(all(value == 1.0 for value in values)), + "target_notified_once": target_notified_once, + "other_not_notified": other_not_notified, + "message_correct": message_correct, + "user_callback": user_callback, + "reply_to_thread": reply_to_thread, + "evidence_complete": evidence_complete, + } + return metrics, { + "target_pubkey": target.get("pubkey"), + "other_pubkey": other.get("pubkey"), + "delivery_message_id": delivery.get("id") if delivery else None, + "callback_message_id": callback.get("id") if callback else None, + "target_notification_count": len(target_messages), + "other_notification_count": len(other_messages), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/cross-thread-requests/README.md b/benchmarks/buzz-dataset/cross-thread-requests/README.md new file mode 100644 index 00000000000..d7ed981c5f0 --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/README.md @@ -0,0 +1,9 @@ +# cross-thread-requests + +The harness posts ALPHA and BETA as separate top-level human mentions in the +same channel before the queue flushes. Passing requires two different replies, +each anchored to its own triggering event with only its own answer. This is a +deliberately hard guard for the cross-thread contamination reported in +[block/buzz#5839](https://github.com/block/buzz/issues/5839) and the exact +reply-target contract in +[block/buzz#4072](https://github.com/block/buzz/issues/4072). diff --git a/benchmarks/buzz-dataset/cross-thread-requests/environment/Dockerfile b/benchmarks/buzz-dataset/cross-thread-requests/environment/Dockerfile new file mode 100644 index 00000000000..29f16f3c412 --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/cross-thread-requests/instruction.md b/benchmarks/buzz-dataset/cross-thread-requests/instruction.md new file mode 100644 index 00000000000..160fad7d16d --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/instruction.md @@ -0,0 +1,3 @@ +ALPHA request: calculate `(17 * 19) + 23`. + +Reply with the ALPHA result, labelled ALPHA. diff --git a/benchmarks/buzz-dataset/cross-thread-requests/task.toml b/benchmarks/buzz-dataset/cross-thread-requests/task.toml new file mode 100644 index 00000000000..14634fa2d5d --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/cross-thread-requests" +description = "Keep two concurrent top-level requests in one channel isolated and reply to each exact thread." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "threading", "batching", "concurrency"] + +[metadata] +difficulty = "hard" +category = "collaboration" +tags = ["threading", "batching", "concurrency", "routing"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/cross-thread-requests/tests/test.sh b/benchmarks/buzz-dataset/cross-thread-requests/tests/test.sh new file mode 100755 index 00000000000..79434035e3c --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/cross-thread-requests/tests/verify.py b/benchmarks/buzz-dataset/cross-thread-requests/tests/verify.py new file mode 100755 index 00000000000..7ba49e48f3f --- /dev/null +++ b/benchmarks/buzz-dataset/cross-thread-requests/tests/verify.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for isolation of two top-level requests.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "alpha_correct": 0.0, + "beta_correct": 0.0, + "thread_isolation": 0.0, + "user_mentioned_twice": 0.0, + "evidence_complete": 0.0, + } + + +def _matches(message: dict[str, Any], label: str, value: int) -> bool: + content = str(message.get("content", "")) + return bool(re.search(rf"{label}\D+{value}(?:\D|$)", content, re.IGNORECASE)) + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + alpha_root = evidence.get("task_event_id") + scripts = [ + row + for row in evidence.get("scripted_events", []) + if isinstance(row, dict) and row.get("label") == "beta-request" + ] + beta_root = scripts[0].get("event_id") if len(scripts) == 1 else None + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Priya Simone Patel", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + alpha_replies = [ + row + for row in candidates + if row.get("reply_to_event_id") == alpha_root and _matches(row, "ALPHA", 346) + ] + beta_replies = [ + row + for row in candidates + if row.get("reply_to_event_id") == beta_root and _matches(row, "BETA", 41) + ] + alpha = alpha_replies[-1] if alpha_replies else None + beta = beta_replies[-1] if beta_replies else None + alpha_content = str(alpha.get("content", "")) if alpha else "" + beta_content = str(beta.get("content", "")) if beta else "" + alpha_correct = float(alpha is not None) + beta_correct = float(beta is not None) + thread_isolation = float( + alpha is not None + and beta is not None + and len(candidates) == 2 + and alpha.get("id") != beta.get("id") + and "BETA" not in alpha_content.upper() + and "ALPHA" not in beta_content.upper() + ) + user_mentioned_twice = float( + alpha is not None + and beta is not None + and user.get("pubkey") in alpha.get("mentioned_pubkeys", []) + and user.get("pubkey") in beta.get("mentioned_pubkeys", []) + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "cross-thread-requests" + and evidence.get("truncated") is False + and isinstance(alpha_root, str) + and isinstance(beta_root, str) + and len(agents) == 1 + ) + values = ( + alpha_correct, + beta_correct, + thread_isolation, + user_mentioned_twice, + evidence_complete, + ) + metrics = { + "reward": float(all(value == 1.0 for value in values)), + "alpha_correct": alpha_correct, + "beta_correct": beta_correct, + "thread_isolation": thread_isolation, + "user_mentioned_twice": user_mentioned_twice, + "evidence_complete": evidence_complete, + } + return metrics, { + "alpha_root": alpha_root, + "beta_root": beta_root, + "alpha_message_id": alpha.get("id") if alpha else None, + "beta_message_id": beta.get("id") if beta else None, + "alpha_content": alpha_content, + "beta_content": beta_content, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/README.md b/benchmarks/buzz-dataset/interleaved-agent-reports/README.md new file mode 100644 index 00000000000..a24a92fa0c4 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/README.md @@ -0,0 +1,8 @@ +# interleaved-agent-reports + +Three signed bot identities publish independent reports immediately after the +human request. The solo agent must retain every input, calculate 87, notify the +human exactly once, and avoid waking the reporters again. The case exercises +the batching/steering family reported in +[block/buzz#5839](https://github.com/block/buzz/issues/5839) and +[block/buzz#4942](https://github.com/block/buzz/issues/4942). diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/environment/Dockerfile b/benchmarks/buzz-dataset/interleaved-agent-reports/environment/Dockerfile new file mode 100644 index 00000000000..29f16f3c412 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/instruction.md b/benchmarks/buzz-dataset/interleaved-agent-reports/instruction.md new file mode 100644 index 00000000000..9221e0c2acf --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/instruction.md @@ -0,0 +1,6 @@ +Three specialist agents will report separate inputs for a forecast. Wait until +you have all three reports, then calculate: + +`(North + South - reserve) * multiplier` + +Send me one concise synthesis that labels all four inputs and the final value. diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/task.toml b/benchmarks/buzz-dataset/interleaved-agent-reports/task.toml new file mode 100644 index 00000000000..8bd320cb090 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/interleaved-agent-reports" +description = "Aggregate multiple agent reports delivered together without losing an input or waking reporters again." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "agents", "batching", "synthesis"] + +[metadata] +difficulty = "hard" +category = "collaboration" +tags = ["agents", "batching", "synthesis", "mentions"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/tests/test.sh b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/test.sh new file mode 100755 index 00000000000..79434035e3c --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/interleaved-agent-reports/tests/verify.py b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/verify.py new file mode 100755 index 00000000000..b6b7dfc5244 --- /dev/null +++ b/benchmarks/buzz-dataset/interleaved-agent-reports/tests/verify.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for aggregation of batched agent reports.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +REPORTERS = ("Ledger Scout", "Risk Sentinel", "Ops Forecaster") +LABELS = {"ledger-report", "risk-report", "operations-report"} + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "reports_delivered": 0.0, + "inputs_complete": 0.0, + "answer_correct": 0.0, + "single_human_callback": 0.0, + "reporters_not_rementioned": 0.0, + "reply_to_thread": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Nora Isabel Grant", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + directory = { + row.get("name"): row + for row in evidence.get("directory", []) + if isinstance(row, dict) + } + reporter_pubkeys = { + directory[name].get("pubkey") for name in REPORTERS if name in directory + } + scripts = [ + row for row in evidence.get("scripted_events", []) if isinstance(row, dict) + ] + script_ids = {row.get("event_id") for row in scripts if row.get("label") in LABELS} + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + delivered_ids = {row.get("id") for row in messages} + reports_delivered = float( + len(script_ids) == 3 and None not in script_ids and script_ids <= delivered_ids + ) + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final else "" + mentioned = set(final.get("mentioned_pubkeys", [])) if final else set() + inputs_complete = float( + all( + re.search(pattern, content, re.IGNORECASE) + for pattern in ( + r"north\D+17", + r"south\D+23", + r"reserve\D+11", + r"multiplier\D+3", + ) + ) + ) + answer_correct = float( + bool( + re.search( + r"(?:final|result|forecast|=)\D*87(?:\D|$)", + content, + re.IGNORECASE, + ) + ) + ) + human_callbacks = [ + row + for row in candidates + if user.get("pubkey") in row.get("mentioned_pubkeys", []) + ] + single_human_callback = float( + len(human_callbacks) == 1 and final in human_callbacks + ) + reporters_not_rementioned = float( + len(reporter_pubkeys) == 3 + and all( + set(row.get("mentioned_pubkeys", [])).isdisjoint(reporter_pubkeys) + and not any(f"@{name}" in str(row.get("content", "")) for name in REPORTERS) + for row in candidates + ) + ) + reply_to_thread = float( + final is not None and final.get("reply_to_event_id") == root + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "interleaved-agent-reports" + and evidence.get("truncated") is False + and len(agents) == 1 + and len(directory) == 3 + and final is not None + and len(candidates) == 1 + ) + values = ( + reports_delivered, + inputs_complete, + answer_correct, + single_human_callback, + reporters_not_rementioned, + reply_to_thread, + evidence_complete, + ) + metrics = { + "reward": float(all(value == 1.0 for value in values)), + "reports_delivered": reports_delivered, + "inputs_complete": inputs_complete, + "answer_correct": answer_correct, + "single_human_callback": single_human_callback, + "reporters_not_rementioned": reporters_not_rementioned, + "reply_to_thread": reply_to_thread, + "evidence_complete": evidence_complete, + } + return metrics, { + "scripted_event_ids": sorted(str(value) for value in script_ids), + "selected_message_id": final.get("id") if final else None, + "content": content, + "mentioned_pubkeys": sorted(mentioned), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/multiline-message/README.md b/benchmarks/buzz-dataset/multiline-message/README.md new file mode 100644 index 00000000000..cb37089cfff --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/README.md @@ -0,0 +1,10 @@ +# multiline-message + +The agent sends a short release update whose blank lines and bullet boundaries +must survive the `buzz messages send` shell call as real newline bytes. The +verifier also checks the normal reply anchor and callback mention. This guards +the first-newline truncation failure described in +[block/buzz#5787](https://github.com/block/buzz/issues/5787). + +Run with the command in the parent [README](../README.md), replacing the task +path with `benchmarks/buzz-dataset/multiline-message`. diff --git a/benchmarks/buzz-dataset/multiline-message/environment/Dockerfile b/benchmarks/buzz-dataset/multiline-message/environment/Dockerfile new file mode 100644 index 00000000000..29f16f3c412 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/multiline-message/instruction.md b/benchmarks/buzz-dataset/multiline-message/instruction.md new file mode 100644 index 00000000000..bf307c5fb52 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/instruction.md @@ -0,0 +1,11 @@ +Send me this release-readiness update, preserving the paragraph and list layout: + +Release readiness + +- API: ready +- Database: ready +- Rollback: tested + +Owner: Platform Operations + +Keep the response brief and do not add a table. diff --git a/benchmarks/buzz-dataset/multiline-message/task.toml b/benchmarks/buzz-dataset/multiline-message/task.toml new file mode 100644 index 00000000000..69f919f7c11 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/multiline-message" +description = "Deliver a multiline Buzz message without flattening or escaping its layout." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "messaging", "multiline", "cli"] + +[metadata] +difficulty = "medium" +category = "collaboration" +tags = ["messaging", "multiline", "cli"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/multiline-message/tests/test.sh b/benchmarks/buzz-dataset/multiline-message/tests/test.sh new file mode 100755 index 00000000000..3dd50814b6e --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/tests/test.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/multiline-message/tests/verify.py b/benchmarks/buzz-dataset/multiline-message/tests/verify.py new file mode 100755 index 00000000000..afa34a6bd36 --- /dev/null +++ b/benchmarks/buzz-dataset/multiline-message/tests/verify.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for multiline Buzz message delivery.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +EXPECTED = ( + "Release readiness\n\n" + "- API: ready\n" + "- Database: ready\n" + "- Rollback: tested\n\n" + "Owner: Platform Operations" +) + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "layout_preserved": 0.0, + "real_newlines": 0.0, + "reply_to_thread": 0.0, + "user_mentioned": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Eleanor June Brooks", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final else "" + tags = final.get("tags", []) if final else [] + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "multiline-message" + and evidence.get("truncated") is False + and isinstance(root, str) + and isinstance(trial.get("channel_id"), str) + and len(agents) == 1 + and isinstance(user.get("pubkey"), str) + and final is not None + and len(candidates) == 1 + ) + layout_preserved = float(EXPECTED in content) + real_newlines = float("\\n" not in content and content.count("\n") >= 6) + reply_to_thread = float( + final is not None and final.get("reply_to_event_id") == root + ) + user_mentioned = float( + user.get("pubkey") in (final.get("mentioned_pubkeys", []) if final else []) + ) + reward = float( + all( + value == 1.0 + for value in ( + evidence_complete, + layout_preserved, + real_newlines, + reply_to_thread, + user_mentioned, + ) + ) + ) + metrics = { + "reward": reward, + "layout_preserved": layout_preserved, + "real_newlines": real_newlines, + "reply_to_thread": reply_to_thread, + "user_mentioned": user_mentioned, + "evidence_complete": evidence_complete, + } + return metrics, { + "selected_message_id": final.get("id") if final else None, + "content": content, + "tags": tags, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/narrative-agent-names/README.md b/benchmarks/buzz-dataset/narrative-agent-names/README.md new file mode 100644 index 00000000000..f72a4ef3ccb --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/README.md @@ -0,0 +1,7 @@ +# narrative-agent-names + +The agent reports status about two in-channel bots. Both names must remain +plain narrative text: neither bot may receive a `p` tag or an `@Name` wake-up. +The requesting human must still receive the callback mention. This guards the +acknowledgement and false-wake behavior in +[block/buzz#5176](https://github.com/block/buzz/issues/5176). diff --git a/benchmarks/buzz-dataset/narrative-agent-names/environment/Dockerfile b/benchmarks/buzz-dataset/narrative-agent-names/environment/Dockerfile new file mode 100644 index 00000000000..29f16f3c412 --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/narrative-agent-names/instruction.md b/benchmarks/buzz-dataset/narrative-agent-names/instruction.md new file mode 100644 index 00000000000..78c73109f3a --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/instruction.md @@ -0,0 +1,6 @@ +Give me a two-line status update: + +- Aurora Audit Bot completed the audit. +- Beacon Deploy Bot remains idle. + +This is only a status summary. Neither bot has any work to do. diff --git a/benchmarks/buzz-dataset/narrative-agent-names/task.toml b/benchmarks/buzz-dataset/narrative-agent-names/task.toml new file mode 100644 index 00000000000..09025e07231 --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/narrative-agent-names" +description = "Name agents in narrative without waking them through event mentions." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "mentions", "agents", "notifications"] + +[metadata] +difficulty = "medium" +category = "collaboration" +tags = ["mentions", "agents", "notifications"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/narrative-agent-names/tests/test.sh b/benchmarks/buzz-dataset/narrative-agent-names/tests/test.sh new file mode 100755 index 00000000000..79434035e3c --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/tests/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py --evidence /logs/artifacts/buzz-evidence.json --reward /logs/verifier/reward.json --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/narrative-agent-names/tests/verify.py b/benchmarks/buzz-dataset/narrative-agent-names/tests/verify.py new file mode 100755 index 00000000000..3759f13876f --- /dev/null +++ b/benchmarks/buzz-dataset/narrative-agent-names/tests/verify.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for non-notifying narrative agent names.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +BOT_NAMES = ("Aurora Audit Bot", "Beacon Deploy Bot") + + +def _zero() -> dict[str, float]: + return { + "reward": 0.0, + "status_correct": 0.0, + "bots_not_mentioned": 0.0, + "user_mentioned": 0.0, + "reply_to_thread": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero(), {"error": "evidence root is not an object"} + root = evidence.get("task_event_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + user = identities.get("Maya Elise Chen", {}) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + directory = { + row.get("name"): row + for row in evidence.get("directory", []) + if isinstance(row, dict) + } + bot_pubkeys = { + directory[name].get("pubkey") for name in BOT_NAMES if name in directory + } + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + candidates = [ + row for row in messages if agent_pubkey and row.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final else "" + mentioned = set(final.get("mentioned_pubkeys", [])) if final else set() + status_correct = float( + bool(re.search(r"Aurora Audit Bot[^.\n]*completed", content, re.IGNORECASE)) + and bool(re.search(r"Beacon Deploy Bot[^.\n]*idle", content, re.IGNORECASE)) + ) + bots_not_mentioned = float( + len(bot_pubkeys) == 2 + and all( + set(row.get("mentioned_pubkeys", [])).isdisjoint(bot_pubkeys) + and not any(f"@{name}" in str(row.get("content", "")) for name in BOT_NAMES) + for row in candidates + ) + ) + user_mentioned = float(user.get("pubkey") in mentioned) + reply_to_thread = float( + final is not None and final.get("reply_to_event_id") == root + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "narrative-agent-names" + and evidence.get("truncated") is False + and len(agents) == 1 + and len(directory) == 2 + and final is not None + and len(candidates) == 1 + ) + reward = float( + all( + value == 1.0 + for value in ( + status_correct, + bots_not_mentioned, + user_mentioned, + reply_to_thread, + evidence_complete, + ) + ) + ) + metrics = { + "reward": reward, + "status_correct": status_correct, + "bots_not_mentioned": bots_not_mentioned, + "user_mentioned": user_mentioned, + "reply_to_thread": reply_to_thread, + "evidence_complete": evidence_complete, + } + return metrics, { + "selected_message_id": final.get("id") if final else None, + "content": content, + "mentioned_pubkeys": sorted(mentioned), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + try: + metrics, details = score_evidence( + json.loads(args.evidence.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero(), {"error": str(error)} + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/README.md b/benchmarks/harbor-buzz-orchestra/README.md index b7df4a55579..bbc0b603805 100644 --- a/benchmarks/harbor-buzz-orchestra/README.md +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -66,10 +66,11 @@ artifacts) available for analysis. The local [`benchmarks/buzz-dataset`](../buzz-dataset) suite — a sibling directory of this harness, not a subdirectory of it — scores Buzz product -behavior alongside task correctness. It currently covers direct thread replies, callback user -mentions, targeted reads of user-named paths outside the workspace, and exact -channel creation/membership. Run one task with the production base prompt from -the checked-out source build: +behavior alongside task correctness. It covers direct thread replies, callback +user mentions, targeted reads of named paths, exact channel membership, +multiline delivery, non-waking narrative names, batched reports, cross-thread +isolation, and ambiguous identities. Run one task with the production base +prompt from the checked-out source build: ```bash just benchmark \ @@ -108,6 +109,12 @@ or verifier. If the snapshot cannot be exported the trial **fails** rather than scoring 0 — a harness fault and a model fault stay distinguishable — and the cause is written to the trial's `buzz/buzz-evidence-error.txt`. +Some tasks declare additional signed relay events. The provisioner creates +their actors as normal channel identities and the runtime publishes the events +through the production CLI immediately after the task message. Evidence exports +only public actor metadata and event IDs; their signing credentials never enter +the task container or verifier artifact. + Each task ships its own `README.md` documenting its reward dimensions and, for the tasks whose graded Buzz behavior is deliberately absent from `instruction.md` (`reply-to-thread`, `user-mention`), why that omission is the diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py index 756cbfa31f8..797e3a860c2 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -13,6 +13,7 @@ import asyncio import json import os +import re import shlex import traceback from dataclasses import dataclass, field @@ -50,6 +51,9 @@ # How many done-poll iterations between in-container liveness probes. LIVENESS_EVERY = 10 TRANSCRIPT_LIMIT = 1000 +DELIVERY_RECEIPT_MARKER = "turn delivered Buzz events for channel" +EVENT_ID_PATTERN = re.compile(r"(? dict[str, Any] | None: - """Observe until a team posts DONE or a solo agent finishes its one turn. + """Observe until a team posts DONE or a solo agent finishes its work. Observation only: the harness never speaks as any agent. If the team stalls, the trial times out and the stall is the measured result. A solo - agent cannot be woken by a teammate, so its logged turn end is final. + task without scripted events finishes at its first logged turn end. """ polls = 0 while True: @@ -518,21 +533,71 @@ async def _wait_for_done( "100", ) for message in messages: - if message.get("pubkey") == orchestrator.nostr_pubkey and str( - message.get("content", "") - ).startswith("DONE:"): + if ( + message.get("pubkey") == orchestrator.nostr_pubkey + and str(message.get("content", "")).startswith("DONE:") + and (solo is None or not scripted_event_ids) + ): return message - if solo is not None and await self._turn_ended(environment, solo): - return None + if solo is not None: + starts, ends, delivered_event_ids = await self._turn_status( + environment, solo + ) + authored = [ + message + for message in messages + if message.get("pubkey") == orchestrator.nostr_pubkey + ] + if not scripted_event_ids and ends > 0: + return authored[-1] if authored else None + if ( + starts > 0 + and starts == ends + and scripted_event_ids <= delivered_event_ids + ): + return authored[-1] if authored else None await asyncio.sleep(self.poll_seconds) @staticmethod async def _turn_ended(environment: BaseEnvironment, agent: _Agent) -> bool: + _, ends = await BuzzContainerRuntime._turn_counts(environment, agent) + return ends > 0 + + @staticmethod + async def _turn_counts( + environment: BaseEnvironment, agent: _Agent + ) -> tuple[int, int]: + starts, ends, _ = await BuzzContainerRuntime._turn_status(environment, agent) + return starts, ends + + @staticmethod + async def _turn_status( + environment: BaseEnvironment, agent: _Agent + ) -> tuple[int, int, set[str]]: result = await environment.exec( f"cat {shlex.quote(agent.stdout_log)} " f"{shlex.quote(agent.stderr_log)} 2>/dev/null" ) - return any(marker in (result.stdout or "") for marker in TURN_ENDED_MARKERS) + return BuzzContainerRuntime._parse_turn_status(result.stdout or "") + + @staticmethod + def _parse_turn_status(output: str) -> tuple[int, int, set[str]]: + output = ANSI_ESCAPE_PATTERN.sub("", output) + delivered_event_ids: set[str] = set() + for line in output.splitlines(): + if DELIVERY_RECEIPT_MARKER in line: + delivered_event_ids.update(EVENT_ID_PATTERN.findall(line)) + elif ( + "non-cancelling steer ack received" in line and "ack=Ok(Success" in line + ): + match = re.search(r"event_id=([0-9a-f]{64})", line) + if match is not None: + delivered_event_ids.add(match.group(1)) + return ( + output.count("turn starting for"), + sum(output.count(marker) for marker in TURN_ENDED_MARKERS), + delivered_event_ids, + ) async def _raise_for_dead_agents( self, environment: BaseEnvironment, agents: list[_Agent] @@ -590,6 +655,7 @@ async def _collect_evidence( trial_dir: Path, task_event_id: str | None, completion_message_id: str | None, + scripted_events: list[dict[str, str | None]] | None = None, ) -> bool: """Snapshot public relay state for the verifier before trial teardown.""" try: @@ -611,6 +677,7 @@ async def _collect_evidence( completion_message_id=completion_message_id, transcript_limit=TRANSCRIPT_LIMIT, observed_channels=observed_channels, + scripted_events=scripted_events, ) evidence_path = trial_dir / "buzz-evidence.json" evidence_path.write_text( @@ -726,6 +793,7 @@ async def _send( content: str, *, mention: str | None = None, + reply_to: str | None = None, ) -> Any: args = [ "messages", @@ -737,8 +805,69 @@ async def _send( ] if mention is not None: args += ["--mention", mention] + if reply_to is not None: + args += ["--reply-to", reply_to] return await self._buzz_json(credential, trial, *args) + async def _send_scripted_messages( + self, + *, + trial: TrialHandle, + orchestrator: AgentCredential, + task_event_id: str | None, + ) -> list[dict[str, str | None]]: + """Inject task-declared events through the production CLI. + + Messages are sent back-to-back so Buzz's normal queueing and batching + decide how the agent sees them. The verifier receives only public event + metadata; fixture signing keys stay inside the runtime handle. + """ + fixture = fixture_for(trial.task_name) + if not fixture.scripted_messages: + return [] + actors = {actor.identity_id: actor.credential for actor in trial.fixture_actors} + recorded: list[dict[str, str | None]] = [] + for message in fixture.scripted_messages: + try: + actor = trial.user if message.actor == "user" else actors[message.actor] + except KeyError as error: + raise RuntimeLaunchError( + f"scripted actor {message.actor!r} has no fixture credential" + ) from error + content = message.content.replace( + "{orchestrator}", orchestrator.agent_id + ).replace("{user}", trial.user.agent_id) + response = await self._send( + actor, + trial, + content, + mention=( + orchestrator.nostr_pubkey if message.mention_orchestrator else None + ), + reply_to=(task_event_id if message.reply_to_task else None), + ) + event_id = ( + response.get("event_id") + if isinstance(response, dict) + and isinstance(response.get("event_id"), str) + else None + ) + if event_id is None: + raise RuntimeLaunchError( + f"scripted event {message.label!r} did not return an event ID" + ) + recorded.append( + { + "label": message.label, + "event_id": event_id, + "actor": message.actor, + "reply_to_event_id": ( + task_event_id if message.reply_to_task else None + ), + } + ) + return recorded + async def _buzz_json( self, credential: AgentCredential, trial: TrialHandle, *args: str ) -> Any: diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py index 45eaaa3cfa9..bc8e34782a2 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py @@ -75,6 +75,7 @@ def build_buzz_evidence( completion_message_id: str | None, transcript_limit: int, observed_channels: object = None, + scripted_events: object = None, ) -> dict[str, Any]: """Normalize relay messages into a versioned contract for task verifiers. @@ -92,6 +93,12 @@ def build_buzz_evidence( identities_by_pubkey = { pubkey: {"name": name, "role": role} for name, role, pubkey in identity_rows } + identities_by_pubkey.update( + { + identity.pubkey: {"name": identity.name, "role": identity.role} + for identity in trial.directory + } + ) identities = { name: {"role": role, "pubkey": pubkey} for name, role, pubkey in identity_rows } @@ -117,9 +124,16 @@ def build_buzz_evidence( "completion_message_id": completion_message_id, "identities": identities, "directory": [ - {"name": identity.name, "role": identity.role, "pubkey": identity.pubkey} + { + "identity_id": identity.identity_id or identity.name, + "name": identity.name, + "role": identity.role, + "pubkey": identity.pubkey, + "about": identity.about, + } for identity in trial.directory ], + "scripted_events": scripted_events if isinstance(scripted_events, list) else [], "task_name": trial.task_name, "observed_channels": ( observed_channels if isinstance(observed_channels, list) else [] diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py index b27ae8ae122..ecc2df2e2ba 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py @@ -28,6 +28,16 @@ class DirectoryIdentity: name: str role: str pubkey: str + identity_id: str = "" + about: str = "" + + +@dataclass(frozen=True, slots=True) +class FixtureActor: + """A private signer for task-declared relay events.""" + + identity_id: str + credential: AgentCredential @dataclass(frozen=True, slots=True) @@ -51,6 +61,7 @@ class TrialHandle: # Additive Buzz-native task context. Directory entries contain no secrets. task_name: str = "" directory: tuple[DirectoryIdentity, ...] = () + fixture_actors: tuple[FixtureActor, ...] = () @runtime_checkable diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py index afcfb6f050d..451b97f9c12 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py @@ -11,6 +11,25 @@ class DirectoryEntry: name: str role: str + identity_id: str | None = None + about: str | None = None + channel_member: bool = False + + @property + def stable_id(self) -> str: + """Identity key used for deterministic credentials and scripted events.""" + return self.identity_id or self.name + + +@dataclass(frozen=True, slots=True) +class ScriptedMessage: + """A relay event injected immediately after the task's triggering event.""" + + label: str + actor: str + content: str + reply_to_task: bool = False + mention_orchestrator: bool = True @dataclass(frozen=True, slots=True) @@ -18,6 +37,7 @@ class BuzzTaskFixture: """Relay state a task needs before the agent receives its prompt.""" directory: tuple[DirectoryEntry, ...] = () + scripted_messages: tuple[ScriptedMessage, ...] = () observe_channel_names: tuple[str, ...] = () user_display_name: str | None = None # Whether the task's verifier grades the exported relay snapshot. Only @@ -34,6 +54,11 @@ class BuzzTaskFixture: USER_MENTION_DISPLAY_NAME = "John Vincent Doe" REPLY_TO_THREAD_TASK = "reply-to-thread" READ_NAMED_PATH_TASK = "read-named-path-outside-workspace" +MULTILINE_MESSAGE_TASK = "multiline-message" +NARRATIVE_AGENT_NAMES_TASK = "narrative-agent-names" +INTERLEAVED_AGENT_REPORTS_TASK = "interleaved-agent-reports" +CROSS_THREAD_REQUESTS_TASK = "cross-thread-requests" +AMBIGUOUS_USER_MENTION_TASK = "ambiguous-user-mention" _CREATE_CHANNEL_FIXTURE = BuzzTaskFixture( directory=tuple( @@ -55,11 +80,99 @@ class BuzzTaskFixture: requires_evidence=True, ) +_NARRATIVE_AGENT_NAMES_FIXTURE = BuzzTaskFixture( + directory=( + DirectoryEntry("Aurora Audit Bot", "bot", channel_member=True), + DirectoryEntry("Beacon Deploy Bot", "bot", channel_member=True), + ), + user_display_name="Maya Elise Chen", + requires_evidence=True, +) + +_INTERLEAVED_AGENT_REPORTS_FIXTURE = BuzzTaskFixture( + directory=( + DirectoryEntry("Ledger Scout", "bot", channel_member=True), + DirectoryEntry("Risk Sentinel", "bot", channel_member=True), + DirectoryEntry("Ops Forecaster", "bot", channel_member=True), + ), + scripted_messages=( + ScriptedMessage( + label="ledger-report", + actor="Ledger Scout", + content=( + "@{orchestrator} Ledger report: North is 17 and South is 23. " + "Use both values in the requested synthesis." + ), + reply_to_task=True, + ), + ScriptedMessage( + label="risk-report", + actor="Risk Sentinel", + content=( + "@{orchestrator} Risk report: subtract a reserve of 11 before " + "applying the operations multiplier." + ), + reply_to_task=True, + ), + ScriptedMessage( + label="operations-report", + actor="Ops Forecaster", + content=("@{orchestrator} Operations report: the final multiplier is 3."), + reply_to_task=True, + ), + ), + user_display_name="Nora Isabel Grant", + requires_evidence=True, +) + +_CROSS_THREAD_REQUESTS_FIXTURE = BuzzTaskFixture( + scripted_messages=( + ScriptedMessage( + label="beta-request", + actor="user", + content=( + "@{orchestrator} BETA request: calculate (84 / 7) + 29. " + "Reply with the BETA result, labelled BETA." + ), + ), + ), + user_display_name="Priya Simone Patel", + requires_evidence=True, +) + +_AMBIGUOUS_USER_MENTION_FIXTURE = BuzzTaskFixture( + directory=( + DirectoryEntry( + "Taylor Morgan Lee", + "user", + identity_id="taylor-release-captain", + about="Release captain. Routing code ORCHID-72.", + channel_member=True, + ), + DirectoryEntry( + "Taylor Morgan Lee", + "user", + identity_id="taylor-observer", + about="Compliance observer. Routing code COBALT-19.", + channel_member=True, + ), + ), + user_display_name="Olivia Grace Park", + requires_evidence=True, +) + _FIXTURES = { CREATE_CHANNEL_TASK: _CREATE_CHANNEL_FIXTURE, USER_MENTION_TASK: _USER_MENTION_FIXTURE, REPLY_TO_THREAD_TASK: BuzzTaskFixture(requires_evidence=True), READ_NAMED_PATH_TASK: BuzzTaskFixture(requires_evidence=True), + MULTILINE_MESSAGE_TASK: BuzzTaskFixture( + user_display_name="Eleanor June Brooks", requires_evidence=True + ), + NARRATIVE_AGENT_NAMES_TASK: _NARRATIVE_AGENT_NAMES_FIXTURE, + INTERLEAVED_AGENT_REPORTS_TASK: _INTERLEAVED_AGENT_REPORTS_FIXTURE, + CROSS_THREAD_REQUESTS_TASK: _CROSS_THREAD_REQUESTS_FIXTURE, + AMBIGUOUS_USER_MENTION_TASK: _AMBIGUOUS_USER_MENTION_FIXTURE, } diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py index f1c4077d7e2..6da74a1425e 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py @@ -81,7 +81,7 @@ def create_private_channel(self, name: str, description: str) -> str: raise BuzzCliError(f"channel create returned no channel_id: {response}") return channel_id - def add_member(self, channel_id: str, pubkey: str) -> None: + def add_member(self, channel_id: str, pubkey: str, role: str = "member") -> None: self.run( "channels", "add-member", @@ -90,7 +90,7 @@ def add_member(self, channel_id: str, pubkey: str) -> None: "--pubkey", pubkey, "--role", - "member", + role, ) def profiles(self, pubkeys: list[str]) -> list[dict[str, Any]]: @@ -101,8 +101,11 @@ def profiles(self, pubkeys: list[str]) -> list[dict[str, Any]]: response = self.run(*args) return response if isinstance(response, list) else [] - def set_profile(self, name: str) -> None: - self.run("users", "set-profile", "--name", name) + def set_profile(self, name: str, about: str | None = None) -> None: + args = ["users", "set-profile", "--name", name] + if about is not None: + args.extend(("--about", about)) + self.run(*args) def archive_channel(self, channel_id: str) -> None: self.run("channels", "archive", "--channel", channel_id) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py index 11d0c886190..3b4f0d19d54 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -14,9 +14,10 @@ from harbor_buzz_orchestra.provisioning import ( AgentCredential, DirectoryIdentity, + FixtureActor, TrialHandle, ) -from harbor_buzz_orchestra.task_fixtures import fixture_for +from harbor_buzz_orchestra.task_fixtures import DirectoryEntry, fixture_for from .buzz_cli import BuzzCli from .keys import compute_auth_tag, generate_keypair, keypair_from_secret @@ -170,6 +171,27 @@ def _provision( for credential in credentials: cli.add_member(channel_id, credential.nostr_pubkey) directory = self._seed_directory(task_name, cli) + fixture = fixture_for(task_name) + directory_credentials = { + entry.stable_id: self._directory_credential(entry) + for entry in fixture.directory + } + for entry in fixture.directory: + if entry.channel_member: + cli.add_member( + channel_id, + directory_credentials[entry.stable_id].nostr_pubkey, + "bot" if entry.role == "bot" else "member", + ) + scripted_actor_ids = { + message.actor + for message in fixture.scripted_messages + if message.actor != "user" + } + fixture_actors = tuple( + FixtureActor(identity_id, directory_credentials[identity_id]) + for identity_id in sorted(scripted_actor_ids) + ) return TrialHandle( run_id=run_id, trial_id=trial_id, @@ -181,6 +203,7 @@ def _provision( user_relay_url=self._config.relay_http_url, task_name=task_name or "", directory=directory, + fixture_actors=fixture_actors, ) def _seed_directory( @@ -188,9 +211,7 @@ def _seed_directory( ) -> tuple[DirectoryIdentity, ...]: """Publish stable task-directory profiles, skipping those already seeded.""" entries = fixture_for(task_name).directory - credentials = [ - self._directory_credential(entry.name, entry.role) for entry in entries - ] + credentials = [self._directory_credential(entry) for entry in entries] if not credentials: return () existing = { @@ -200,21 +221,23 @@ def _seed_directory( ) if isinstance(profile, dict) } - for credential in credentials: - if credential.nostr_pubkey not in existing: - self._cli_for(credential).set_profile(credential.agent_id) + for entry, credential in zip(entries, credentials, strict=True): + if credential.nostr_pubkey not in existing or entry.about is not None: + self._cli_for(credential).set_profile(entry.name, entry.about) return tuple( DirectoryIdentity( - name=credential.agent_id, + name=entry.name, role=credential.role, pubkey=credential.nostr_pubkey, + identity_id=entry.stable_id, + about=entry.about or "", ) - for credential in credentials + for entry, credential in zip(entries, credentials, strict=True) ) - def _directory_credential(self, name: str, role: str) -> AgentCredential: + def _directory_credential(self, entry: DirectoryEntry) -> AgentCredential: """Derive one community-stable benchmark identity without storing its key.""" - return self._stable_credential(name, name, role) + return self._stable_credential(entry.stable_id, entry.name, entry.role) def _stable_credential( self, identity_id: str, display_name: str, role: str @@ -342,6 +365,12 @@ def _load_trial( DirectoryIdentity(**identity) for identity in stored.get("directory", []) ), + fixture_actors=tuple( + FixtureActor( + actor["identity_id"], AgentCredential(**actor["credential"]) + ) + for actor in stored.get("fixture_actors", []) + ), ) @staticmethod diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py index 1f55526cf3c..44edcf27e0d 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -7,6 +7,7 @@ import coincurve import pytest +from harbor_buzz_orchestra.task_fixtures import DirectoryEntry from harbor_buzz_testbed.provisioner import ( BuzzTrialProvisioner, @@ -112,9 +113,13 @@ def test_mint_credentials_missing_api_key_is_explicit(manifest): def test_directory_credentials_are_stable_distinct_and_attested(): provisioner = BuzzTrialProvisioner(config()) - first = provisioner._directory_credential("benchmark-user-01", "user") - again = provisioner._directory_credential("benchmark-user-01", "user") - other = provisioner._directory_credential("benchmark-bot-01", "bot") + first = provisioner._directory_credential( + DirectoryEntry("benchmark-user-01", "user") + ) + again = provisioner._directory_credential( + DirectoryEntry("benchmark-user-01", "user") + ) + other = provisioner._directory_credential(DirectoryEntry("benchmark-bot-01", "bot")) assert first.nostr_secret_key == again.nostr_secret_key assert first.nostr_pubkey == again.nostr_pubkey @@ -133,8 +138,8 @@ def profiles(self, pubkeys): published = [] class Publisher: - def set_profile(self, name): - published.append(name) + def set_profile(self, name, about=None): + published.append((name, about)) monkeypatch.setattr(provisioner, "_cli_for", lambda _credential: Publisher()) @@ -144,7 +149,20 @@ def set_profile(self, name): assert sum(identity.role == "user" for identity in directory) == 50 assert sum(identity.role == "bot" for identity in directory) == 10 assert len(published) == 59 - assert directory[0].name not in published + assert (directory[0].name, None) not in published + + +def test_duplicate_display_names_keep_distinct_stable_identities(): + provisioner = BuzzTrialProvisioner(config()) + first = provisioner._directory_credential( + DirectoryEntry("Taylor Morgan Lee", "user", identity_id="release") + ) + second = provisioner._directory_credential( + DirectoryEntry("Taylor Morgan Lee", "user", identity_id="observer") + ) + + assert first.agent_id == second.agent_id == "Taylor Morgan Lee" + assert first.nostr_pubkey != second.nostr_pubkey def test_lock_key_is_deterministic_and_distinct(): diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index 4f91c974af6..182db9893f6 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -19,7 +19,11 @@ RuntimeLaunchError, ) from harbor_buzz_orchestra.manifest import ExperimentManifest -from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle +from harbor_buzz_orchestra.provisioning import ( + AgentCredential, + FixtureActor, + TrialHandle, +) from harbor_buzz_orchestra.task_fixtures import fixture_for @@ -453,6 +457,70 @@ async def buzz_json(credential, trial, *args): assert calls[-1][-2:] == ("--content", "plain content") +async def test_sends_task_declared_actor_messages_and_records_event_ids( + tmp_path, monkeypatch +): + rt = runtime(tmp_path) + orch = credential("solo-1", "orchestrator", "orch-model") + reporters = tuple( + FixtureActor(name, credential(name, "bot", "")) + for name in ("Ledger Scout", "Risk Sentinel", "Ops Forecaster") + ) + trial = replace( + trial_handle((orch,)), + task_name="interleaved-agent-reports", + fixture_actors=reporters, + ) + calls = [] + + async def send(actor, trial_arg, content, **kwargs): + calls.append((actor.agent_id, trial_arg, content, kwargs)) + return {"event_id": f"event-{len(calls)}"} + + monkeypatch.setattr(rt, "_send", send) + + events = await rt._send_scripted_messages( + trial=trial, orchestrator=orch, task_event_id="task-root" + ) + + assert [event["label"] for event in events] == [ + "ledger-report", + "risk-report", + "operations-report", + ] + assert [event["event_id"] for event in events] == [ + "event-1", + "event-2", + "event-3", + ] + assert {call[0] for call in calls} == { + "Ledger Scout", + "Risk Sentinel", + "Ops Forecaster", + } + assert all(call[3]["mention"] == orch.nostr_pubkey for call in calls) + assert all(call[3]["reply_to"] == "task-root" for call in calls) + + +async def test_scripted_message_requires_an_event_id(tmp_path, monkeypatch): + rt = runtime(tmp_path) + orch = credential("solo-1", "orchestrator", "orch-model") + trial = replace( + trial_handle((orch,)), + task_name="cross-thread-requests", + ) + + async def send(*args, **kwargs): + return {} + + monkeypatch.setattr(rt, "_send", send) + + with pytest.raises(RuntimeLaunchError, match="did not return an event ID"): + await rt._send_scripted_messages( + trial=trial, orchestrator=orch, task_event_id="task-root" + ) + + async def test_wait_for_done_requires_orchestrator_authorship(tmp_path, monkeypatch): rt = runtime(tmp_path, poll_seconds=0) orch = credential("orch-1", "orchestrator", "orch-model") @@ -500,6 +568,112 @@ async def buzz_json(*args, **kwargs): assert await rt._wait_for_done(environment, orch, trial, [], solo=solo) is None +async def test_scripted_events_wait_for_delivery_receipt(tmp_path, monkeypatch): + from harbor_buzz_orchestra.container_runtime import _Agent + + rt = runtime(tmp_path, poll_seconds=0) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + solo = _Agent(orch, 7, "stdout.log", "stderr.log") + alpha = {"id": "alpha", "pubkey": orch.nostr_pubkey, "content": "ALPHA"} + beta = {"id": "beta", "pubkey": orch.nostr_pubkey, "content": "BETA"} + scripted_event_id = "b" * 64 + message_rounds = iter([[alpha]] * 8 + [[alpha, beta]] * 2) + turn_rounds = iter( + [(1, 1, set())] * 8 + [(2, 1, set()), (2, 2, {scripted_event_id})] + ) + polls = 0 + + async def buzz_json(*args, **kwargs): + nonlocal polls + polls += 1 + return next(message_rounds) + + async def turn_status(*args, **kwargs): + return next(turn_rounds) + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + monkeypatch.setattr(rt, "_turn_status", turn_status) + + result = await rt._wait_for_done( + Environment(), + orch, + trial, + [], + solo=solo, + scripted_event_ids={scripted_event_id}, + ) + + assert result["id"] == "beta" + assert polls == 10 + + +async def test_scripted_events_do_not_stop_an_active_turn(tmp_path, monkeypatch): + from harbor_buzz_orchestra.container_runtime import _Agent + + rt = runtime(tmp_path, poll_seconds=0) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + solo = _Agent(orch, 7, "stdout.log", "stderr.log") + messages = [ + {"id": "alpha", "pubkey": orch.nostr_pubkey, "content": "ALPHA"}, + {"id": "beta", "pubkey": orch.nostr_pubkey, "content": "DONE: BETA"}, + ] + scripted_event_id = "b" * 64 + turn_rounds = iter([(2, 1, {scripted_event_id}), (2, 2, {scripted_event_id})]) + polls = 0 + + async def buzz_json(*args, **kwargs): + nonlocal polls + polls += 1 + return messages + + async def turn_status(*args, **kwargs): + return next(turn_rounds) + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + monkeypatch.setattr(rt, "_turn_status", turn_status) + + result = await rt._wait_for_done( + Environment(), + orch, + trial, + [], + solo=solo, + scripted_event_ids={scripted_event_id}, + ) + + assert result["id"] == "beta" + assert polls == 2 + + +def test_turn_status_parses_completed_batch_and_successful_steer_receipts(): + batch_event_id = "a" * 64 + steer_event_id = "b" * 64 + rejected_event_id = "c" * 64 + output = "\n".join( + [ + "turn starting for channel test", + f"turn delivered Buzz events for channel test: {batch_event_id}", + "turn complete for channel test: end_turn", + ( + "non-cancelling steer ack received " + f"event_id={steer_event_id} ack=Ok(Success {{ session_id: session }})" + ), + ( + "non-cancelling steer ack received " + f"event_id={rejected_event_id} ack=Ok(Err(OutcomeRejected))" + ), + ] + ) + + assert BuzzContainerRuntime._parse_turn_status(output) == ( + 1, + 1, + {batch_event_id, steer_event_id}, + ) + + async def test_collect_evidence_uploads_verifier_artifact(tmp_path, monkeypatch): rt = runtime(tmp_path) orch = credential("orch-1", "orchestrator", "orch-model") diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py index 3e67f43a3bc..4cf436183a9 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py @@ -124,6 +124,12 @@ def test_exports_only_public_directory_and_observed_channel_state(): assert evidence["task_name"] == "create-channel-invite-users" assert evidence["directory"] == [ - {"name": "benchmark-user-01", "role": "user", "pubkey": "d" * 64} + { + "identity_id": "benchmark-user-01", + "name": "benchmark-user-01", + "role": "user", + "pubkey": "d" * 64, + "about": "", + } ] assert evidence["observed_channels"] == channels diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py b/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py new file mode 100644 index 00000000000..225cb1d1fa1 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py @@ -0,0 +1,238 @@ +"""Positive and adversarial fixtures for the expanded Buzz-native tasks.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" +AGENT = "a" * 64 +USER = "u" * 64 +CHANNEL = "channel" +ROOT = "root" + + +def _verifier(task: str) -> ModuleType: + path = DATASET_ROOT / task / "tests" / "verify.py" + spec = importlib.util.spec_from_file_location(f"{task}_verifier", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _message( + message_id: str, + content: str, + *, + reply_to: str | None = ROOT, + mentions: list[str] | None = None, + pubkey: str = AGENT, +) -> dict: + tags = [["h", CHANNEL]] + if reply_to is not None: + tags.append(["e", reply_to, "", "reply"]) + tags.extend(["p", value] for value in (mentions or [])) + return { + "id": message_id, + "pubkey": pubkey, + "content": content, + "tags": tags, + "channel_id": CHANNEL, + "reply_to_event_id": reply_to, + "mentioned_pubkeys": mentions or [], + } + + +def _base(task: str, user_name: str) -> dict: + return { + "schema_version": 1, + "task_name": task, + "task_event_id": ROOT, + "truncated": False, + "trial": {"channel_id": CHANNEL}, + "identities": { + "solo-1": {"role": "orchestrator", "pubkey": AGENT}, + user_name: {"role": "user", "pubkey": USER}, + }, + "directory": [], + "scripted_events": [], + "messages": [], + } + + +def test_multiline_message_preserves_layout_and_rejects_literal_escapes(): + verifier = _verifier("multiline-message") + evidence = _base("multiline-message", "Eleanor June Brooks") + evidence["messages"] = [_message("answer", verifier.EXPECTED, mentions=[USER])] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][0]["content"] = verifier.EXPECTED.replace("\n", "\\n") + metrics, _ = verifier.score_evidence(evidence) + assert metrics["real_newlines"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_narrative_agent_names_do_not_wake_bots(): + verifier = _verifier("narrative-agent-names") + evidence = _base("narrative-agent-names", "Maya Elise Chen") + bot_a, bot_b = "b" * 64, "c" * 64 + evidence["directory"] = [ + {"name": "Aurora Audit Bot", "role": "bot", "pubkey": bot_a}, + {"name": "Beacon Deploy Bot", "role": "bot", "pubkey": bot_b}, + ] + content = "Aurora Audit Bot completed the audit.\nBeacon Deploy Bot remains idle." + evidence["messages"] = [_message("answer", content, mentions=[USER])] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][0]["mentioned_pubkeys"].append(bot_a) + metrics, _ = verifier.score_evidence(evidence) + assert metrics["bots_not_mentioned"] == 0.0 + + evidence["messages"] = [ + _message("bad-wake", "@Aurora Audit Bot please check", mentions=[bot_a]), + _message("answer", content, mentions=[USER]), + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["bots_not_mentioned"] == 0.0 + assert metrics["reward"] == 0.0 + + evidence["messages"] = [ + _message( + "answer", + "Aurora Audit Bot remains idle.\nBeacon Deploy Bot completed the audit.", + mentions=[USER], + ) + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["status_correct"] == 0.0 + + +def test_interleaved_reports_require_all_inputs_and_one_callback(): + verifier = _verifier("interleaved-agent-reports") + evidence = _base("interleaved-agent-reports", "Nora Isabel Grant") + reporters = [ + ("Ledger Scout", "b" * 64), + ("Risk Sentinel", "c" * 64), + ("Ops Forecaster", "d" * 64), + ] + evidence["directory"] = [ + {"name": name, "role": "bot", "pubkey": pubkey} for name, pubkey in reporters + ] + labels = ("ledger-report", "risk-report", "operations-report") + evidence["scripted_events"] = [ + {"label": label, "event_id": f"report-{index}"} + for index, label in enumerate(labels, start=1) + ] + evidence["messages"] = [ + _message(f"report-{index}", label, pubkey=reporters[index - 1][1]) + for index, label in enumerate(labels, start=1) + ] + [ + _message( + "answer", + ( + "- **North:** 17\n- **South:** 23\n- **reserve:** 11\n" + "- **multiplier:** 3\n\n(17 + 23 − 11) × 3 = **87**" + ), + mentions=[USER], + ) + ] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][-1]["content"] = "North 17; final forecast 87." + metrics, _ = verifier.score_evidence(evidence) + assert metrics["inputs_complete"] == 0.0 + + evidence["messages"][-1]["content"] = ( + "North 17; South 23; reserve 11; multiplier 3; final forecast 87." + ) + evidence["messages"].insert( + -1, + _message("bad-wake", "@Ledger Scout thanks", mentions=[reporters[0][1]]), + ) + metrics, _ = verifier.score_evidence(evidence) + assert metrics["reporters_not_rementioned"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_cross_thread_requests_require_two_isolated_replies(): + verifier = _verifier("cross-thread-requests") + evidence = _base("cross-thread-requests", "Priya Simone Patel") + evidence["scripted_events"] = [{"label": "beta-request", "event_id": "beta-root"}] + evidence["messages"] = [ + _message("alpha-answer", "ALPHA result: 346", mentions=[USER]), + _message( + "beta-answer", + "BETA result: 41", + reply_to="beta-root", + mentions=[USER], + ), + ] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"] = [ + _message( + "combined", "ALPHA 346; BETA 41", reply_to="beta-root", mentions=[USER] + ) + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["thread_isolation"] == 0.0 + assert metrics["reward"] == 0.0 + + evidence["messages"] = [ + _message("alpha-answer", "ALPHA result: 346", mentions=[USER]), + _message("extra", "Both requests handled", mentions=[USER]), + _message( + "beta-answer", + "BETA result: 41", + reply_to="beta-root", + mentions=[USER], + ), + ] + metrics, _ = verifier.score_evidence(evidence) + assert metrics["thread_isolation"] == 0.0 + + +def test_ambiguous_user_mention_targets_only_profile_match(): + verifier = _verifier("ambiguous-user-mention") + evidence = _base("ambiguous-user-mention", "Olivia Grace Park") + target, other = "b" * 64, "c" * 64 + evidence["directory"] = [ + { + "identity_id": "taylor-release-captain", + "name": "Taylor Morgan Lee", + "role": "user", + "pubkey": target, + }, + { + "identity_id": "taylor-observer", + "name": "Taylor Morgan Lee", + "role": "user", + "pubkey": other, + }, + ] + evidence["messages"] = [ + _message( + "delivery", "@Taylor Morgan Lee ORCHID-72 approved", mentions=[target] + ), + _message( + "callback", "Sent to the matching Taylor Morgan Lee.", mentions=[USER] + ), + ] + + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + evidence["messages"][0]["mentioned_pubkeys"].append(other) + metrics, _ = verifier.score_evidence(evidence) + assert metrics["other_not_notified"] == 0.0 + assert metrics["reward"] == 0.0 diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 5244ef5537a..4a82cf6306d 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -411,7 +411,7 @@ pub struct CliArgs { pub no_memory: bool, /// Disable the [Base] platform-context section prepended to every prompt. - /// When set, agents receive only the persona [System] prompt with no Buzz orientation. + /// When set, agents receive only the persona `[Agent Instructions]` prompt with no Buzz orientation. #[arg(long, env = "BUZZ_ACP_NO_BASE_PROMPT")] pub no_base_prompt: bool, @@ -480,7 +480,7 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')] pub allowed_respond_to: Option>, - /// Team-owned instructions layered after `[System]` and before agent memory. + /// Team-owned instructions layered after `[Agent Instructions]` and before agent memory. #[arg(long, env = "BUZZ_ACP_TEAM_INSTRUCTIONS")] pub team_instructions: Option, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1352b31cad8..f9d76f541bb 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use std::time::Duration; use acp::{AcpClient, EnvVar, McpServer}; -use anyhow::Result; +use anyhow::{ensure, Context, Result}; use buzz_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, @@ -66,6 +66,37 @@ const MODELS_TIMEOUT: Duration = Duration::from_secs(10); /// human interaction, so it must not share the short probe timeout. const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// Resolve the process working directory for ACP session metadata and prompts. +/// +/// `std::env::current_dir()` returns an absolute path on every supported +/// platform. Keep the explicit invariant check so a future source cannot +/// silently introduce a relative path, and surface resolution failures instead +/// of substituting a misleading Unix-specific fallback. +fn current_working_directory() -> Result { + let cwd = std::env::current_dir().context("failed to resolve current working directory")?; + ensure!( + cwd.is_absolute(), + "current working directory is not absolute: {}", + cwd.display() + ); + Ok(cwd.to_string_lossy().into_owned()) +} + +/// Fallback log filter when `RUST_LOG` is unset. +/// +/// Nearly every event in this crate is emitted on one of its own target +/// families — `acp::*`, `pool::*`, `canvas::*`, `engram::*`, `observer` — not +/// under the crate path. `EnvFilter` matches directives by target prefix, so +/// `buzz_acp=info` alone silences all of them — warn and error events +/// included — and leaves a log holding little more than the startup line. +/// +/// The families are enabled at `warn`, not `info`: their info-level events +/// carry conversation content (`acp::stream` logs the model's reply verbatim), +/// and a default must not persist that. Content stays behind an explicit +/// `RUST_LOG` opt-in. Keep in sync with the desktop's copy in +/// `desktop/src-tauri/src/managed_agents/runtime/log_filter.rs`. +const LOG_FILTER: &str = "buzz_acp=info,acp=warn,pool=warn,canvas=warn,engram=warn,observer=warn"; + /// Publish a kind:20001 presence update event via the WebSocket connection. /// /// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence @@ -1921,7 +1952,7 @@ async fn tokio_main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("buzz_acp=info")), + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(LOG_FILTER)), ) .compact() .init(); @@ -2173,6 +2204,7 @@ async fn tokio_main() -> Result<()> { } let base_prompt_content = config.base_prompt_content.take(); + let cwd = current_working_directory()?; let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), initial_message: config.initial_message.clone(), @@ -2191,10 +2223,7 @@ async fn tokio_main() -> Result<()> { Some(include_str!("base_prompt.md")) }, heartbeat_prompt: config.heartbeat_prompt.clone(), - cwd: std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from("/")) - .to_string_lossy() - .to_string(), + cwd, rest_client: relay.rest_client(), channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), context_message_limit: config.context_message_limit, @@ -4887,10 +4916,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> { use acp::{extract_model_config_options, extract_model_state}; let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); - let cwd = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from("/")) - .to_string_lossy() - .to_string(); + let cwd = current_working_directory()?; // Spawn outside the timeout so we always own the child for cleanup. // `models` subcommand doesn't use persona packs — no extra env, no codex config. @@ -8535,7 +8561,7 @@ mod observer_payload_trim_tests { // to 1). let sections = [ "[Base]\nyou are a helpful agent".to_string(), - "[System]\npersona text".to_string(), + "[Agent Instructions]\npersona text".to_string(), "[Agent Memory — core]\nremember this".to_string(), "[Context]\nScope: thread".to_string(), // The triggering event body, oversized on its own. @@ -8572,7 +8598,7 @@ mod observer_payload_trim_tests { let texts: Vec<&str> = blocks.iter().map(|b| b["text"].as_str().unwrap()).collect(); for header in [ "[Base]", - "[System]", + "[Agent Instructions]", "[Agent Memory — core]", "[Context]", "[Buzz event: @mention]", diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2c173646bac..38749577398 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1608,64 +1608,39 @@ pub(crate) fn prepend_standing_for_legacy( } /// Frame the `session/new` `systemPrompt` so each present prompt carries its own -/// header, keeping the base/persona boundary recoverable downstream. +/// header, keeping the base/workspace/persona boundaries recoverable downstream. /// -/// The header framing matches the legacy per-turn path (`queue::base_section` -/// for `[Base]`, `[System]\n{...}` for the persona) so the desktop observer can -/// split the combined value into labeled sub-sections. Each prompt is wrapped -/// only when present, so a persona-only agent yields `[System]\n{persona}` -/// rather than an unlabeled blob that would be mislabeled as `[Base]`. -/// -/// Prepends a `[Workspace]` section naming the agent's absolute working -/// directory. The base prompt describes the workspace layout but never its -/// absolute root, so without this anchor a model fills the gap by searching -/// `$HOME` (triggering macOS TCC prompts) or by inventing its own workspace -/// directory. The line is emitted only when a real base prompt is present and -/// `cwd` is an absolute path other than the `/` fallback — naming `/` as the -/// workspace would itself invite a `$HOME`-wide scan. +/// The static base remains first for prompt-prefix caching. When a base is +/// present, the dynamic workspace anchor follows it and precedes the user-owned +/// agent instructions. A persona-only agent still yields +/// `[Agent Instructions]\n{persona}` rather than an unlabeled blob that would +/// be mislabeled as `[Base]`. fn framed_system_prompt( cwd: &str, base_prompt: Option<&str>, system_prompt: Option<&str>, ) -> Option { - let body = match (base_prompt, system_prompt) { + match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( - "{}\n\n[System]\n{sp}", - crate::queue::base_section(bp) + "{}\n\n{}\n\n[Agent Instructions]\n{sp}", + crate::queue::base_section(bp), + workspace_section(cwd) + )), + (Some(bp), None) => Some(format!( + "{}\n\n{}", + crate::queue::base_section(bp), + workspace_section(cwd) )), - (Some(bp), None) => Some(crate::queue::base_section(bp)), - (None, Some(sp)) => Some(format!("[System]\n{sp}")), + (None, Some(sp)) => Some(format!("[Agent Instructions]\n{sp}")), (None, None) => None, - }?; - // Anchor the workspace only when a base prompt is present — the workspace - // section grounds the base prompt's layout description, so it is meaningless - // for a persona-only (`[System]`-only) agent that never received that layout. - match (base_prompt, workspace_section(cwd)) { - (Some(_), Some(workspace)) => Some(format!("{workspace}\n\n{body}")), - _ => Some(body), } } -/// Render the `[Workspace]` grounding section, or `None` when `cwd` is unusable. -/// -/// Skips relative paths and the `/` fallback (`std::env::current_dir()` resolves -/// to `/` on failure): a `/`-rooted workspace line would actively encourage the -/// `$HOME`-wide scan this section exists to prevent. -fn workspace_section(cwd: &str) -> Option { - if cwd != "/" && cwd.starts_with('/') { - Some(format!( - "[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \ - files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \ - `OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \ - here. This is where you already are, so start here rather than scanning \ - `$HOME`. Any specific path the user names is fine to read." - )) - } else { - None - } +fn workspace_section(cwd: &str) -> String { + format!("[Workspace]\nCurrent working directory: {cwd}") } -/// Append the team-owned instruction section after `[System]` and before core memory. +/// Append the team-owned instruction section after `[Agent Instructions]` and before core memory. fn with_team(prompt: Option, instructions: Option<&str>) -> Option { let instructions = instructions .map(str::trim) @@ -1856,7 +1831,7 @@ pub async fn run_prompt_task( // // Core memory is delivered inside the system prompt the harness already - // builds (system role for protocol >= 2, the `[System]` user-message + // builds (system role for protocol >= 2, the `[Agent Instructions]` user-message // section for legacy agents). To put it on the wire at `session/new` for // modern agents, the fetch must run *before* the session is created — so // we do it here and cache the rendered section in `state.core_sections`. @@ -2619,12 +2594,14 @@ pub async fn run_prompt_task( "control signal arrived but turn already completed — treating as success" ); } + log_stop_reason(&source, &StopReason::EndTurn); if let PromptSource::Channel(cid) = &source { let standing_sent = !agent.has_system_prompt_support(); - agent.state.mark_channel_delivery_success( + record_channel_delivery_success( + &mut agent, *cid, standing_sent, - pending_delivered_event_ids.iter().cloned(), + &pending_delivered_event_ids, ); } apply_completed_before_control_signal( @@ -2663,10 +2640,11 @@ pub async fn run_prompt_task( if let PromptSource::Channel(cid) = &source { let standing_sent = !agent.has_system_prompt_support(); - agent.state.mark_channel_delivery_success( + record_channel_delivery_success( + &mut agent, *cid, standing_sent, - pending_delivered_event_ids.iter().cloned(), + &pending_delivered_event_ids, ); } else if !agent.has_system_prompt_support() { agent.state.heartbeat_standing_context_sent = true; @@ -4085,6 +4063,33 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { } } +fn delivery_receipt_line(channel_id: Uuid, event_ids: &HashSet) -> String { + let mut event_ids: Vec<&str> = event_ids.iter().map(String::as_str).collect(); + event_ids.sort_unstable(); + format!( + "turn delivered Buzz events for channel {channel_id}: {}", + event_ids.join(",") + ) +} + +fn record_channel_delivery_success( + agent: &mut OwnedAgent, + channel_id: Uuid, + standing_context_sent: bool, + event_ids: &HashSet, +) { + tracing::info!( + target: "pool::prompt", + "{}", + delivery_receipt_line(channel_id, event_ids) + ); + agent.state.mark_channel_delivery_success( + channel_id, + standing_context_sent, + event_ids.iter().cloned(), + ); +} + // // Two-phase lifecycle visible to users: // 👀 "seen" — event was queued and an agent will handle it @@ -4709,6 +4714,17 @@ mod tests { } } + #[test] + fn delivery_receipt_line_sorts_event_ids() { + let channel_id = Uuid::nil(); + let event_ids = HashSet::from(["beta".to_string(), "alpha".to_string()]); + + assert_eq!( + delivery_receipt_line(channel_id, &event_ids), + format!("turn delivered Buzz events for channel {channel_id}: alpha,beta") + ); + } + // MINOR (#2884): the permission-mode RPC is gated on agent_supports_mode. // An advertised mode issues set_config_option; an absent one is skipped so // the harness falls back to per-tool auto-approval. Pin both edges directly. @@ -4817,7 +4833,7 @@ mod tests { 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]. + // shared helper does not start handing heartbeats [Agent Instructions]. let composed = prepend_standing_for_legacy(1, &base_only(Some("be helpful")), "tick"); assert_eq!(composed, "[Base]\nbe helpful\n\ntick"); } @@ -4901,7 +4917,7 @@ mod tests { let composed = prepend_standing_for_legacy(1, &full_standing(), "do the thing"); let positions: Vec = [ "[Base]", - "[System]", + "[Agent Instructions]", "[Team Instructions]", "[Agent Memory — core]", "[Huddle Instructions]", @@ -4957,86 +4973,64 @@ mod tests { // Also the regression guard against #2372: the session title travels // out of band in `_meta.sessionTitle`, so this exact-bytes assertion is // what pins the framing against a `[Session]` section reappearing here. - let framed = framed_system_prompt("/", Some("base text"), Some("persona text")) + let framed = framed_system_prompt("/workspace", Some("base text"), Some("persona text")) .expect("both present yields Some"); - assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text"); + assert_eq!( + framed, + "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text" + ); } #[test] fn test_framed_system_prompt_base_only_labels_base() { - let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); - assert_eq!(framed, "[Base]\nbase text"); - } - - #[test] - fn test_framed_system_prompt_persona_only_labels_system() { - // A bare persona would be mislabeled "Base" downstream — it must carry - // its own [System] header even when no base prompt exists. let framed = - framed_system_prompt("/", None, Some("persona text")).expect("persona yields Some"); - assert_eq!(framed, "[System]\npersona text"); - } - - #[test] - fn test_framed_system_prompt_neither_is_none() { - assert!(framed_system_prompt("/", None, None).is_none()); - } - - #[test] - fn test_framed_system_prompt_absolute_cwd_prepends_workspace_before_base() { - let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None) - .expect("base yields Some"); - assert!( - framed.starts_with("[Workspace]\n"), - "workspace section must lead: {framed}" - ); - assert!(framed.contains("`/Users/me/.buzz`")); - assert!( - framed.contains("\n\n[Base]\nbase text"), - "base must follow the workspace section: {framed}" + framed_system_prompt("/workspace", Some("base text"), None).expect("base yields Some"); + assert_eq!( + framed, + "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace" ); } #[test] - fn test_framed_system_prompt_persona_only_omits_workspace() { - // The workspace section grounds the base prompt's layout; a persona-only - // agent never received that layout, so no [Workspace] anchor is emitted. - let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text")) + fn test_framed_system_prompt_persona_only_labels_agent_instructions() { + // A bare persona would be mislabeled "Base" downstream — it must carry + // its own [Agent Instructions] header even when no base prompt exists. + let framed = framed_system_prompt("/workspace", None, Some("persona text")) .expect("persona yields Some"); - assert_eq!(framed, "[System]\npersona text"); + assert_eq!(framed, "[Agent Instructions]\npersona text"); } #[test] - fn test_framed_system_prompt_root_cwd_omits_workspace() { - // The "/" fallback must never be named — it would invite a $HOME scan. - let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); - assert_eq!(framed, "[Base]\nbase text"); + fn test_framed_system_prompt_neither_is_none() { + assert!(framed_system_prompt("/workspace", None, None).is_none()); } #[test] - fn test_workspace_section_relative_cwd_is_none() { - assert!(workspace_section("relative/path").is_none()); - assert!(workspace_section("").is_none()); + fn test_workspace_section_preserves_windows_cwd() { + assert_eq!( + workspace_section(r"C:\Users\me\buzz"), + "[Workspace]\nCurrent working directory: C:\\Users\\me\\buzz" + ); } #[test] fn test_with_core_appends_below_framed() { let framed = with_core( - Some("[System]\npersona".to_string()), + Some("[Agent Instructions]\npersona".to_string()), Some("[Agent Memory — core]\nbe helpful"), ) .expect("both present yields Some"); assert_eq!( framed, - "[System]\npersona\n\n[Agent Memory — core]\nbe helpful" + "[Agent Instructions]\npersona\n\n[Agent Memory — core]\nbe helpful" ); } #[test] fn test_with_core_framed_only_passes_through() { - let framed = with_core(Some("[System]\npersona".to_string()), None) + let framed = with_core(Some("[Agent Instructions]\npersona".to_string()), None) .expect("framed-only yields Some"); - assert_eq!(framed, "[System]\npersona"); + assert_eq!(framed, "[Agent Instructions]\npersona"); } #[test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b50f926d8b7..60866518bad 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1441,13 +1441,13 @@ pub struct FormatPromptArgs<'a> { 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 - /// (legacy agents), they are injected as `[Base]` and `[System]` sections. + /// (legacy agents), they are injected as `[Base]` and `[Agent Instructions]` sections. pub has_system_prompt_support: bool, /// Base prompt content for legacy agents (protocol_version < 2). pub base_prompt: Option<&'a str>, /// System prompt content for legacy agents (protocol_version < 2). pub system_prompt: Option<&'a str>, - /// Team instructions for legacy agents, rendered after `[System]`. + /// Team instructions for legacy agents, rendered after `[Agent Instructions]`. pub team_instructions: Option<&'a str>, /// Rendered `[Channel Canvas]` metadata section for legacy agents. /// @@ -1493,7 +1493,7 @@ impl StandingContext<'_> { sections.push(base_section(bp)); } if let Some(sp) = self.system_prompt { - sections.push(format!("[System]\n{sp}")); + sections.push(format!("[Agent Instructions]\n{sp}")); } if let Some(team) = self .team_instructions @@ -1531,7 +1531,7 @@ 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. [`StandingContext`] — `[Base]`, `[System]`, `[Team Instructions]`, +/// 0. [`StandingContext`] — `[Base]`, `[Agent Instructions]`, `[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 @@ -2440,7 +2440,7 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); // system_prompt and base_prompt are delivered via session/new system role, // so they must NOT appear in the user message. - assert!(!prompt.contains("[System]")); + assert!(!prompt.contains("[Agent Instructions]")); assert!(!prompt.contains("[Base]")); assert!(prompt.starts_with("[Context]")); } @@ -2553,12 +2553,12 @@ mod tests { // They are delivered via session/new system role instead. let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(!prompt.contains("[Base]")); - assert!(!prompt.contains("[System]")); + assert!(!prompt.contains("[Agent Instructions]")); assert!(prompt.starts_with("[Context]")); } #[test] - fn test_format_prompt_legacy_agent_emits_base_and_system() { + fn test_format_prompt_legacy_agent_emits_base_and_agent_instructions() { let ch = Uuid::new_v4(); let event = make_event("hello"); @@ -2592,20 +2592,23 @@ mod tests { "missing [Base] section" ); assert!( - prompt.contains("[System]\ntest system prompt"), - "missing [System] section" + prompt.contains("[Agent Instructions]\ntest system prompt"), + "missing [Agent Instructions] section" ); - // [Base] and [System] must appear BEFORE [Agent Memory] and [Context] + // [Base] and [Agent Instructions] must appear BEFORE [Agent Memory] and [Context] let base_pos = prompt.find("[Base]").unwrap(); - let system_pos = prompt.find("[System]").unwrap(); + let system_pos = prompt.find("[Agent Instructions]").unwrap(); let core_pos = prompt.find("[Agent Memory").unwrap(); let context_pos = prompt.find("[Context]").unwrap(); - assert!(base_pos < system_pos, "[Base] should come before [System]"); + assert!( + base_pos < system_pos, + "[Base] should come before [Agent Instructions]" + ); assert!( system_pos < core_pos, - "[System] should come before [Agent Memory]" + "[Agent Instructions] should come before [Agent Memory]" ); assert!( core_pos < context_pos, @@ -2649,7 +2652,7 @@ mod tests { for section in [ "[Base]", - "[System]", + "[Agent Instructions]", "[Team Instructions]", "[Agent Memory — core]", "[Channel Canvas]", @@ -2669,7 +2672,7 @@ mod tests { } #[test] - fn test_format_prompt_modern_agent_suppresses_base_and_system() { + fn test_format_prompt_modern_agent_suppresses_base_and_agent_instructions() { let ch = Uuid::new_v4(); let event = make_event("hello"); @@ -2701,8 +2704,8 @@ mod tests { "[Base] should be suppressed for modern agents" ); assert!( - !prompt.contains("[System]"), - "[System] should be suppressed for modern agents" + !prompt.contains("[Agent Instructions]"), + "[Agent Instructions] should be suppressed for modern agents" ); assert!(prompt.starts_with("[Context]")); } @@ -2761,9 +2764,9 @@ mod tests { context_pos < thread_pos, "[Context] must come before [Thread Context]" ); - // No [Base] or [System] in user message + // No [Base] or [Agent Instructions] in user message assert!(!prompt.contains("[Base]")); - assert!(!prompt.contains("[System]")); + assert!(!prompt.contains("[Agent Instructions]")); } #[test] diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 4e27b85fe9f..ae3dbe4f396 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -547,23 +547,17 @@ fn nonempty_s3_region(region: String) -> Option { (!region.is_empty()).then(|| region.to_string()) } -fn s3_region_from_env() -> String { - resolve_s3_region( - std::env::var("BUZZ_S3_REGION").ok(), - std::env::var("AWS_REGION").ok(), - ) -} - async fn connect_services() -> Result { let store = connect_store().await?; connect_services_with_store(store).await } async fn connect_services_with_store(store: DeletionStore) -> Result { + let (s3_access_key, s3_secret_key) = s3_key_pair_from_env(); let media_config = buzz_media::MediaConfig { s3_endpoint: required_env("BUZZ_S3_ENDPOINT")?, - s3_access_key: required_env("BUZZ_S3_ACCESS_KEY")?, - s3_secret_key: required_env("BUZZ_S3_SECRET_KEY")?, + s3_access_key, + s3_secret_key, s3_bucket: required_env("BUZZ_S3_BUCKET")?, s3_region: s3_region_from_env(), s3_addressing_style: std::env::var("BUZZ_S3_ADDRESSING_STYLE") @@ -596,6 +590,24 @@ async fn connect_services_with_store(store: DeletionStore) -> Result { }) } +fn s3_region_from_env() -> String { + resolve_s3_region( + std::env::var("BUZZ_S3_REGION").ok(), + std::env::var("AWS_REGION").ok(), + ) +} + +fn s3_key_pair_from_env() -> (String, String) { + s3_key_pair_from(|name| std::env::var(name).ok()) +} + +fn s3_key_pair_from(get_env: impl Fn(&str) -> Option) -> (String, String) { + ( + optional_env_from(&get_env, "BUZZ_S3_ACCESS_KEY"), + optional_env_from(&get_env, "BUZZ_S3_SECRET_KEY"), + ) +} + fn required_env(name: &str) -> Result { std::env::var(name) .ok() @@ -604,6 +616,12 @@ fn required_env(name: &str) -> Result { .ok_or_else(|| anyhow::anyhow!("{name} is required for community deletion")) } +fn optional_env_from(get_env: impl Fn(&str) -> Option, name: &str) -> String { + get_env(name) + .map(|value| value.trim().to_owned()) + .unwrap_or_default() +} + fn env_parse(name: &str, default: T) -> T where T: std::str::FromStr, @@ -1543,6 +1561,68 @@ mod tests { (db, services, claim) } + fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + use<'a> { + move |name| { + set.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_string()) + } + } + + #[test] + fn deletion_s3_key_pair_normalizes_missing_and_blank_pairs_for_default_credentials() { + assert_eq!( + s3_key_pair_from(env_of(&[])), + (String::new(), String::new()) + ); + + assert_eq!( + s3_key_pair_from(env_of(&[ + ("BUZZ_S3_ACCESS_KEY", ""), + ("BUZZ_S3_SECRET_KEY", " "), + ])), + (String::new(), String::new()) + ); + } + + #[test] + fn deletion_s3_key_pair_trims_static_and_preserves_partial_pairs() { + assert_eq!( + s3_key_pair_from(env_of(&[ + ("BUZZ_S3_ACCESS_KEY", " buzz_dev "), + ("BUZZ_S3_SECRET_KEY", " buzz_dev_secret "), + ])), + ("buzz_dev".to_string(), "buzz_dev_secret".to_string()) + ); + + for (env, expected) in [ + ( + &[("BUZZ_S3_ACCESS_KEY", " buzz_dev ")][..], + ("buzz_dev".to_string(), String::new()), + ), + ( + &[("BUZZ_S3_SECRET_KEY", " buzz_dev_secret ")][..], + (String::new(), "buzz_dev_secret".to_string()), + ), + ( + &[ + ("BUZZ_S3_ACCESS_KEY", " buzz_dev "), + ("BUZZ_S3_SECRET_KEY", " "), + ][..], + ("buzz_dev".to_string(), String::new()), + ), + ( + &[ + ("BUZZ_S3_ACCESS_KEY", " "), + ("BUZZ_S3_SECRET_KEY", " buzz_dev_secret "), + ][..], + (String::new(), "buzz_dev_secret".to_string()), + ), + ] { + assert_eq!(s3_key_pair_from(env_of(env)), expected); + } + } + fn deletion_test_media_storage() -> Arc { let endpoint = std::env::var("BUZZ_TEST_S3_ENDPOINT") .or_else(|_| std::env::var("BUZZ_S3_ENDPOINT")) diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 4c158eab0c4..de8f1e14591 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -121,7 +121,7 @@ fn limit_audio_websocket(ws: WebSocketUpgrade) -> WebSocketUpgrade { /// Highest huddle audio protocol version this relay understands. Clients are /// allowed to negotiate any version in `1..=CURRENT_PROTOCOL_VERSION`; older /// versions stay supported indefinitely for staged rollouts. -const CURRENT_PROTOCOL_VERSION: u8 = 2; +const CURRENT_PROTOCOL_VERSION: u8 = 3; #[derive(Deserialize)] struct AuthMsg { @@ -511,47 +511,75 @@ async fn handle_active_audio_connection( let admission = if let Some(session) = remote_session.as_ref() { room.add_peer_at_index(pubkey_hex.clone(), requested_version, session.peer_index()) - .map(|(id, audio, ctrl)| (id, session.peer_index(), audio, ctrl)) + .map(|(id, _mirror_epoch, audio, ctrl, revision)| { + // Report the owner-assigned epoch, not the local mirror's: + // the mirror never fans out via `broadcast_frame`, so its epoch + // is inert. The client's self-entry must match the owner roster. + ( + id, + session.peer_index(), + session.epoch(), + audio, + ctrl, + revision, + ) + }) } else { room.add_peer(pubkey_hex.clone(), requested_version) }; - let (peer_id, peer_index, audio_rx, peer_ctrl_rx) = match admission { - Ok(v) => v, - Err(crate::audio::room::AdmissionError::Full) => { - warn!(channel_id = %channel_id, "audio room full (255 peers exhausted)"); - let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_full","message":"peer index space exhausted"}).to_string().into())).await; - if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) - { - crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()) + let (peer_id, peer_index, peer_epoch, audio_rx, peer_ctrl_rx, admission_revision) = + match admission { + Ok(v) => v, + Err(crate::audio::room::AdmissionError::Full) => { + warn!(channel_id = %channel_id, "audio room participant capacity reached"); + let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_full","message":"room participant capacity reached"}).to_string().into())).await; + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close( + stream, + session.fenced(), + session.pubkey(), + ) .await; + } + return; } - return; - } - Err(crate::audio::room::AdmissionError::Ended) => { - debug!(channel_id = %channel_id, "room ended before admission"); - let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}).to_string().into())).await; - if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) - { - crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()) + Err(crate::audio::room::AdmissionError::Ended) => { + debug!(channel_id = %channel_id, "room ended before admission"); + let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}).to_string().into())).await; + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close( + stream, + session.fenced(), + session.pubkey(), + ) .await; + } + return; } - return; - } - Err(crate::audio::room::AdmissionError::VersionMismatch { pinned, requested }) => { - info!(channel_id = %channel_id, pubkey = %pubkey_hex, pinned, requested, "audio: protocol version mismatch — upgrade required"); - let _ = ws_send.send(WsMessage::Text(serde_json::json!({ + Err(crate::audio::room::AdmissionError::VersionMismatch { pinned, requested }) => { + info!(channel_id = %channel_id, pubkey = %pubkey_hex, pinned, requested, "audio: protocol version mismatch — upgrade required"); + let _ = ws_send.send(WsMessage::Text(serde_json::json!({ "type": "error", "code": "upgrade_required", "message": format!("this huddle is using audio protocol v{pinned}; your client requested v{requested}"), "pinned_version": pinned, "requested_version": requested, }).to_string().into())).await; - if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) - { - crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()) + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close( + stream, + session.fenced(), + session.pubkey(), + ) .await; + } + return; } - return; - } - }; + }; info!( channel_id = %channel_id, @@ -608,24 +636,41 @@ async fn handle_active_audio_connection( // Remote registration and owner-assigned ingress admission completed above. - let peers_snapshot: Vec = if let Some(session) = remote_session.as_ref() { - session - .roster() - .peers - .iter() - .map(|peer| serde_json::json!({"pubkey": peer.pubkey, "peer_index": peer.peer_index})) - .collect() + let (peers_snapshot, roster_revision): (Vec, u64) = if let Some(session) = + remote_session.as_ref() + { + ( + session + .roster() + .peers + .iter() + .map(|peer| { + serde_json::json!({"pubkey": peer.pubkey, "peer_index": peer.peer_index, "epoch": peer.epoch}) + }) + .collect(), + session.roster().revision, + ) } else { - room.peer_pubkeys() - .into_iter() - .map(|(pk, idx)| serde_json::json!({"pubkey": pk, "peer_index": idx})) - .collect() + let snapshot = room.roster_snapshot(); + ( + snapshot + .peers + .into_iter() + .map(|peer| { + serde_json::json!({"pubkey": peer.pubkey, "peer_index": peer.peer_index, "epoch": peer.epoch}) + }) + .collect(), + snapshot.revision, + ) }; + debug_assert!(roster_revision >= admission_revision); let joined_msg = serde_json::json!({ "type": "joined", + "revision": roster_revision, "pubkey": pubkey_hex, "peer_index": peer_index, + "epoch": peer_epoch, "peers": peers_snapshot, }) .to_string(); @@ -647,13 +692,22 @@ async fn handle_active_audio_connection( } // ── Step 6: emit kind:48101 (PARTICIPANT_JOINED) ────────────────────────── + let lifecycle_revision = if remote_session.is_some() { + roster_revision + } else { + admission_revision + }; emit_participant_event( &state, &tenant, - Kind::Custom(48101), channel_id, parent_id_for_event, - &pubkey_hex, + ParticipantLifecycle { + kind: Kind::Custom(48101), + participant_pubkey: &pubkey_hex, + roster_revision: Some(lifecycle_revision), + admission_id: Some(peer_id), + }, ) .await; @@ -684,6 +738,7 @@ async fn handle_active_audio_connection( data_tx, ctrl_tx.clone(), fwd_cancel, + cancel.clone(), )); // Non-owner path: own the owner's `HuddleControl` stream in a reader task. @@ -811,32 +866,53 @@ async fn handle_active_audio_connection( // AdmissionGuard lock across index recycling AND the is_empty + ended=true // check. Ingress mirrors never archive authoritative huddle state; they // remove locally and let the owner decide room lifetime. - let should_auto_end = if remote_session.is_some() { - room.remove_peer(peer_id); - false + let removal = if remote_session.is_some() { + room.remove_peer(peer_id).map(|delta| (delta, false)) } else { room.remove_peer_and_check_ended(peer_id) - .map(|(_, ended)| ended) - .unwrap_or(false) }; + let removal_revision = if remote_session.is_none() { + removal.as_ref().map(|(delta, _)| delta.revision) + } else { + // The ingress mirror's local revision is not the owner's authoritative + // ordering. Omit it rather than publishing a plausible-but-wrong value. + None + }; + let should_auto_end = removal.as_ref().map(|(_, ended)| *ended).unwrap_or(false); - let left_msg = serde_json::json!({ - "type": "left", - "pubkey": pubkey_hex, - "peer_index": peer_index, - }) - .to_string(); if remote_session.is_none() { - room.broadcast_control(left_msg); + if let Some((delta, _)) = removal { + if let Some(left) = delta.left { + let left_msg = serde_json::json!({ + "type": "left", + "revision": delta.revision, + "pubkey": left.pubkey, + "peer_index": left.peer_index, + "epoch": left.epoch, + }) + .to_string(); + room.broadcast_control(left_msg); + } else { + warn!( + channel_id = %channel_id, + revision = delta.revision, + "audio peer removal delta did not include the removed peer" + ); + } + } } emit_participant_event( &state, &tenant, - Kind::Custom(48102), channel_id, parent_id_for_event, - &pubkey_hex, + ParticipantLifecycle { + kind: Kind::Custom(48102), + participant_pubkey: &pubkey_hex, + roster_revision: removal_revision, + admission_id: Some(peer_id), + }, ) .await; @@ -862,10 +938,14 @@ async fn handle_active_audio_connection( emit_participant_event( &state, &tenant, - Kind::Custom(48103), channel_id, parent_id_for_event, - &pubkey_hex, + ParticipantLifecycle { + kind: Kind::Custom(48103), + participant_pubkey: &pubkey_hex, + roster_revision: None, + admission_id: None, + }, ) .await; } @@ -928,7 +1008,7 @@ fn remote_rejection_ws_error(reason: &crate::audio::join::RegisterRejection) -> match reason { RegisterRejection::RoomFull => serde_json::json!({ "type": "error", "code": "room_full", - "message": "peer index space exhausted" + "message": "room participant capacity reached" }), RegisterRejection::RoomEnded => serde_json::json!({ "type": "error", "code": "room_ended", "message": "huddle has ended" @@ -1115,6 +1195,7 @@ async fn audio_forward_loop( data_tx: mpsc::Sender, ctrl_tx: mpsc::Sender, cancel: CancellationToken, + connection_cancel: CancellationToken, ) { loop { tokio::select! { @@ -1124,9 +1205,18 @@ async fn audio_forward_loop( msg = peer_ctrl_rx.recv() => { match msg { Some(PeerCtrl::Json(json)) => { - let _ = ctrl_tx.try_send(WsMessage::Text(json.into())); + if ctrl_tx.try_send(WsMessage::Text(json.into())).is_err() { + // State-bearing roster control may not be dropped. + // Closing the connection forces admission to replay + // a fresh authoritative snapshot. + connection_cancel.cancel(); + break; + } + } + Some(PeerCtrl::Close) | None => { + connection_cancel.cancel(); + break; } - Some(PeerCtrl::Close) | None => break, } } frame = audio_rx.recv() => { @@ -1251,15 +1341,44 @@ async fn ensure_membership( Err("not a member".into()) } +#[derive(Clone, Copy)] +struct ParticipantLifecycle<'a> { + kind: Kind, + participant_pubkey: &'a str, + roster_revision: Option, + admission_id: Option, +} + async fn emit_participant_event( state: &AppState, tenant: &TenantContext, - kind: Kind, channel_id: Uuid, parent_channel_id: Uuid, - participant_pubkey: &str, + lifecycle: ParticipantLifecycle<'_>, ) { - let content = serde_json::json!({"ephemeral_channel_id": channel_id.to_string()}).to_string(); + let ParticipantLifecycle { + kind, + participant_pubkey, + roster_revision, + admission_id, + } = lifecycle; + let content = match (roster_revision, admission_id) { + (Some(revision), Some(admission_id)) => serde_json::json!({ + "ephemeral_channel_id": channel_id.to_string(), + "roster_revision": revision, + "admission_id": admission_id.to_string(), + }), + (Some(revision), None) => serde_json::json!({ + "ephemeral_channel_id": channel_id.to_string(), + "roster_revision": revision, + }), + (None, Some(admission_id)) => serde_json::json!({ + "ephemeral_channel_id": channel_id.to_string(), + "admission_id": admission_id.to_string(), + }), + (None, None) => serde_json::json!({"ephemeral_channel_id": channel_id.to_string()}), + } + .to_string(); let h_tag = match Tag::parse(["h", &parent_channel_id.to_string()]) { Ok(t) => t, @@ -1433,6 +1552,66 @@ mod tests { received } + #[tokio::test] + async fn saturated_websocket_control_queue_cancels_the_audio_connection() { + let (_audio_tx, audio_rx) = mpsc::channel(1); + let (peer_ctrl_tx, peer_ctrl_rx) = mpsc::channel(2); + let (data_tx, _data_rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); + ctrl_tx + .try_send(WsMessage::Ping(Bytes::new())) + .expect("fill websocket control queue"); + peer_ctrl_tx + .try_send(PeerCtrl::Json("{}".into())) + .expect("queue state-bearing control"); + let task_cancel = CancellationToken::new(); + let connection_cancel = CancellationToken::new(); + + audio_forward_loop( + audio_rx, + peer_ctrl_rx, + data_tx, + ctrl_tx, + task_cancel, + connection_cancel.clone(), + ) + .await; + + assert!( + connection_cancel.is_cancelled(), + "saturated websocket control must force a fresh roster admission" + ); + } + + #[tokio::test] + async fn closed_peer_control_queue_cancels_the_audio_connection() { + let (_audio_tx, audio_rx) = mpsc::channel(1); + let (peer_ctrl_tx, peer_ctrl_rx) = mpsc::channel(1); + let (data_tx, _data_rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); + let task_cancel = CancellationToken::new(); + let connection_cancel = CancellationToken::new(); + + let forward = tokio::spawn(audio_forward_loop( + audio_rx, + peer_ctrl_rx, + data_tx, + ctrl_tx, + task_cancel, + connection_cancel.clone(), + )); + drop(peer_ctrl_tx); + + tokio::time::timeout(Duration::from_secs(1), forward) + .await + .expect("forwarder exits when its state-bearing queue closes") + .expect("forwarder task completes cleanly"); + assert!( + connection_cancel.is_cancelled(), + "lost control state must tear down the WebSocket for a fresh roster" + ); + } + #[tokio::test] async fn audio_send_loop_sends_policy_close_when_community_is_deleted() { use futures_util::Sink; diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs index ddadb13f7ff..96cc66b4e07 100644 --- a/crates/buzz-relay/src/audio/join.rs +++ b/crates/buzz-relay/src/audio/join.rs @@ -47,7 +47,7 @@ use dashmap::DashMap; use serde::{Deserialize, Serialize}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use tracing::debug; +use tracing::{debug, warn}; use uuid::Uuid; use super::mesh::spawn_remote_peer_sink; @@ -831,6 +831,11 @@ pub enum HuddleControlMsg { /// Owner-allocated 0..=254 index; the sole allocator is the owner, so /// indices never collide across pods. peer_index: u8, + /// Owner-assigned occupancy epoch for `peer_index`. The non-owner pod + /// stamps this on protocol v3 media datagrams so the frame carries the + /// same `[peer_index][epoch]` identity a same-pod speaker's frame would + /// (see [`RosterEntry::epoch`]). + epoch: u8, /// Complete authoritative roster after this admission. This is in the /// registration reply so no media/client identity can precede it. roster: RosterSnapshot, @@ -880,6 +885,9 @@ pub struct RosterEntry { pub pubkey: String, /// Owner-assigned media routing index. pub peer_index: u8, + /// Occupancy epoch for `peer_index`, bumped each time the index is reused + /// by a new pubkey so stale in-flight media frames can be fenced. + pub epoch: u8, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -896,6 +904,7 @@ impl From for RosterEntry { Self { pubkey: peer.pubkey, peer_index: peer.peer_index, + epoch: peer.epoch, } } } @@ -1292,17 +1301,8 @@ impl HuddleControlAcceptor { self.rooms .get(CommunityId::from_uuid(community_id), session_id) }) { - let peer_index = room.peers.get(&peer_id).map(|peer| peer.peer_index); - room.remove_peer(peer_id); - if let Some(peer_index) = peer_index { - room.broadcast_control( - serde_json::json!({ - "type": "left", - "pubkey": pubkey, - "peer_index": peer_index, - }) - .to_string(), - ); + if let Some(delta) = room.remove_peer(peer_id) { + broadcast_peer_left(&room, delta, session_id); } } } @@ -1351,18 +1351,9 @@ impl HuddleControlAcceptor { self.rooms .get(CommunityId::from_uuid(community_id), session_id) }) { - for (pubkey, peer_id) in registered { - let peer_index = room.peers.get(&peer_id).map(|peer| peer.peer_index); - room.remove_peer(peer_id); - if let Some(peer_index) = peer_index { - room.broadcast_control( - serde_json::json!({ - "type": "left", - "pubkey": pubkey, - "peer_index": peer_index, - }) - .to_string(), - ); + for (_pubkey, peer_id) in registered { + if let Some(delta) = room.remove_peer(peer_id) { + broadcast_peer_left(&room, delta, session_id); } } } @@ -1381,7 +1372,7 @@ impl HuddleControlAcceptor { registered: &mut std::collections::HashMap, ) -> HuddleControlMsg { match room.add_peer(pubkey.to_string(), protocol_version) { - Ok((peer_id, peer_index, audio_rx, _peer_ctrl_rx)) => { + Ok((peer_id, peer_index, epoch, audio_rx, _peer_ctrl_rx, roster_revision)) => { registered.insert(pubkey.to_string(), peer_id); // The owner's Room fans out to this remote peer's `audio_tx`; // the sink drains `audio_rx` and ships each frame as a datagram @@ -1389,15 +1380,18 @@ impl HuddleControlAcceptor { spawn_remote_peer_sink(Arc::clone(&self.transport), from, fenced, audio_rx); let joined = serde_json::json!({ "type": "joined", + "revision": roster_revision, "pubkey": pubkey, "peer_index": peer_index, - "peers": [{"pubkey": pubkey, "peer_index": peer_index}], + "epoch": epoch, + "peers": [{"pubkey": pubkey, "peer_index": peer_index, "epoch": epoch}], }) .to_string(); room.broadcast_control(joined); HuddleControlMsg::PeerRegistered { pubkey: pubkey.to_string(), peer_index, + epoch, roster: roster_snapshot(&room), } } @@ -1409,6 +1403,34 @@ impl HuddleControlAcceptor { } } +fn broadcast_peer_left(room: &Room, delta: RoomRosterDelta, session_id: Uuid) { + let Some(left) = peer_left_control(delta, session_id) else { + return; + }; + room.broadcast_control(left); +} + +fn peer_left_control(delta: RoomRosterDelta, session_id: Uuid) -> Option { + let Some(left) = delta.left else { + warn!( + %session_id, + revision = delta.revision, + "mesh audio peer removal delta did not include the removed peer" + ); + return None; + }; + Some( + serde_json::json!({ + "type": "left", + "revision": delta.revision, + "pubkey": left.pubkey, + "peer_index": left.peer_index, + "epoch": left.epoch, + }) + .to_string(), + ) +} + fn roster_snapshot(room: &Room) -> RosterSnapshot { let snapshot = room.roster_snapshot(); RosterSnapshot { @@ -1471,6 +1493,13 @@ pub struct RemoteHuddleSession { /// The owner-allocated peer index this client occupies in the owner's room. /// Stamped on every media datagram so the owner attributes frames correctly. peer_index: u8, + /// The owner-assigned occupancy epoch for `peer_index`. Stamped alongside + /// `peer_index` on protocol v3 media datagrams so owner-side fan-out + /// produces the same `[peer_index][epoch]` prefix as a same-pod speaker. + epoch: u8, + /// The protocol version negotiated for this client. Cross-pod framing must + /// preserve the released v1/v2 one-byte prefix and add `epoch` only for v3. + protocol_version: u8, /// Latest complete authoritative owner roster. roster: RosterSnapshot, /// Fenced header for this session's owner epoch; every datagram carries it. @@ -1544,7 +1573,7 @@ pub async fn read_owner_control( let json = serde_json::json!({ "type": "roster", "revision": revision, "peers": peers.into_iter().map(|p| serde_json::json!({ - "pubkey": p.pubkey, "peer_index": p.peer_index, + "pubkey": p.pubkey, "peer_index": p.peer_index, "epoch": p.epoch, })).collect::>() }) .to_string(); @@ -1565,13 +1594,13 @@ pub async fn read_owner_control( let json = if let Some(peer) = joined { serde_json::json!({ "type": "joined", "revision": revision, - "pubkey": peer.pubkey, "peer_index": peer.peer_index, - "peers": [{"pubkey": peer.pubkey, "peer_index": peer.peer_index}], + "pubkey": peer.pubkey, "peer_index": peer.peer_index, "epoch": peer.epoch, + "peers": [{"pubkey": peer.pubkey, "peer_index": peer.peer_index, "epoch": peer.epoch}], }) } else if let Some(peer) = left { serde_json::json!({ "type": "left", "revision": revision, - "pubkey": peer.pubkey, "peer_index": peer.peer_index, + "pubkey": peer.pubkey, "peer_index": peer.peer_index, "epoch": peer.epoch, }) } else { continue; @@ -1696,10 +1725,15 @@ pub async fn dial_remote_owner( match stream.recv_frame().await? { Some(MeshStreamFrame::Data { payload, .. }) => match decode_control(&payload)? { HuddleControlMsg::PeerRegistered { - peer_index, roster, .. + peer_index, + epoch, + roster, + .. } => Ok(( RemoteHuddleSession { peer_index, + epoch, + protocol_version, roster, fenced, owner, @@ -1733,6 +1767,14 @@ impl RemoteHuddleSession { self.peer_index } + /// The owner-assigned occupancy epoch for this client's index. Reported to + /// the client alongside `peer_index` so its self-entry matches the owner's + /// authoritative roster (the local ingress mirror's own epoch is inert — + /// the mirror never fans out via `broadcast_frame`). + pub fn epoch(&self) -> u8 { + self.epoch + } + /// Complete authoritative roster returned atomically with registration. pub fn roster(&self) -> &RosterSnapshot { &self.roster @@ -1753,7 +1795,14 @@ impl RemoteHuddleSession { /// with the owner-assigned index. Drop-on-error: realtime audio never blocks /// on a slow or gone link (the same discipline as local fan-out). pub fn forward_media(&mut self, client_frame: &[u8]) { - let dgram = media_datagram(self.peer_index, self.fenced, self.seq, client_frame); + let dgram = media_datagram( + self.peer_index, + self.epoch, + self.protocol_version, + self.fenced, + self.seq, + client_frame, + ); self.seq = self.seq.wrapping_add(1); if let Err(e) = self.transport.send_datagram(self.owner, dgram) { debug!(owner = %self.owner, "huddle media datagram to owner failed: {e}"); @@ -1785,17 +1834,27 @@ pub async fn send_clean_close(stream: &mut MeshStream, fenced: FencedHeader, pub } /// Build the media datagram a non-owner ships to the owner for one client -/// frame: `[owner_peer_index][client frame]`, stamped with the session fence -/// and sequence. Pure so the framing is unit-testable without a live transport -/// or stream. +/// frame, stamped with the session fence and sequence. Protocol v1/v2 retain +/// their released `[owner_peer_index][client frame]` framing; v3 adds the +/// owner-assigned epoch: `[owner_peer_index][epoch][client frame]`. Both are +/// byte-identical to [`super::room::Room::broadcast_frame`]. The owner-side +/// [`super::mesh::MeshAudioRouter::on_media_datagram`] splits off `peer_index` +/// and re-prefixes the opaque remainder. Pure so framing is unit-testable +/// without a live transport or stream. fn media_datagram( peer_index: u8, + epoch: u8, + protocol_version: u8, fenced: FencedHeader, seq: u64, client_frame: &[u8], ) -> MeshDatagram { - let mut payload = Vec::with_capacity(1 + client_frame.len()); + let prefix_len = if protocol_version >= 3 { 2 } else { 1 }; + let mut payload = Vec::with_capacity(prefix_len + client_frame.len()); payload.push(peer_index); + if protocol_version >= 3 { + payload.push(epoch); + } payload.extend_from_slice(client_frame); MeshDatagram { fenced, @@ -1817,6 +1876,21 @@ mod tests { CommunityId::from_uuid(Uuid::from_u128(0xC0FFEE)) } + #[test] + fn missing_peer_in_removal_delta_is_non_fatal() { + assert_eq!( + peer_left_control( + RoomRosterDelta { + revision: 7, + joined: None, + left: None, + }, + Uuid::from_u128(42), + ), + None, + ); + } + /// Scripted directory: `owner_of` returns a queued lookup, `acquire` /// returns a queued outcome, `validate` returns a queued result. Records /// call counts so ordering can be asserted. @@ -2037,11 +2111,13 @@ mod tests { HuddleControlMsg::PeerRegistered { pubkey: "abc123".into(), peer_index: 42, + epoch: 0, roster: RosterSnapshot { revision: 1, peers: vec![RosterEntry { pubkey: "abc123".into(), peer_index: 42, + epoch: 0, }], }, }, @@ -2051,6 +2127,7 @@ mod tests { left: Some(RosterEntry { pubkey: "abc123".into(), peer_index: 42, + epoch: 0, }), }, HuddleControlMsg::RosterResync, @@ -2133,6 +2210,7 @@ mod tests { joined: Some(RosterEntry { pubkey: "bob".into(), peer_index: 7, + epoch: 0, }), left: None, }) @@ -2162,6 +2240,7 @@ mod tests { peers: vec![RosterEntry { pubkey: "bob".into(), peer_index: 7, + epoch: 0, }], }) .unwrap(), @@ -2277,7 +2356,7 @@ mod tests { let fenced = fenced_owned_by(owner_rt, session_id); let rooms = Arc::new(AudioRoomManager::new()); let room = rooms.get_or_create(community(), session_id); - let (_local_id, _local_index, _audio_rx, mut local_ctrl_rx) = + let (_local_id, _local_index, _epoch, _audio_rx, mut local_ctrl_rx, _revision) = room.add_peer("owner-local".into(), 2).unwrap(); // Discard the local peer's own roster delta; this assertion targets the // websocket-compatible control fanout below. @@ -2913,21 +2992,29 @@ mod tests { } #[test] - fn media_datagram_tags_owner_index_and_stamps_fence() { + fn media_datagram_preserves_versioned_prefix_and_stamps_fence() { let fenced = FencedHeader { session_id: Uuid::new_v4(), generation: 9, owner_runtime_id: rt(2), }; - // Owner-assigned index is the first payload byte; client bytes follow. - let d0 = media_datagram(42, fenced, 0, &[0xDE, 0xAD]); - assert_eq!(d0.payload, vec![42, 0xDE, 0xAD]); - assert_eq!(d0.fenced, fenced); - assert_eq!(d0.seq, 0); - // Empty client frame still carries the index byte (owner tolerates it). - let d1 = media_datagram(7, fenced, 3, &[]); - assert_eq!(d1.payload, vec![7]); - assert_eq!(d1.seq, 3); + let client_frame = [0xDE, 0xAD]; + + for protocol_version in [1, 2] { + let legacy = media_datagram(42, 3, protocol_version, fenced, 0, &client_frame); + assert_eq!(legacy.payload, vec![42, 0xDE, 0xAD]); + assert_eq!(legacy.fenced, fenced); + assert_eq!(legacy.seq, 0); + } + + let v3 = media_datagram(42, 3, 3, fenced, 1, &client_frame); + assert_eq!(v3.payload, vec![42, 3, 0xDE, 0xAD]); + assert_eq!(v3.fenced, fenced); + assert_eq!(v3.seq, 1); + + // Empty frames still carry exactly the negotiated prefix. + assert_eq!(media_datagram(7, 9, 2, fenced, 2, &[]).payload, vec![7]); + assert_eq!(media_datagram(7, 9, 3, fenced, 3, &[]).payload, vec![7, 9]); } // ── Non-owner teardown reader: wire signal → HuddleTeardownCause ────────── diff --git a/crates/buzz-relay/src/audio/mesh.rs b/crates/buzz-relay/src/audio/mesh.rs index 1eb62fdcfa3..4c06de37077 100644 --- a/crates/buzz-relay/src/audio/mesh.rs +++ b/crates/buzz-relay/src/audio/mesh.rs @@ -21,14 +21,16 @@ //! //! ## The payload invariant (why this needs no wire change) //! -//! The client sends `[8B v2 header][opaque Opus]`; the relay parses the header -//! for telemetry only and forwards the frame opaquely, and `broadcast_frame` -//! prepends a 1-byte `peer_index`. That `peer_index` is relay-added *routing* -//! metadata — it never touches ciphertext — so the whole byte string -//! `[peer_index][v2 header][Opus]` is exactly what [`MeshDatagram::payload`] is -//! for: opaque to encryption, owned by the routing plane. **peer_index is -//! always the first byte of a media datagram payload, both directions.** The -//! client's WebSocket wire format is byte-identical to a single-pod huddle. +//! Protocol v1/v2 clients send an opaque client frame and receive the released +//! one-byte `[peer_index]` routing prefix. Protocol v3 adds a per-index `epoch`, +//! so its relay-added prefix is `[peer_index][epoch]`. The relay parses v2/v3 +//! frame headers for telemetry only and otherwise forwards client bytes opaquely. +//! Both prefix shapes are routing metadata — they never touch ciphertext — and +//! map directly onto [`MeshDatagram::payload`]. **peer_index is always the first +//! byte of a media datagram payload, both directions**; the remainder is the +//! versioned opaque wire frame and rides unchanged through the split-and-reprefix +//! below. The client's WebSocket wire format stays byte-identical to single-pod +//! fan-out. //! //! ## Room stays pure //! @@ -199,7 +201,8 @@ impl MeshAudioRouter { /// Deliver an inbound media datagram to the addressed local huddle. /// - /// The payload is `[peer_index][v2 header][Opus]` — already prefixed by the + /// The payload is `[peer_index][client frame]` for protocol v1/v2, or + /// `[peer_index][epoch][client frame]` for v3 — already prefixed by the /// sender (the owner, when fanning out to us; or a non-owner client's pod, /// when we are the owner). We fence, then push the payload into every /// *local* peer's audio sink **except** the peer whose index authored it, @@ -235,10 +238,11 @@ impl MeshAudioRouter { warn!(%session_id, "empty media datagram payload — dropping"); return verdict; }; - // Reconstruct the exact on-wire frame the local fan-out uses: - // [peer_index][v2 header][Opus]. `rest` is [v2 header][Opus]; the - // prefix is the author's index. We hand peers the already-prefixed - // bytes and skip re-broadcasting to the author's own index. + // Reconstruct the exact versioned on-wire frame the local fan-out uses. + // `rest` is the opaque client frame for v1/v2, or `[epoch][client frame]` + // for v3; only `peer_index` (the author's routing index) is split off for + // the skip-self check. Hand peers the already-prefixed bytes without + // interpreting the negotiated payload shape. let mut prefixed = bytes::BytesMut::with_capacity(dgram.payload.len()); prefixed.extend_from_slice(&[author_index]); prefixed.extend_from_slice(rest); diff --git a/crates/buzz-relay/src/audio/room.rs b/crates/buzz-relay/src/audio/room.rs index d5c42869883..d2849f3e0bd 100644 --- a/crates/buzz-relay/src/audio/room.rs +++ b/crates/buzz-relay/src/audio/room.rs @@ -2,15 +2,18 @@ //! //! ```text //! Client A → WS binary frame → Room::broadcast_frame → Client B, C, ... -//! (1-byte peer_index prefix) +//! (versioned peer prefix) //! ``` //! -//! Frames are opaque Opus bytes — the relay never decodes audio. -//! `try_send` is used throughout: real-time audio tolerates drops, never queues. +//! Frames are opaque Opus bytes — the relay never decodes audio. Protocol v3 +//! adds the occupancy epoch after the peer index; v1/v2 keep their released +//! one-byte peer prefix. `try_send` is used throughout: real-time audio +//! tolerates drops, never queues. use buzz_core::CommunityId; use bytes::Bytes; use dashmap::DashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::{broadcast, mpsc}; use uuid::Uuid; @@ -26,6 +29,15 @@ pub struct AudioPeer { pub ctrl_tx: mpsc::Sender, /// Stable 0-254 index assigned at join; prefixed onto relayed frames. pub peer_index: u8, + /// Per-index reuse generation. Incremented each time this `peer_index` is + /// (re)assigned to a new occupant, so a frame authored by a departed peer + /// can be told apart from one authored by the peer that later reused the + /// same index. Prefixed onto protocol-v3 relayed frames alongside + /// `peer_index`. + pub epoch: u8, + /// Pinned wire version used to shape outbound relay prefixes without + /// taking the admission mutex on the per-frame audio hot path. + pub protocol_version: u8, } /// Control message for a single peer (separate from audio frames). @@ -45,7 +57,7 @@ const CTRL_CHANNEL_CAPACITY: usize = 32; /// Defense-in-depth cap on peers per room. A room with N peers generates /// N×(N−1) frame copies per 20ms tick — 25 peers = 600 copies/tick, which -/// is reasonable. The 255 index space is the hard limit; this is the soft one. +/// is reasonable. Routing identities rotate through a larger 255-value pool. const MAX_PEERS_PER_ROOM: usize = 25; /// One authoritative owner-roster entry. @@ -55,6 +67,10 @@ pub struct RosterPeer { pub pubkey: String, /// Owner-assigned media routing index. pub peer_index: u8, + /// Per-index reuse generation for `peer_index` (see [`AudioPeer::epoch`]). + /// Carried in roster snapshots/deltas so receivers can fence media frames + /// authored by a prior occupant of the same index. + pub epoch: u8, } /// A complete owner-roster snapshot at one monotonic revision. @@ -78,12 +94,36 @@ pub struct RosterDelta { pub left: Option, } +/// Successful local admission: peer ID, routing index, per-index epoch, +/// audio/control receivers, and the authoritative roster revision assigned to +/// the join. +pub type PeerAdmission = ( + Uuid, + u8, + u8, + mpsc::Receiver, + mpsc::Receiver, + u64, +); + +/// Successful admission at an owner-assigned index: peer ID, per-index epoch, +/// audio/control receivers, and the roster revision. The routing index is +/// omitted because the caller supplied it. +pub type IndexedPeerAdmission = ( + Uuid, + u8, + mpsc::Receiver, + mpsc::Receiver, + u64, +); + /// Reason a peer was refused entry to a room. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdmissionError { /// The room has been ended (or is shutting down) and no longer admits peers. Ended, - /// The room has hit the soft peer cap or exhausted the 255-index space. + /// The room has hit its participant cap or the requested routing identity + /// is already active. Full, /// The room is pinned to a different protocol version than the requested one. /// The caller should reply to the WS client with an `upgrade_required` error @@ -104,8 +144,20 @@ pub enum AdmissionError { /// exclusive with peer admission. This closes the race between the last /// peer's cleanup path and a concurrent joiner. struct AdmissionGuard { - next_fresh: u8, - free: Vec, + /// Next routing identity to probe. Allocation rotates through the complete + /// 0..=254 space so a recently departed identity is not immediately reused, + /// while long-running rooms never consume a finite lifetime admission + /// budget. + next_candidate: u8, + /// Routing identities held by currently connected peers. Owner-assigned + /// mesh identities share this set with locally allocated identities. + active_indices: HashSet, + /// Per-index reuse generation. `next_epoch_for(idx)` returns the epoch to + /// stamp on the next occupant of `idx` and advances the counter, so every + /// (re)assignment of an index gets a distinct, monotonically increasing + /// (mod 256) epoch. A frame carrying a stale epoch for its index was + /// authored by a departed occupant and is fenced by receivers. + index_epochs: HashMap, ended: bool, /// Pinned huddle audio protocol version for this room. /// @@ -132,28 +184,35 @@ struct AdmissionGuard { impl AdmissionGuard { fn new() -> Self { Self { - next_fresh: 0, - free: Vec::new(), + next_candidate: 0, + active_indices: HashSet::new(), + index_epochs: HashMap::new(), ended: false, pinned_version: None, roster_revision: 0, } } - fn alloc(&mut self) -> Option { - if let Some(idx) = self.free.pop() { - return Some(idx); - } - if self.next_fresh == 255 { - return None; + fn alloc(&mut self) -> Option<(u8, u8)> { + for _ in 0..255 { + let idx = self.next_candidate; + self.next_candidate = if idx == 254 { 0 } else { idx + 1 }; + if self.active_indices.insert(idx) { + return Some((idx, self.next_epoch_for(idx))); + } } - let idx = self.next_fresh; - self.next_fresh += 1; - Some(idx) + None } - fn release(&mut self, idx: u8) { - self.free.push(idx); + /// Epoch to stamp on the next occupant of `idx`, advancing the per-index + /// counter. The first occupant of an index gets epoch 0; each later reuse + /// increments (wrapping at 256, which is astronomically larger than the + /// number of in-flight frames a stale occupant could have queued). + fn next_epoch_for(&mut self, idx: u8) -> u8 { + let slot = self.index_epochs.entry(idx).or_insert(0); + let epoch = *slot; + *slot = slot.wrapping_add(1); + epoch } } @@ -229,7 +288,7 @@ impl Room { &self, pubkey: String, requested_version: u8, - ) -> Result<(Uuid, u8, mpsc::Receiver, mpsc::Receiver), AdmissionError> { + ) -> Result { let mut g = self.guard.lock().map_err( |_| AdmissionError::Ended, /* poisoned ≈ shutting down */ )?; @@ -247,7 +306,7 @@ impl Room { }); } } - let peer_index = g.alloc().ok_or(AdmissionError::Full)?; + let (peer_index, epoch) = g.alloc().ok_or(AdmissionError::Full)?; // Pin the room version on the first successful index allocation. We // pin *after* alloc so a Full error doesn't accidentally set the // version for a peer that didn't actually join. @@ -262,17 +321,24 @@ impl Room { audio_tx, ctrl_tx, peer_index, + epoch, + protocol_version: requested_version, }, ); g.roster_revision = g.roster_revision.wrapping_add(1); + let revision = g.roster_revision; let delta = RosterDelta { - revision: g.roster_revision, - joined: Some(RosterPeer { pubkey, peer_index }), + revision, + joined: Some(RosterPeer { + pubkey, + peer_index, + epoch, + }), left: None, }; let _ = self.roster_tx.send(delta); drop(g); // Release lock after ordered roster publication. - Ok((peer_id, peer_index, audio_rx, ctrl_rx)) + Ok((peer_id, peer_index, epoch, audio_rx, ctrl_rx, revision)) } /// Add a non-owner ingress peer at the index already allocated by the @@ -283,14 +349,12 @@ impl Room { pubkey: String, requested_version: u8, peer_index: u8, - ) -> Result<(Uuid, mpsc::Receiver, mpsc::Receiver), AdmissionError> { + ) -> Result { let mut g = self.guard.lock().map_err(|_| AdmissionError::Ended)?; if g.ended { return Err(AdmissionError::Ended); } - if self.peers.len() >= MAX_PEERS_PER_ROOM - || self.peers.iter().any(|peer| peer.peer_index == peer_index) - { + if self.peers.len() >= MAX_PEERS_PER_ROOM || g.active_indices.contains(&peer_index) { return Err(AdmissionError::Full); } if let Some(pinned) = g.pinned_version { @@ -302,13 +366,11 @@ impl Room { } } g.pinned_version.get_or_insert(requested_version); - // Keep a later local allocation from colliding if ownership changes - // while this room is still winding down. Skipped lower indices are a - // bounded handoff cost; a fresh room resets the allocator. - g.free.retain(|idx| *idx != peer_index); - if peer_index >= g.next_fresh { - g.next_fresh = peer_index.saturating_add(1); - } + g.active_indices.insert(peer_index); + let epoch = g.next_epoch_for(peer_index); + // Continue local allocation after the newest owner-assigned identity. + // The cursor wraps, so a high mesh index cannot burn the lower space. + g.next_candidate = if peer_index == 254 { 0 } else { peer_index + 1 }; let peer_id = Uuid::new_v4(); let (audio_tx, audio_rx) = mpsc::channel(AUDIO_CHANNEL_CAPACITY); @@ -320,50 +382,59 @@ impl Room { audio_tx, ctrl_tx, peer_index, + epoch, + protocol_version: requested_version, }, ); g.roster_revision = g.roster_revision.wrapping_add(1); + let revision = g.roster_revision; let delta = RosterDelta { - revision: g.roster_revision, - joined: Some(RosterPeer { pubkey, peer_index }), + revision, + joined: Some(RosterPeer { + pubkey, + peer_index, + epoch, + }), left: None, }; let _ = self.roster_tx.send(delta); drop(g); - Ok((peer_id, audio_rx, ctrl_rx)) + Ok((peer_id, epoch, audio_rx, ctrl_rx, revision)) } - /// Remove a peer and recycle its index. - pub fn remove_peer(&self, peer_id: Uuid) { + /// Remove a peer and release its routing identity for a later allocator + /// rotation. Returns the ordered roster delta when the peer existed. + pub fn remove_peer(&self, peer_id: Uuid) -> Option { let Ok(mut g) = self.guard.lock() else { - return; + return None; }; - if let Some((_, peer)) = self.peers.remove(&peer_id) { - g.release(peer.peer_index); - g.roster_revision = g.roster_revision.wrapping_add(1); - let delta = RosterDelta { - revision: g.roster_revision, - joined: None, - left: Some(RosterPeer { - pubkey: peer.pubkey, - peer_index: peer.peer_index, - }), - }; - let _ = self.roster_tx.send(delta); - drop(g); - } + let (_, peer) = self.peers.remove(&peer_id)?; + g.active_indices.remove(&peer.peer_index); + g.roster_revision = g.roster_revision.wrapping_add(1); + let delta = RosterDelta { + revision: g.roster_revision, + joined: None, + left: Some(RosterPeer { + pubkey: peer.pubkey, + peer_index: peer.peer_index, + epoch: peer.epoch, + }), + }; + let _ = self.roster_tx.send(delta.clone()); + drop(g); + Some(delta) } /// Remove a peer AND atomically check if the room should end. /// If the room is now empty, sets `ended = true` under the same lock - /// acquisition that recycles the index — no window for a concurrent + /// acquisition that removes the peer — no window for a concurrent /// `add_peer` to sneak in between removal and the ended flag. - /// Returns `(peer_index, should_auto_end)`. - pub fn remove_peer_and_check_ended(&self, peer_id: Uuid) -> Option<(u8, bool)> { + /// Returns `(roster_delta, should_auto_end)`. + pub fn remove_peer_and_check_ended(&self, peer_id: Uuid) -> Option<(RosterDelta, bool)> { let mut g = self.guard.lock().ok()?; let (_, peer) = self.peers.remove(&peer_id)?; let peer_index = peer.peer_index; - g.release(peer_index); + g.active_indices.remove(&peer_index); g.roster_revision = g.roster_revision.wrapping_add(1); let delta = RosterDelta { revision: g.roster_revision, @@ -371,6 +442,7 @@ impl Room { left: Some(RosterPeer { pubkey: peer.pubkey, peer_index, + epoch: peer.epoch, }), }; // Only the first task to see empty + !ended wins the auto-end. @@ -382,23 +454,27 @@ impl Room { } else { false }; - let _ = self.roster_tx.send(delta); + let _ = self.roster_tx.send(delta.clone()); drop(g); - Some((peer_index, should_end)) + Some((delta, should_end)) } - /// Fan-out a binary frame to all peers except the sender. - /// Prepends the sender's `peer_index` as a 1-byte prefix. - /// Drops on full buffer — real-time audio never queues. + /// Fan-out a binary frame to all peers except the sender. Protocol v3 + /// prepends the sender's `peer_index` and per-index `epoch`; v1/v2 retain + /// their released one-byte `peer_index` prefix. Drops on full buffer — + /// real-time audio never queues. pub fn broadcast_frame(&self, sender_id: Uuid, frame: Bytes) { - let sender_index = match self.peers.get(&sender_id) { - Some(p) => p.peer_index, + let (sender_index, sender_epoch, protocol_version) = match self.peers.get(&sender_id) { + Some(p) => (p.peer_index, p.epoch, p.protocol_version), None => return, }; - // Prepend peer_index as 1-byte header. - let mut prefixed = bytes::BytesMut::with_capacity(1 + frame.len()); + let prefix_len = if protocol_version >= 3 { 2 } else { 1 }; + let mut prefixed = bytes::BytesMut::with_capacity(prefix_len + frame.len()); prefixed.extend_from_slice(&[sender_index]); + if protocol_version >= 3 { + prefixed.extend_from_slice(&[sender_epoch]); + } prefixed.extend_from_slice(&frame); let prefixed = prefixed.freeze(); @@ -431,19 +507,23 @@ impl Room { /// Send a JSON control message to all peers via the control channel. /// Separate from audio so control is never starved by audio backpressure. /// Control messages (joined/left) are state-bearing — the client's - /// peer_index→pubkey map depends on receiving every one. The channel is - /// sized generously (32 slots) so drops should never happen in practice; - /// if they do, we log a warning so the issue is visible. + /// peer_index→pubkey map depends on receiving every one. Saturation is + /// therefore terminal for that receiver: dropping its sender closes the + /// queue, forcing a reconnect with a fresh authoritative admission snapshot. pub fn broadcast_control(&self, json: String) { - for entry in self.peers.iter() { + for mut entry in self.peers.iter_mut() { if entry .ctrl_tx .try_send(PeerCtrl::Json(json.clone())) .is_err() { + let (replacement_tx, replacement_rx) = mpsc::channel(1); + drop(replacement_rx); + let old_tx = std::mem::replace(&mut entry.ctrl_tx, replacement_tx); + drop(old_tx); tracing::warn!( peer_id = %entry.key(), - "control channel full — dropped state-bearing message (peer map may desync)" + "control channel full — closing receiver for authoritative roster resync" ); } } @@ -466,6 +546,7 @@ impl Room { .map(|e| RosterPeer { pubkey: e.pubkey.clone(), peer_index: e.peer_index, + epoch: e.epoch, }) .collect::>(); peers.sort_by_key(|peer| peer.peer_index); @@ -568,7 +649,7 @@ mod tests { let (_local_id, local_index, ..) = room.add_peer("owner-local".into(), 2).unwrap(); assert_eq!(local_index, 0); - let (remote_id, _audio, _ctrl) = room + let (remote_id, _epoch, _audio, _ctrl, _revision) = room .add_peer_at_index("remote".into(), 2, 7) .expect("owner-assigned index admits"); assert_eq!(room.peers.get(&remote_id).unwrap().peer_index, 7); @@ -580,6 +661,34 @@ mod tests { ); } + #[test] + fn active_owner_assigned_index_cannot_be_readmitted() { + let room = fresh_room(); + let (_remote_id, _epoch, _audio, _ctrl, _revision) = room + .add_peer_at_index("remote".into(), 2, 7) + .expect("owner-assigned index admits"); + + let result = room.add_peer_at_index("replacement".into(), 2, 7); + assert!( + matches!(result, Err(AdmissionError::Full)), + "an active owner-assigned index must not identify another socket" + ); + } + + #[test] + fn owner_assigned_high_index_does_not_exhaust_local_allocation() { + let room = fresh_room(); + let (remote_id, _epoch, _audio, _ctrl, _revision) = room + .add_peer_at_index("remote".into(), 2, 254) + .expect("high owner-assigned index admits"); + room.remove_peer(remote_id).expect("remote peer leaves"); + + let (_local_id, local_index, ..) = room + .add_peer("local".into(), 2) + .expect("a high owner index must not burn lower routing identities"); + assert_eq!(local_index, 0); + } + #[test] fn roster_revisions_are_ordered_and_snapshot_is_authoritative() { let room = fresh_room(); @@ -604,6 +713,7 @@ mod tests { vec![RosterPeer { pubkey: "bob".into(), peer_index: bob_index, + epoch: 0, }] ); } @@ -674,7 +784,7 @@ mod tests { let channel_id = Uuid::new_v4(); let room1 = manager.get_or_create(community_id, channel_id); - let (peer_id, _, _, _) = room1 + let (peer_id, _, _, _, _, _) = room1 .add_peer("alice".to_string(), 2) .expect("first peer admits"); // Last peer leaves and ends the room atomically. @@ -729,27 +839,123 @@ mod tests { ); } - /// Peer-index reuse: after a peer leaves, their index is released; a new - /// peer joining the same (still-pinned) room reuses the freed index. - /// Version pin must persist across this reuse — the room generation - /// hasn't ended. + /// Peer indices rotate instead of being immediately reused, which gives + /// queued media and cleanup work time to drain without imposing a lifetime + /// admission budget on the room. #[test] - fn version_pin_persists_across_peer_churn() { + fn peer_indices_are_not_reused_within_a_room_generation() { let room = fresh_room(); - let (alice_id, alice_idx, _, _) = + let (alice_id, alice_idx, _, _, _, _) = room.add_peer("alice".to_string(), 2).expect("alice admits"); - room.remove_peer(alice_id); - // Room is non-empty thanks to nothing yet — wait, alice left and - // nobody else is here. Add bob with the same version: should work. - // Then add carol with a different version: should fail with the - // *original* pin, even though alice already left. - let (_, bob_idx, _, _) = room + let (_keeper_id, keeper_idx, _, _, _, _) = room + .add_peer("keeper".to_string(), 2) + .expect("keeper admits"); + + room.remove_peer(alice_id).expect("alice leaves"); + let (_, bob_idx, _, _, _, _) = room .add_peer("bob".to_string(), 2) .expect("bob admits at v=2"); + + assert_eq!(alice_idx, 0); + assert_eq!(keeper_idx, 1); + assert_eq!( + bob_idx, 2, + "a departed peer index must not be immediately reused", + ); + } + + /// A reused peer index carries a distinct epoch from its prior occupant, + /// so receivers can fence media authored before the reassignment. Rotation + /// still holds (the index is not immediately reused), but even after the + /// allocator wraps back, the epoch advances. + #[test] + fn reused_peer_index_gets_a_distinct_epoch() { + let room = fresh_room(); + // First occupant of index 0 gets epoch 0. + let (alice_id, alice_index, alice_epoch, ..) = + room.add_peer("alice".into(), 2).expect("alice admits"); + assert_eq!(alice_index, 0); + assert_eq!(alice_epoch, 0); + room.remove_peer(alice_id).expect("alice leaves"); + + // Force the allocator cursor back to 0 so the next admit reuses index 0. + // A single owner-assigned admit at 254 sets next_candidate to wrap to 0. + let (_high_id, high_epoch, ..) = room + .add_peer_at_index("high".into(), 2, 254) + .expect("high owner index admits"); + assert_eq!(high_epoch, 0, "index 254 is a first occupant"); + + let (_bob_id, bob_index, bob_epoch, ..) = + room.add_peer("bob".into(), 2).expect("bob admits"); + assert_eq!(bob_index, 0, "cursor wrapped to reuse index 0"); assert_eq!( - bob_idx, alice_idx, - "freed peer index should be recycled by the next admit", + bob_epoch, 1, + "reused index 0 must advance its epoch past alice's" ); + } + + /// The epoch stamped on a fanned-out v3 frame matches the sender's current + /// per-index epoch. Released v2 retains its one-byte prefix so old v2 + /// clients cannot share a room with v3 clients while decoding different + /// binary layouts under the same negotiated version. + #[test] + fn broadcast_frame_uses_the_prefix_for_the_pinned_version() { + let v2_room = fresh_room(); + let (v2_sender_id, v2_sender_index, ..) = v2_room + .add_peer("v2-sender".into(), 2) + .expect("v2 sender admits"); + let (_v2_listener_id, _, _, mut v2_listener_rx, _, _) = v2_room + .add_peer("v2-listener".into(), 2) + .expect("v2 listener admits"); + v2_room.broadcast_frame(v2_sender_id, Bytes::from_static(&[0xAB, 0xCD])); + let v2_frame = v2_listener_rx + .try_recv() + .expect("v2 listener receives frame"); + assert_eq!(&v2_frame[..1], &[v2_sender_index]); + assert_eq!(&v2_frame[1..], &[0xAB, 0xCD]); + + let v3_room = fresh_room(); + let (v3_sender_id, v3_sender_index, v3_sender_epoch, ..) = v3_room + .add_peer("v3-sender".into(), 3) + .expect("v3 sender admits"); + let (_v3_listener_id, _, _, mut v3_listener_rx, _, _) = v3_room + .add_peer("v3-listener".into(), 3) + .expect("v3 listener admits"); + v3_room.broadcast_frame(v3_sender_id, Bytes::from_static(&[0xAB, 0xCD])); + let v3_frame = v3_listener_rx + .try_recv() + .expect("v3 listener receives frame"); + assert_eq!(&v3_frame[..2], &[v3_sender_index, v3_sender_epoch]); + assert_eq!(&v3_frame[2..], &[0xAB, 0xCD]); + } + + #[test] + fn one_seated_peer_survives_more_than_index_space_reconnects() { + let room = fresh_room(); + let (_keeper_id, keeper_index, ..) = + room.add_peer("keeper".into(), 2).expect("keeper admits"); + + for cycle in 0..300 { + let (peer_id, peer_index, ..) = room + .add_peer(format!("reconnect-{cycle}"), 2) + .unwrap_or_else(|error| panic!("cycle {cycle} must admit: {error:?}")); + assert_ne!(peer_index, keeper_index); + room.remove_peer(peer_id).expect("reconnecting peer leaves"); + } + } + + /// Protocol version pinning persists across peer churn even while routing + /// identities rotate for later reuse. + #[test] + fn version_pin_persists_across_peer_churn() { + let room = fresh_room(); + let (alice_id, _, _, _, _, _) = + room.add_peer("alice".to_string(), 2).expect("alice admits"); + let (_keeper_id, _, _, _, _, _) = room + .add_peer("keeper".to_string(), 2) + .expect("keeper admits"); + room.remove_peer(alice_id); + let err = room .add_peer("carol".to_string(), 1) .expect_err("v=1 must still be refused — room is pinned v=2"); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9629ffb0fd1..dd2fa6e93e0 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -50,6 +50,98 @@ use crate::conformance::{ state_for_request, EmitGuard, TraceAction, Verdict, }; +fn huddle_backing_channel_id(event: &Event) -> Result { + let content: serde_json::Value = serde_json::from_str(&event.content).map_err(|_| { + IngestError::Rejected("invalid: Huddle event content must be a JSON object".into()) + })?; + let channel_id = content + .get("ephemeral_channel_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + IngestError::Rejected("invalid: Huddle event must name an ephemeral_channel_id".into()) + })?; + channel_id.parse::().map_err(|_| { + IngestError::Rejected("invalid: Huddle ephemeral_channel_id must be a UUID".into()) + }) +} + +fn map_huddle_backing_channel_error(error: buzz_db::DbError) -> IngestError { + match error { + buzz_db::DbError::ChannelNotFound(_) => { + IngestError::Rejected("invalid: Huddle backing channel not found".into()) + } + error => IngestError::Internal(format!("error: loading Huddle backing channel: {error}")), + } +} + +fn expected_huddle_backing_ttl(ephemeral_ttl_override: Option) -> i32 { + ephemeral_ttl_override.unwrap_or(3600) +} + +async fn validate_huddle_lifecycle_event( + tenant: &TenantContext, + state: &AppState, + event: &Event, + kind: u32, +) -> Result<(), IngestError> { + if kind != KIND_HUDDLE_STARTED && kind != KIND_HUDDLE_ENDED { + return Ok(()); + } + + let backing_channel_id = huddle_backing_channel_id(event)?; + let backing = state + .db + .get_channel(tenant.community(), backing_channel_id) + .await + .map_err(map_huddle_backing_channel_error)?; + let signer = event.pubkey.to_bytes(); + let relay = state.relay_keypair.public_key().to_bytes(); + let signer_created_backing = backing.created_by.as_slice() == signer.as_slice(); + + if kind == KIND_HUDDLE_STARTED { + let expected_ttl = expected_huddle_backing_ttl(state.config.ephemeral_ttl_override); + if !signer_created_backing + || backing.channel_type != "stream" + || backing.visibility != "private" + || backing.ttl_seconds != Some(expected_ttl) + || backing.archived_at.is_some() + { + return Err(IngestError::Rejected( + "invalid: Huddle start must reference the signer's active private ephemeral stream" + .into(), + )); + } + } else { + if !signer_created_backing && signer.as_slice() != relay.as_slice() { + return Err(IngestError::Rejected( + "invalid: only the Huddle creator or relay may end it".into(), + )); + } + let parent_channel_id = extract_channel_id(event).ok_or_else(|| { + IngestError::Rejected("invalid: Huddle end must name its parent channel".into()) + })?; + let linked = state + .db + .huddle_started_link_exists( + tenant.community(), + parent_channel_id, + backing_channel_id, + &backing.created_by, + ) + .await + .map_err(|error| { + IngestError::Internal(format!("error: checking Huddle start linkage: {error}")) + })?; + if !linked { + return Err(IngestError::Rejected( + "invalid: Huddle end does not match a creator-signed start in this channel".into(), + )); + } + } + + Ok(()) +} + fn validate_custom_emoji_tags(event: &Event) -> Result<(), IngestError> { for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -2560,6 +2652,8 @@ async fn ingest_event_inner( }); } + validate_huddle_lifecycle_event(tenant, state, &event, kind_u32).await?; + if crate::handlers::side_effects::is_admin_kind(kind_u32) { crate::handlers::side_effects::validate_admin_event(tenant, kind_u32, &event, state) .await @@ -3193,6 +3287,61 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + #[test] + fn missing_huddle_backing_channel_is_a_client_rejection() { + let channel_id = Uuid::new_v4(); + assert!(matches!( + map_huddle_backing_channel_error(buzz_db::DbError::ChannelNotFound(channel_id)), + IngestError::Rejected(message) if message.contains("backing channel not found") + )); + } + + #[test] + fn huddle_backing_channel_lookup_outage_is_internal() { + let error = sqlx::Error::Io(std::io::Error::other("database unavailable")); + assert!(matches!( + map_huddle_backing_channel_error(buzz_db::DbError::Sqlx(error)), + IngestError::Internal(message) if message.contains("loading Huddle backing channel") + )); + } + + #[test] + fn huddle_backing_ttl_honors_the_ephemeral_override() { + assert_eq!(expected_huddle_backing_ttl(None), 3600); + assert_eq!(expected_huddle_backing_ttl(Some(60)), 60); + } + + #[test] + fn huddle_lifecycle_requires_a_uuid_backing_channel() { + let event = EventBuilder::new( + Kind::Custom(KIND_HUDDLE_STARTED as u16), + r#"{"ephemeral_channel_id":"not-a-uuid"}"#, + ) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign Huddle event"); + + assert!(matches!( + huddle_backing_channel_id(&event), + Err(IngestError::Rejected(message)) if message.contains("must be a UUID") + )); + } + + #[test] + fn huddle_lifecycle_extracts_the_backing_channel() { + let channel_id = Uuid::new_v4(); + let event = EventBuilder::new( + Kind::Custom(KIND_HUDDLE_ENDED as u16), + serde_json::json!({"ephemeral_channel_id": channel_id}).to_string(), + ) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign Huddle event"); + + assert_eq!( + huddle_backing_channel_id(&event).expect("channel id"), + channel_id + ); + } + #[test] fn reaction_validation_accepts_wrapped_max_shortcode() { let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); diff --git a/desktop/package.json b/desktop/package.json index 683c1529d25..4a6bdcd7f56 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.17", + "version": "0.5.18", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5bd19ef2b6d..fb60a351895 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.17" +version = "0.5.18" dependencies = [ "anyhow", "arboard", @@ -3080,9 +3080,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -3090,9 +3090,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" @@ -3107,9 +3107,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -3126,32 +3126,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -10239,6 +10239,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" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index db089fc13fa..3f7189deea1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.17" +version = "0.5.18" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 5f5019cd20c..7c41f6bfe26 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -129,6 +129,7 @@ pub struct AppState { /// bounded and letting a later leave correctly flip the channel back to /// `is_member=false`. pub pending_owned_channels: Mutex>, + pub archive_db: crate::archive::ArchiveDb, } /// Parse the `BUZZ_PRIVATE_KEY` env var into identity keys. `Some` means the @@ -219,9 +220,7 @@ pub fn build_app_state() -> AppState { huddle_audio: Default::default(), app_handle: Mutex::new(None), media_proxy_port: AtomicU16::new(0), - prevent_sleep: Arc::new(Mutex::new( - crate::prevent_sleep::PreventSleepState::default(), - )), + prevent_sleep: Default::default(), keyring_locked: AtomicBool::new(false), identity_lost: AtomicBool::new(false), reset_failed: AtomicBool::new(false), @@ -232,6 +231,7 @@ pub fn build_app_state() -> AppState { #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), pending_owned_channels: Mutex::new(std::collections::HashSet::new()), + archive_db: crate::archive::ArchiveDb::default(), } } diff --git a/desktop/src-tauri/src/archive/archive_db.rs b/desktop/src-tauri/src/archive/archive_db.rs new file mode 100644 index 00000000000..7c69591b0f6 --- /dev/null +++ b/desktop/src-tauri/src/archive/archive_db.rs @@ -0,0 +1,199 @@ +//! Process-wide gated adapter for the local archive SQLite database. +//! +//! Two coupled guarantees, both required by plan v3 (decisions 1–2): +//! +//! 1. **Init barrier.** Exactly one blocking task opens the DB the first time +//! and completes every schema migration (including M4, whose index build +//! over Will's 1.3M-row archive is not free). Every production open — every +//! Tauri archive command and all future startup/prune work — `await`s that +//! result before touching its own connection. The barrier is independent of +//! identity/relay resolution: the archive DB is a single per-nest file +//! (identity is a row column, not part of the path), and the path resolves +//! from `nest_dir()`, which is fixed early in `setup()` before the async +//! workspace relay override settles. This satisfies Thufir's binding +//! condition that the barrier cover workspace/identity timing and the +//! globally-mounted observer archive producer. +//! +//! 2. **Maintenance lock.** A shared `RwLock` whose read guard is held for the +//! full lifetime of every ordinary connection (acquired before the blocking +//! dispatch, released only after the closure returns and its connection is +//! dropped). The Phase-4 "reclaim space" conversion will take the *write* +//! guard for its sole-connection `VACUUM` sequence; nothing else may hold a +//! live connection while that runs. Phase 1 only ever takes the read guard, +//! but the lock lives here so the write path has a home. +//! +//! M4 additionally keeps its own `BEGIN IMMEDIATE` + in-lock recheck for crash +//! and cross-process safety — the in-process barrier serializes this process's +//! opens, but a second OS process (or a direct `open_archive_db` in tests) can +//! still race the first open. The two mechanisms are complementary, not +//! redundant: the barrier is startup orchestration, the immediate-lock is +//! durability. + +use std::path::PathBuf; + +use rusqlite::Connection; +use tokio::sync::{OnceCell, RwLock}; + +use super::store; +use crate::managed_agents::nest_dir; + +/// A hook run once on the blocking pool at the start of the single init task. +/// Test-only: lets a test count initializations and hold the winner long +/// enough to prove concurrent callers await it. Never set in production. +#[cfg(test)] +type InitHook = std::sync::Arc; + +/// Test-only overrides so the barrier and guard-lifetime contracts can be +/// exercised without a real nest: a fixed DB path in place of `nest_dir()` and +/// an optional init hook. `Default` leaves this `None`, so production always +/// resolves the path from the nest and runs no hook. +#[cfg(test)] +struct TestSeam { + path: PathBuf, + on_init: Option, +} + +/// Gated owner of every production archive DB connection. Lives in +/// [`crate::app_state::AppState`]; commands call [`ArchiveDb::with_conn`]. +#[derive(Default)] +pub struct ArchiveDb { + /// Set to `()` once the first open (which runs all migrations incl. M4) + /// succeeds. A failed init is NOT cached — the next caller retries — so a + /// transient error (e.g. a briefly unavailable external volume) does not + /// wedge the archive for the process lifetime. + init: OnceCell<()>, + /// Maintenance lock. Ordinary connections hold the read guard for their + /// whole lifetime; the Phase-4 conversion holds the write guard. + maintenance: RwLock<()>, + /// Test-only path/hook overrides; always `None` in production. + #[cfg(test)] + test_seam: Option, +} + +impl ArchiveDb { + /// Resolve the archive DB path. Production resolves from the nest + /// directory; a test seam (when present) supplies a fixed path so the + /// barrier can be exercised without a real nest. Errors only when the nest + /// cannot be resolved (fatal for archive access, same as the former + /// `open_db`). + fn db_path(&self) -> Result { + #[cfg(test)] + if let Some(seam) = &self.test_seam { + return Ok(seam.path.clone()); + } + let nest = nest_dir().ok_or("cannot resolve nest directory for archive")?; + Ok(nest.join("archive").join("archive.db")) + } + + /// The init hook, if a test installed one; always `None` in production. + #[cfg(test)] + fn init_hook(&self) -> Option { + self.test_seam.as_ref().and_then(|s| s.on_init.clone()) + } + + /// Complete the one-time init: open the DB once on the blocking pool, + /// running `SCHEMA` + all migrations (incl. M4), then drop the connection. + /// Concurrent callers await the same single execution. Idempotent and + /// cheap after the first success (the cached `()` short-circuits). + async fn ensure_initialized(&self) -> Result<(), String> { + let path = self.db_path()?; + #[cfg(test)] + let hook = self.init_hook(); + self.init + .get_or_try_init(|| async { + tokio::task::spawn_blocking(move || { + // Test hook runs at the very start of the single init task, + // before the migration opens the DB — this is where a test + // holds the winner past the busy timeout to prove ordinary + // callers await it. No-op in production. + #[cfg(test)] + if let Some(hook) = hook { + hook(); + } + // Opening runs every migration; the connection exists only + // to complete them behind the barrier, so drop it here. + let conn = store::open_archive_db(&path)?; + drop(conn); + Ok::<(), String>(()) + }) + .await + .map_err(|e| format!("archive init task failed: {e}"))? + }) + .await + .map(|_| ()) + } + + /// Warm the init barrier without running a query. Called once from + /// `setup()` so the first-open migration cost (M4's index build over a + /// large archive) is paid at startup rather than blocking a user's first + /// archive command. A failure here is non-fatal — the first real + /// [`with_conn`](Self::with_conn) caller retries and surfaces the error. + pub async fn warm_init(&self) -> Result<(), String> { + self.ensure_initialized().await + } + + /// Run `task` against a fresh archive connection on the blocking pool. + /// + /// Ordering: await the init barrier → acquire the maintenance read guard → + /// dispatch the blocking closure with its own connection. The read guard is + /// held across the `.await` on the blocking join, so it is released only + /// after `task` returns and the connection it borrowed has dropped — the + /// guard-lifetime contract the Phase-4 write path depends on. + pub async fn with_conn(&self, task: F) -> Result + where + T: Send + 'static, + F: FnOnce(&Connection) -> Result + Send + 'static, + { + self.ensure_initialized().await?; + let path = self.db_path()?; + let _guard = self.maintenance.read().await; + tokio::task::spawn_blocking(move || { + let conn = store::open_archive_db(&path)?; + task(&conn) + }) + .await + .map_err(|e| format!("archive db task failed: {e}"))? + } +} + +#[cfg(test)] +impl ArchiveDb { + /// Build an adapter bound to a fixed DB path (no nest required), so the + /// barrier and guard-lifetime contracts can be exercised in isolation. + fn with_test_path(path: PathBuf) -> Self { + Self { + init: OnceCell::new(), + maintenance: RwLock::new(()), + test_seam: Some(TestSeam { + path, + on_init: None, + }), + } + } + + /// Build an adapter bound to a fixed path whose single initialization runs + /// `hook` first — used to count initializations and to hold the init task + /// open across the concurrent-caller window. + fn with_test_hook(path: PathBuf, hook: InitHook) -> Self { + Self { + init: OnceCell::new(), + maintenance: RwLock::new(()), + test_seam: Some(TestSeam { + path, + on_init: Some(hook), + }), + } + } + + /// Whether the maintenance WRITE guard can be taken right now. A live + /// `with_conn` connection holds the read guard, so this returns `false` + /// while any ordinary connection is open and `true` once all have dropped — + /// exactly the signal the Phase-4 sole-connection VACUUM will gate on. + fn maintenance_write_available(&self) -> bool { + self.maintenance.try_write().is_ok() + } +} + +#[cfg(test)] +#[path = "archive_db_tests.rs"] +mod archive_db_tests; diff --git a/desktop/src-tauri/src/archive/archive_db_tests.rs b/desktop/src-tauri/src/archive/archive_db_tests.rs new file mode 100644 index 00000000000..f8182f829ce --- /dev/null +++ b/desktop/src-tauri/src/archive/archive_db_tests.rs @@ -0,0 +1,246 @@ +//! Behavior tests for the [`ArchiveDb`] init barrier and maintenance-lock +//! guard lifetime — the two contracts Phase 1 introduced and Thufir's pass-1 +//! review required to be pinned directly (not via raw SQLite contention). +//! +//! These race PRODUCTION-shaped `with_conn` callers through the real +//! `OnceCell`/`RwLock` orchestration, using the `#[cfg(test)]` path/hook seam +//! on `ArchiveDb` to make timing deterministic instead of relying on a large +//! on-disk fixture or wall-clock sleeps to approach the 5s busy timeout. + +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +/// A one-shot latch that blocks a blocking-pool thread until the test releases +/// it. Deterministic stand-in for "the M4 winner holds init open past the busy +/// timeout": the init/closure thread parks here, the test observes the frozen +/// state, then releases. `Mutex` + `Condvar` are `Sync`, so an `Arc` +/// captured by the `Send + Sync` init hook type-checks. +struct Latch { + open: Mutex, + cv: Condvar, +} + +impl Latch { + fn new() -> Arc { + Arc::new(Self { + open: Mutex::new(false), + cv: Condvar::new(), + }) + } + + /// Block until [`release`](Self::release) is called (returns immediately if + /// already released). + fn wait(&self) { + let mut open = self.open.lock().unwrap(); + while !*open { + open = self.cv.wait(open).unwrap(); + } + } + + fn release(&self) { + *self.open.lock().unwrap() = true; + self.cv.notify_all(); + } +} + +/// Poll `cond` on the async runtime until it holds or `timeout` elapses. +/// Panics on timeout so a broken barrier surfaces as a failure, never a hang. +async fn await_until(what: &str, timeout: Duration, cond: impl Fn() -> bool) { + let deadline = Instant::now() + timeout; + while !cond() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +/// An archive DB path inside a fresh temp dir. The dir is returned so the +/// caller keeps it alive for the whole test (dropping it deletes the file). +fn temp_db() -> (TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("archive").join("archive.db"); + (dir, path) +} + +/// The barrier serializes first-open: exactly one initialization runs while +/// every concurrent `with_conn` caller awaits it, and no ordinary connection +/// opens until that initialization has completed. +/// +/// The init hook holds the single init task open on a latch. While it is held +/// we prove no `with_conn` caller has opened its connection (`open_count == 0`) +/// — impossible if callers bypassed the `OnceCell` and opened independently. +/// Releasing the latch lets init finish; all callers then complete, exactly +/// one initialization ran, and every open happened after init. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_first_open_barrier_serializes_init_and_defers_opens() { + let (_dir, path) = temp_db(); + let init_count = Arc::new(AtomicUsize::new(0)); + let open_count = Arc::new(AtomicUsize::new(0)); + let release = Latch::new(); + + let hook = { + let init_count = Arc::clone(&init_count); + let release = Arc::clone(&release); + Arc::new(move || { + // Runs once, at the head of the single init task. Record the + // initialization, then park so the test can inspect the frozen + // pre-open state. + init_count.fetch_add(1, Ordering::SeqCst); + release.wait(); + }) as InitHook + }; + let db = Arc::new(ArchiveDb::with_test_hook(path, hook)); + + // Trigger the single init and wait until it is provably in flight (hook + // ran) and parked on the latch. + let warm = { + let db = Arc::clone(&db); + tokio::spawn(async move { db.warm_init().await }) + }; + await_until("init to start", Duration::from_secs(10), || { + init_count.load(Ordering::SeqCst) == 1 + }) + .await; + + // Fan out production-shaped callers while init is held. Each records that + // its task was scheduled (`entered`) and that its closure actually opened a + // connection (`open_count`). + let entered = Arc::new(AtomicUsize::new(0)); + let callers: Vec<_> = (0..4) + .map(|_| { + let db = Arc::clone(&db); + let open_count = Arc::clone(&open_count); + let entered = Arc::clone(&entered); + tokio::spawn(async move { + entered.fetch_add(1, Ordering::SeqCst); + db.with_conn(move |conn| { + open_count.fetch_add(1, Ordering::SeqCst); + // Touch the migrated schema to prove a usable connection. + conn.query_row("SELECT COUNT(*) FROM archive_meta", [], |r| { + r.get::<_, i64>(0) + }) + .map_err(|e| e.to_string()) + }) + .await + }) + }) + .collect(); + + // All four caller tasks are scheduled and running before we judge the + // barrier: they have entered `with_conn` and can only be parked on the + // init `OnceCell`. Without the barrier they would instead open independent + // connections here and bump `open_count` while init is still held. + await_until("callers to be scheduled", Duration::from_secs(10), || { + entered.load(Ordering::SeqCst) == 4 + }) + .await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!( + open_count.load(Ordering::SeqCst), + 0, + "no ordinary connection may open until initialization completes" + ); + + // Let init finish; every caller now completes against the migrated DB. + release.release(); + assert!(warm.await.unwrap().is_ok(), "warm init must succeed"); + for caller in callers { + assert!( + caller.await.unwrap().is_ok(), + "every with_conn must succeed" + ); + } + + assert_eq!( + init_count.load(Ordering::SeqCst), + 1, + "exactly one initialization ran behind the barrier" + ); + assert_eq!( + open_count.load(Ordering::SeqCst), + 4, + "all callers opened, and only after init" + ); +} + +/// The maintenance read guard lives for the FULL lifetime of a `with_conn` +/// connection: a write-lock contender cannot enter until the closure returns +/// and its connection has dropped. This is the invariant the Phase-4 +/// sole-connection VACUUM depends on. +/// +/// A `with_conn` closure parks on a latch while holding its connection; a +/// separate task contends for the maintenance write guard. While the closure +/// is parked the writer must be blocked. Releasing the closure — which returns +/// and drops the connection — lets the writer finally acquire the guard. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_with_conn_read_guard_blocks_writer_until_connection_drops() { + let (_dir, path) = temp_db(); + let db = Arc::new(ArchiveDb::with_test_path(path)); + db.warm_init().await.expect("init must succeed"); + + let in_closure = Arc::new(AtomicUsize::new(0)); + let hold = Latch::new(); + + // A live connection: the closure parks holding it (and thus the read + // guard) until released. + let work = { + let db = Arc::clone(&db); + let in_closure = Arc::clone(&in_closure); + let hold = Arc::clone(&hold); + tokio::spawn(async move { + db.with_conn(move |_conn| { + in_closure.fetch_add(1, Ordering::SeqCst); + hold.wait(); + Ok(()) + }) + .await + }) + }; + await_until( + "closure to hold the connection", + Duration::from_secs(10), + || in_closure.load(Ordering::SeqCst) == 1, + ) + .await; + + // A genuine write-lock contender. + let write_entered = Arc::new(AtomicUsize::new(0)); + let writer = { + let db = Arc::clone(&db); + let write_entered = Arc::clone(&write_entered); + tokio::spawn(async move { + let _w = db.maintenance.write().await; + write_entered.fetch_add(1, Ordering::SeqCst); + }) + }; + + // While the connection is held, the writer must not have entered, and the + // write guard must be unavailable. + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + write_entered.load(Ordering::SeqCst), + 0, + "writer must block while a with_conn connection is live" + ); + assert!( + !db.maintenance_write_available(), + "write guard is unavailable while the read guard is held" + ); + + // Release the closure: it returns and its connection drops, releasing the + // read guard so the writer can proceed. + hold.release(); + assert!(work.await.unwrap().is_ok(), "held with_conn must succeed"); + writer.await.unwrap(); + assert_eq!( + write_entered.load(Ordering::SeqCst), + 1, + "writer enters once the connection has dropped" + ); + assert!( + db.maintenance_write_available(), + "write guard is free again after the connection drops" + ); +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 81bc2133528..1b246b3fa23 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -18,12 +18,16 @@ //! == agent) is applied fail-closed. mod agent_usage; +mod archive_db; mod metric_store; mod pipeline; +pub mod retention; pub mod store; mod store_migrations; pub mod sync; +pub use archive_db::ArchiveDb; + use pipeline::{commit_archive, plan_archive, query_buckets}; use nostr::Event; @@ -32,7 +36,6 @@ use serde::{Deserialize, Serialize}; use tauri::State; use crate::app_state::AppState; -use crate::managed_agents::nest_dir; use crate::relay::{query_relay, relay_ws_url_with_override}; // ── Constants ─────────────────────────────────────────────────────────────── @@ -43,10 +46,20 @@ const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; // ── DB helpers ─────────────────────────────────────────────────────────────── -fn open_db() -> Result { - let nest = nest_dir().ok_or("cannot resolve nest directory for archive")?; - let db_path = nest.join("archive").join("archive.db"); - store::open_archive_db(&db_path) +/// Warm the archive DB init barrier on a background task, now that the nest +/// exists, so the first-open schema migration cost (M4's index build over a +/// large archive) is paid at startup rather than blocking a user's first +/// archive command. The globally-mounted observer archive producer also +/// `await`s this barrier before its first write, so warming it early avoids a +/// stall on the first observer frame. Non-fatal: the first real archive command +/// retries and surfaces any error. +pub fn spawn_warm_init(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + use tauri::Manager; + if let Err(error) = app.state::().archive_db.warm_init().await { + eprintln!("buzz-desktop: archive DB init deferred: {error}"); + } + }); } fn identity_pubkey(state: &AppState) -> Result { @@ -61,19 +74,6 @@ fn now_secs() -> i64 { .as_secs() as i64 } -async fn run_archive_db_task(task: F) -> Result -where - T: Send + 'static, - F: FnOnce(&Connection) -> Result + Send + 'static, -{ - tokio::task::spawn_blocking(move || { - let conn = open_db()?; - task(&conn) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - // ── Scope type ─────────────────────────────────────────────────────────────── /// The three supported archive scope discriminants. @@ -170,10 +170,10 @@ pub(crate) async fn archive_candidates( // ── Phase 1: plan (blocking SQLite) ───────────────────────────────────── let plan_identity_pk = identity_pk.clone(); let plan_relay_url = relay_url.clone(); - let plan = run_archive_db_task(move |conn| { - plan_archive(candidates, &plan_identity_pk, &plan_relay_url, conn) - }) - .await?; + let plan = state + .archive_db + .with_conn(move |conn| plan_archive(candidates, &plan_identity_pk, &plan_relay_url, conn)) + .await?; // ── Phase 2: relay queries (async) ─────────────────────────────────────── let bucket_results = query_buckets(plan.buckets, state).await; @@ -186,19 +186,21 @@ pub(crate) async fn archive_candidates( }; let commit_identity_pk = identity_pk.clone(); let commit_relay_url = relay_url.clone(); - run_archive_db_task(move |conn| { - commit_archive( - bucket_results, - plan.ephemeral, - plan.pre_dropped, - &commit_identity_pk, - &commit_relay_url, - &owner_keys, - now, - conn, - ) - }) - .await + state + .archive_db + .with_conn(move |conn| { + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &commit_identity_pk, + &commit_relay_url, + &owner_keys, + now, + conn, + ) + }) + .await } /// Validate an ephemeral observer frame (kind 24200) against ALL local rules. @@ -337,16 +339,21 @@ pub async fn create_save_subscription( let kinds_json = serde_json::to_string(&kinds).map_err(|e| format!("failed to serialize kinds: {e}"))?; - let conn = open_db()?; - store::upsert_save_subscription( - &conn, - &identity_pk, - &relay_url, - scope_type.as_str(), - &scope_value, - &kinds_json, - now, - )?; + let scope_type_str = scope_type.as_str().to_string(); + state + .archive_db + .with_conn(move |conn| { + store::upsert_save_subscription( + conn, + &identity_pk, + &relay_url, + &scope_type_str, + &scope_value, + &kinds_json, + now, + ) + }) + .await?; sync_state.notify_subscriptions_changed().await; Ok(()) } @@ -452,10 +459,12 @@ pub async fn merge_save_subscription_kinds( let relay_url = relay_ws_url_with_override(&state); let now = now_secs(); let owner_pk = identity_pk.clone(); - run_archive_db_task(move |conn| { - store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now) - }) - .await?; + state + .archive_db + .with_conn(move |conn| { + store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now) + }) + .await?; sync_state.notify_subscriptions_changed().await; Ok(()) } @@ -487,10 +496,12 @@ pub async fn remove_save_subscription_kind( let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); let owner_pk = identity_pk.clone(); - run_archive_db_task(move |conn| { - store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind) - }) - .await?; + state + .archive_db + .with_conn(move |conn| { + store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind) + }) + .await?; sync_state.notify_subscriptions_changed().await; Ok(()) } @@ -504,7 +515,9 @@ pub async fn list_save_subscriptions( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| store::list_save_subscriptions(conn, &identity_pk, &relay_url)) + state + .archive_db + .with_conn(move |conn| store::list_save_subscriptions(conn, &identity_pk, &relay_url)) .await } @@ -523,16 +536,18 @@ pub async fn delete_save_subscription( ) -> Result { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let removed = run_archive_db_task(move |conn| { - store::delete_save_subscription( - conn, - &identity_pk, - &relay_url, - scope_type.as_str(), - &scope_value, - ) - }) - .await?; + let removed = state + .archive_db + .with_conn(move |conn| { + store::delete_save_subscription( + conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + ) + }) + .await?; if removed { sync_state.notify_subscriptions_changed().await; } @@ -564,18 +579,20 @@ pub async fn read_archived_observer_events_for_channel( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| { - store::read_archived_observer_events_for_channel( - conn, - &identity_pk, - &relay_url, - &channel_id, - before_created_at, - before_id.as_deref(), - limit.unwrap_or(DEFAULT_READ_LIMIT), - ) - }) - .await + state + .archive_db + .with_conn(move |conn| { + store::read_archived_observer_events_for_channel( + conn, + &identity_pk, + &relay_url, + &channel_id, + before_created_at, + before_id.as_deref(), + limit.unwrap_or(DEFAULT_READ_LIMIT), + ) + }) + .await } // ── index_observer_channel_id ───────────────────────────────────────────────── @@ -595,20 +612,22 @@ pub async fn index_observer_channel_id( ) -> Result<(), String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| { - for entry in &entries { - store::upsert_observer_channel_index( - conn, - &identity_pk, - &relay_url, - &entry.event_id, - entry.channel_id.as_deref(), - entry.created_at, - )?; - } - Ok(()) - }) - .await + state + .archive_db + .with_conn(move |conn| { + for entry in &entries { + store::upsert_observer_channel_index( + conn, + &identity_pk, + &relay_url, + &entry.event_id, + entry.channel_id.as_deref(), + entry.created_at, + )?; + } + Ok(()) + }) + .await } /// A single (event_id, channel_id?, created_at) record used by @@ -639,18 +658,20 @@ pub async fn read_unindexed_observer_rows( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| { - let rows = store::read_unindexed_observer_rows(conn, &identity_pk, &relay_url)?; - Ok(rows - .into_iter() - .map(|(id, raw_json, created_at)| RawObserverRow { - id, - raw_json, - created_at, - }) - .collect()) - }) - .await + state + .archive_db + .with_conn(move |conn| { + let rows = store::read_unindexed_observer_rows(conn, &identity_pk, &relay_url)?; + Ok(rows + .into_iter() + .map(|(id, raw_json, created_at)| RawObserverRow { + id, + raw_json, + created_at, + }) + .collect()) + }) + .await } /// Wire type returned by `read_unindexed_observer_rows`. @@ -695,20 +716,22 @@ pub async fn read_archived_events( let relay_url = relay_ws_url_with_override(&state); let scope_type_str = scope_type.as_str().to_string(); let read_limit = limit.unwrap_or(DEFAULT_READ_LIMIT); - run_archive_db_task(move |conn| { - store::read_archived_events( - conn, - &identity_pk, - &relay_url, - &scope_type_str, - &scope_value, - kinds.as_deref(), - before_created_at, - before_id.as_deref(), - read_limit, - ) - }) - .await + state + .archive_db + .with_conn(move |conn| { + store::read_archived_events( + conn, + &identity_pk, + &relay_url, + &scope_type_str, + &scope_value, + kinds.as_deref(), + before_created_at, + before_id.as_deref(), + read_limit, + ) + }) + .await } // ── get_agent_usage_series ─────────────────────────────────────────────────── @@ -797,10 +820,48 @@ pub async fn get_agent_usage_series( ) -> Result { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| agent_usage_series(conn, &identity_pk, &relay_url, &request)) + state + .archive_db + .with_conn(move |conn| agent_usage_series(conn, &identity_pk, &relay_url, &request)) + .await +} + +// ── Retention configuration commands ────────────────────────────────────────── + +/// Read the global observer-frame (kind 24200) retention window, in days. Every +/// other archived kind — NIP-AM metrics and any custom subscription — is kept +/// indefinitely and has no setting. +#[tauri::command] +pub async fn get_observer_retention_days(state: State<'_, AppState>) -> Result { + state + .archive_db + .with_conn(retention::get_observer_retention_days) + .await +} + +/// Set the global observer-frame retention window, in days. Fail-closed: the +/// store layer rejects zero, negative, or out-of-range values (see +/// [`retention::validate_days`]). +#[tauri::command] +pub async fn set_observer_retention_days( + state: State<'_, AppState>, + days: i64, +) -> Result<(), String> { + state + .archive_db + .with_conn(move |conn| retention::set_observer_retention_days(conn, days)) .await } +/// Physical (file) and logical (page) size accounting for the archive DB, for +/// the Settings size readout. PRAGMAs + file metadata only — no payload scans. +#[tauri::command] +pub async fn archive_size_stats( + state: State<'_, AppState>, +) -> Result { + state.archive_db.with_conn(retention::size_stats).await +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/archive/retention.rs b/desktop/src-tauri/src/archive/retention.rs new file mode 100644 index 00000000000..5ee9acff200 --- /dev/null +++ b/desktop/src-tauri/src/archive/retention.rs @@ -0,0 +1,216 @@ +//! Local archive retention configuration + size accounting. +//! +//! v4 (Will's ruling, 2026-08-19): retention is a single global setting — how +//! many days observer frames (kind 24200) are kept locally. NIP-AM metrics +//! (44200) and every other archived kind are kept indefinitely with no +//! retention machinery. The setting lives as one row in the `archive_meta` k/v +//! table (`observer_retention_days`), seeded by migration M4. +//! +//! This module owns the `archive_meta` schema, the scope-age index used by the +//! Phase-2 prune scan, the get/set accessors for the observer window, and the +//! PRAGMA-based size readout. The prune worker itself lands in Phase 2. +//! +//! Kept in a sibling file (not `store.rs`) to respect the 1000-line gate, per +//! the existing `metric_store.rs` / `pipeline.rs` / `store_migrations.rs` +//! precedent. + +use rusqlite::{params, Connection, OptionalExtension}; + +// ── Constants ──────────────────────────────────────────────────────────────── + +/// `archive_meta` key holding the observer-frame retention window, in days, +/// stored as its decimal text. +pub const OBSERVER_RETENTION_DAYS_KEY: &str = "observer_retention_days"; + +/// Default rolling window for observer frames (Will's ruling: "~14–30"; 30 is +/// the shipped default, trivially changeable). Seeded into `archive_meta` by M4. +pub const DEFAULT_OBSERVER_RETENTION_DAYS: i64 = 30; + +/// Upper bound on the retention window (~100 years). Guards against a day count +/// large enough to overflow `archived_at` cutoff arithmetic while still +/// admitting any realistic user choice. +pub const MAX_RETENTION_DAYS: i64 = 36_500; + +// ── Schema (created by migration M4, not the base SCHEMA) ───────────────────── + +/// `archive_meta`. Created inside M4 under `BEGIN IMMEDIATE` (see +/// `store_migrations::migrate_add_archive_meta`). Plain `CREATE TABLE` +/// (no `IF NOT EXISTS`): M4 creates it once on a DB that provably lacks it (the +/// fail-closed guard rejects a pre-existing one), so the clause would be dead +/// weight. Holds the observer-retention window and the Phase-2 prune timestamp. +pub(super) const ARCHIVE_META_SCHEMA: &str = " +CREATE TABLE archive_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +"; + +/// Covering index for the Phase-2 retention prune candidate scan. V4 prunes +/// one global observer window, so the scan filters `(identity_pubkey, +/// relay_url, archived_at < cutoff)` and joins `archived_events` for +/// `kind = 24200` — it does NOT constrain `scope_type`/`scope_value`. Age +/// therefore has to lead the non-equality keys: putting `archived_at` +/// immediately after the two identity/relay equality keys lets SQLite +/// range-seek `archived_at < ?` directly (`SEARCH ... USING COVERING INDEX +/// ... (identity_pubkey=? AND relay_url=? AND archived_at Result<(), String> { + if !(1..=MAX_RETENTION_DAYS).contains(&days) { + return Err(format!( + "retention days must be between 1 and {MAX_RETENTION_DAYS}; got {days}" + )); + } + Ok(()) +} + +// ── Observer retention window (archive_meta accessor) ─────────────────────────── + +/// Read the observer-frame retention window (days). Returns the seeded default +/// when the row is somehow absent (older DB opened before M4 seeded it, or an +/// externally cleared row) so the setting always resolves to a bounded window +/// rather than silently becoming Forever. +pub fn get_observer_retention_days(conn: &Connection) -> Result { + let raw: Option = conn + .query_row( + "SELECT value FROM archive_meta WHERE key = ?1", + params![OBSERVER_RETENTION_DAYS_KEY], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("read observer retention days: {e}"))?; + + match raw { + Some(s) => s + .parse::() + .map_err(|e| format!("observer retention days not an integer ({s:?}): {e}")), + None => Ok(DEFAULT_OBSERVER_RETENTION_DAYS), + } +} + +/// Set the observer-frame retention window (days). Fail-closed: rejects an +/// out-of-range value before writing (see [`validate_days`]). A single +/// idempotent upsert, atomic under autocommit. +pub fn set_observer_retention_days(conn: &Connection, days: i64) -> Result<(), String> { + validate_days(days)?; + conn.execute( + "INSERT INTO archive_meta (key, value) VALUES (?1, ?2) + ON CONFLICT (key) DO UPDATE SET value = excluded.value", + params![OBSERVER_RETENTION_DAYS_KEY, days.to_string()], + ) + .map_err(|e| format!("set observer retention days: {e}"))?; + Ok(()) +} + +// ── Size accounting ───────────────────────────────────────────────────────────── + +/// Collect physical (file) and logical (page) size figures for the archive DB. +/// PRAGMAs only — no row or payload scans. The main DB file path is read from +/// the connection itself (`PRAGMA database_list`), and the `-wal` sidecar is +/// measured by appending `-wal` to it. +pub fn size_stats(conn: &Connection) -> Result { + let page_size: i64 = conn + .pragma_query_value(None, "page_size", |row| row.get(0)) + .map_err(|e| format!("read page_size: {e}"))?; + let page_count: i64 = conn + .pragma_query_value(None, "page_count", |row| row.get(0)) + .map_err(|e| format!("read page_count: {e}"))?; + let freelist_count: i64 = conn + .pragma_query_value(None, "freelist_count", |row| row.get(0)) + .map_err(|e| format!("read freelist_count: {e}"))?; + + // `PRAGMA database_list` yields (seq, name, file) rows; the `main` schema's + // `file` is the on-disk DB path (empty for a `:memory:` DB). File sizes are + // read from filesystem metadata rather than page arithmetic so WAL frames + // not yet checkpointed into the main file are accounted for separately. + let main_path: Option = conn + .query_row( + "SELECT file FROM pragma_database_list WHERE name = 'main'", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("read database_list: {e}"))? + .filter(|p| !p.is_empty()); + + let (main_file_bytes, wal_file_bytes) = match main_path { + Some(p) => { + let main = std::path::PathBuf::from(&p); + (file_len(&main), file_len(&wal_path(&main))) + } + None => (0, 0), + }; + + Ok(ArchiveSizeStats { + main_file_bytes, + wal_file_bytes, + page_size, + page_count, + freelist_count, + }) +} + +/// Size of a file in bytes, or `0` when it does not exist / cannot be stat'd. +/// A missing `-wal` (fully checkpointed DB) is the normal case, not an error. +fn file_len(path: &std::path::Path) -> i64 { + std::fs::metadata(path).map(|m| m.len() as i64).unwrap_or(0) +} + +/// The `-wal` sidecar path for a main DB file (`archive.db` → `archive.db-wal`). +fn wal_path(db_path: &std::path::Path) -> std::path::PathBuf { + let mut name = db_path.as_os_str().to_os_string(); + name.push("-wal"); + std::path::PathBuf::from(name) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "retention_tests.rs"] +mod retention_tests; diff --git a/desktop/src-tauri/src/archive/retention_tests.rs b/desktop/src-tauri/src/archive/retention_tests.rs new file mode 100644 index 00000000000..26e6a25fdae --- /dev/null +++ b/desktop/src-tauri/src/archive/retention_tests.rs @@ -0,0 +1,440 @@ +//! Behavior tests for the observer-retention setting, the size readout, and the +//! M4 migration. +//! +//! Kept in a sibling file so `retention.rs` stays under the 1000-line gate; +//! `#[path]`-included from there. `super::*` brings the retention API (and its +//! `rusqlite::{params, Connection}` imports) into scope; `super::super::store` +//! reaches the neighbouring subscription mutators and the base `SCHEMA`. + +use super::super::store; +use super::*; +use std::path::Path; +use std::sync::{Arc, Barrier}; +use tempfile::NamedTempFile; + +const ID: &str = "idpk"; +const RELAY: &str = "wss://r"; +const OWNER: &str = "owner_p"; + +/// Open a fresh archive DB (runs the full schema + every migration incl. M4). +fn fresh(db: &NamedTempFile) -> Connection { + store::open_archive_db(db.path()).expect("open_archive_db must succeed") +} + +/// Build a legacy DB that has the base schema and M1–M3 markers but NOT M4, +/// so the next `open_archive_db` pends only the retention migration. This +/// isolates the M4 first-open race from the separately-tested M1–M3 chain. +fn build_pre_m4_db(path: &Path) { + let conn = Connection::open(path).unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.execute_batch(store::SCHEMA).unwrap(); + for name in [ + "add_harness_to_metric_index", + "add_cache_read_tokens", + "add_cache_write_and_pricing", + ] { + conn.execute( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) VALUES (?1, 0)", + params![name], + ) + .unwrap(); + } +} + +fn m4_marker_count(conn: &Connection) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_archive_meta'", + [], + |r| r.get(0), + ) + .unwrap() +} + +// ── Validation (pure) ────────────────────────────────────────────────────────── + +#[test] +fn test_validate_days_accepts_one_and_max() { + assert!(validate_days(1).is_ok()); + assert!(validate_days(DEFAULT_OBSERVER_RETENTION_DAYS).is_ok()); + assert!(validate_days(MAX_RETENTION_DAYS).is_ok()); +} + +#[test] +fn test_validate_days_rejects_zero_negative_and_over_max() { + assert!(validate_days(0).is_err()); + assert!(validate_days(-1).is_err()); + assert!(validate_days(MAX_RETENTION_DAYS + 1).is_err()); +} + +// ── Observer retention get / set ──────────────────────────────────────────────── + +#[test] +fn test_get_observer_days_returns_seeded_default_on_fresh_db() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS + ); +} + +#[test] +fn test_set_observer_days_upserts_and_overwrites() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + set_observer_retention_days(&conn, 14).unwrap(); + assert_eq!(get_observer_retention_days(&conn).unwrap(), 14); + set_observer_retention_days(&conn, 60).unwrap(); + assert_eq!(get_observer_retention_days(&conn).unwrap(), 60); +} + +#[test] +fn test_set_observer_days_rejects_out_of_range_without_writing() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + // Establish a known good value, then prove a rejected write leaves it. + set_observer_retention_days(&conn, 45).unwrap(); + assert!(set_observer_retention_days(&conn, 0).is_err()); + assert!(set_observer_retention_days(&conn, -5).is_err()); + assert!(set_observer_retention_days(&conn, MAX_RETENTION_DAYS + 1).is_err()); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + 45, + "a rejected set must not overwrite the stored value" + ); +} + +#[test] +fn test_observer_days_survive_reopen() { + let db = NamedTempFile::new().unwrap(); + { + let first = fresh(&db); + set_observer_retention_days(&first, 7).unwrap(); + } + let second = fresh(&db); + assert_eq!( + get_observer_retention_days(&second).unwrap(), + 7, + "the setting persists across opens and M4 re-run does not reset it" + ); +} + +// ── Size accounting ───────────────────────────────────────────────────────────── + +#[test] +fn test_size_stats_reports_pages_and_main_file_bytes() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + // Write enough rows that the DB grows past a single page. + for i in 0..200 { + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, 24200, 'author', ?4, ?5, ?4)", + params![ID, RELAY, format!("e{i}"), 1000 + i, "x".repeat(256)], + ) + .unwrap(); + } + let stats = size_stats(&conn).unwrap(); + assert!(stats.page_size > 0, "page_size is a positive PRAGMA value"); + assert!(stats.page_count > 1, "multi-page DB after 200 inserts"); + assert!(stats.freelist_count >= 0); + // In WAL mode the just-written pages live in the `-wal` sidecar until a + // checkpoint folds them into the main file, so the logical page total + // (page_size * page_count) is covered by the two files combined, not by + // the main file alone. + assert!( + stats.main_file_bytes + stats.wal_file_bytes >= stats.page_size * stats.page_count, + "main + wal bytes cover at least the counted pages ({} + {} >= {}*{})", + stats.main_file_bytes, + stats.wal_file_bytes, + stats.page_size, + stats.page_count + ); + assert!(stats.main_file_bytes > 0, "the main DB file is on disk"); + // WAL mode: the sidecar exists and carries the just-written frames. + assert!( + stats.wal_file_bytes > 0, + "the -wal sidecar is measured in WAL mode" + ); +} + +#[test] +fn test_size_stats_freelist_grows_after_delete() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + for i in 0..200 { + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, 24200, 'author', ?4, ?5, ?4)", + params![ID, RELAY, format!("e{i}"), 1000 + i, "x".repeat(256)], + ) + .unwrap(); + } + let before = size_stats(&conn).unwrap(); + conn.execute("DELETE FROM archived_events", []).unwrap(); + let after = size_stats(&conn).unwrap(); + assert!( + after.freelist_count > before.freelist_count, + "deleted pages land on the freelist ({} > {})", + after.freelist_count, + before.freelist_count + ); +} + +// ── Migration M4 ────────────────────────────────────────────────────────────── + +#[test] +fn test_m4_fresh_open_creates_schema_marker_and_seed() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); + let objects: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE (type = 'table' AND name = 'archive_meta') + OR (type = 'index' AND name = 'idx_archived_event_scopes_age')", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(objects, 2, "one table + one index"); + assert_eq!(m4_marker_count(&conn), 1, "M4 marker recorded"); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS, + "the observer window is seeded to the default" + ); +} + +#[test] +fn test_m4_reopen_is_idempotent_and_preserves_setting() { + let db = NamedTempFile::new().unwrap(); + let first = fresh(&db); + set_observer_retention_days(&first, 90).unwrap(); + drop(first); + let second = fresh(&db); + assert_eq!( + m4_marker_count(&second), + 1, + "exactly one marker after reopen" + ); + assert_eq!( + get_observer_retention_days(&second).unwrap(), + 90, + "the re-run must not re-seed over an existing value" + ); +} + +#[test] +fn test_m4_applies_over_a_populated_pre_m4_db() { + let db = NamedTempFile::new().unwrap(); + build_pre_m4_db(db.path()); + // Pre-M4 subscriptions and archived rows must survive the migration + // untouched — M4 no longer reads or seeds from subscriptions. + { + let conn = Connection::open(db.path()).unwrap(); + store::upsert_save_subscription(&conn, ID, RELAY, OWNER, ID, "[24200,44200,1]", 100) + .unwrap(); + } + let conn = fresh(&db); + assert_eq!(m4_marker_count(&conn), 1); + assert_eq!( + get_observer_retention_days(&conn).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS + ); + assert_eq!( + store::list_save_subscriptions(&conn, ID, RELAY) + .unwrap() + .len(), + 1, + "the pre-existing subscription is untouched by M4" + ); +} + +#[test] +fn test_m4_preexisting_archive_meta_without_marker_fails_closed() { + let db = NamedTempFile::new().unwrap(); + build_pre_m4_db(db.path()); + // An `archive_meta` table present without the M4 marker is unreachable via + // shipped code — M4 runs the whole body (create table, build index, seed, + // marker) in one transactional `BEGIN IMMEDIATE`, so a crash rolls back the + // table too. The only way to reach this state is an externally-created + // table. M4 refuses to certify it: it fails closed and rolls back with no + // marker rather than adopting a table it did not build. The table's shape + // is irrelevant — its mere presence without the marker is the trigger. + { + let conn = Connection::open(db.path()).unwrap(); + conn.execute_batch("CREATE TABLE archive_meta (key TEXT PRIMARY KEY);") + .unwrap(); + } + assert!( + store::open_archive_db(db.path()).is_err(), + "M4 must fail closed on a pre-existing archive_meta" + ); + let verify = Connection::open(db.path()).unwrap(); + assert_eq!( + m4_marker_count(&verify), + 0, + "no marker may certify an externally-created table" + ); +} + +#[test] +fn test_m4_preexisting_index_under_name_is_silently_rebuilt() { + let db = NamedTempFile::new().unwrap(); + build_pre_m4_db(db.path()); + // Unlike a table (which carries data and is fail-closed), an index carries + // no data, so M4 drops any index sharing the name and recreates it + // unconditionally — no shape inspection. A bogus pre-existing index under + // the name is silently replaced with the correct one and the marker lands. + { + let conn = Connection::open(db.path()).unwrap(); + conn.execute_batch(&format!( + "CREATE INDEX {SCOPE_AGE_INDEX_NAME} ON archived_event_scopes (id);" + )) + .unwrap(); + } + let conn = fresh(&db); + assert_eq!(m4_marker_count(&conn), 1, "M4 completes after the rebuild"); + // The rebuilt index has the six expected key columns in the covering order. + let mut stmt = conn + .prepare(&format!( + "SELECT name FROM pragma_index_xinfo('{SCOPE_AGE_INDEX_NAME}') \ + WHERE key = 1 ORDER BY seqno" + )) + .unwrap(); + let keys: Vec = stmt + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + keys, + [ + "identity_pubkey", + "relay_url", + "archived_at", + "id", + "scope_type", + "scope_value" + ], + "the index was rebuilt with the age-first covering key order" + ); +} + +// ── Concurrency ─────────────────────────────────────────────────────────────── + +#[test] +fn test_m4_two_conn_first_open_race_neither_times_out_and_marks_once() { + use std::thread; + let db = NamedTempFile::new().unwrap(); + let path = db.path().to_path_buf(); + build_pre_m4_db(&path); + // A realistically-populated legacy DB: a multi-kind subscription plus + // archived scope rows so M4's index build touches real data. + { + let conn = Connection::open(&path).unwrap(); + store::upsert_save_subscription(&conn, ID, RELAY, OWNER, ID, "[24200,44200,1]", 100) + .unwrap(); + for i in 0..8 { + conn.execute( + "INSERT INTO archived_event_scopes + (identity_pubkey, relay_url, id, scope_type, scope_value, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ID, RELAY, format!("e{i}"), OWNER, ID, 1000 + i], + ) + .unwrap(); + } + } + + let barrier = Arc::new(Barrier::new(2)); + let handles: Vec<_> = (0..2) + .map(|_| { + let p = path.clone(); + let b = Arc::clone(&barrier); + thread::spawn(move || { + b.wait(); // maximise the first-open race window + store::open_archive_db(&p).map(|_| ()) + }) + }) + .collect(); + for h in handles { + assert!( + h.join().unwrap().is_ok(), + "both racing opens must complete within busy_timeout" + ); + } + + let verify = store::open_archive_db(&path).unwrap(); + assert_eq!(m4_marker_count(&verify), 1, "M4 applied exactly once"); + // The seed ran once inside the winner's transaction — one meta row, default. + let meta_rows: i64 = verify + .query_row( + "SELECT COUNT(*) FROM archive_meta WHERE key = ?1", + params![OBSERVER_RETENTION_DAYS_KEY], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(meta_rows, 1, "the observer window was seeded exactly once"); + assert_eq!( + get_observer_retention_days(&verify).unwrap(), + DEFAULT_OBSERVER_RETENTION_DAYS + ); +} + +// ── Prune-candidate access path (index shape) ────────────────────────────────── + +/// The Phase-2 prune-candidate scan (per `PLANS/ARCHIVE_RETENTION_PLAN.md` +/// line 40): the single global observer window filters `identity + relay + +/// archived_at < cutoff`, joins `archived_events` for `kind = 24200`, and +/// selects the scope-row PK so the bounded `DELETE` can materialize candidates. +/// The scope side constrains neither `scope_type` nor `scope_value`. +const PRUNE_CANDIDATE_SQL: &str = " +SELECT s.id, s.scope_type, s.scope_value +FROM archived_event_scopes s +JOIN archived_events e + ON e.identity_pubkey = s.identity_pubkey + AND e.relay_url = s.relay_url + AND e.id = s.id +WHERE s.identity_pubkey = ?1 + AND s.relay_url = ?2 + AND s.archived_at < ?3 + AND e.kind = 24200 +LIMIT 1000 +"; + +/// The scope-age index must let the real Phase-2 prune query range-seek +/// `archived_at` directly rather than fall back to a bare identity/relay seek +/// plus a temp b-tree. The index is this PR's deliverable, so its access path +/// is pinned here even though the prune worker lands in Phase 2. +#[test] +fn test_prune_candidate_scan_seeks_archived_at_through_the_index() { + let db = NamedTempFile::new().unwrap(); + let conn = fresh(&db); // runs M4, creating the scope-age index + + let plan: Vec = conn + .prepare(&format!("EXPLAIN QUERY PLAN {PRUNE_CANDIDATE_SQL}")) + .unwrap() + .query_map(params![ID, RELAY, 0_i64], |r| r.get::<_, String>(3)) + .unwrap() + .collect::, _>>() + .unwrap(); + + let scope_step = plan + .iter() + .find(|d| d.contains(SCOPE_AGE_INDEX_NAME)) + .unwrap_or_else(|| panic!("prune scan must use {SCOPE_AGE_INDEX_NAME}; plan was {plan:?}")); + // The planner range-seeks archived_at through the index (equality on the + // two leading identity/relay keys, then the age bound) — not a bare + // identity/relay seek that would leave age to a scan / temp b-tree. + assert!( + scope_step.contains("archived_at Result<(), String> { migrate_add_cache_read_tokens(conn)?; migrate_add_cache_write_and_pricing(conn)?; - migrate_add_harness_to_metric_index(conn) + migrate_add_harness_to_metric_index(conn)?; + migrate_add_archive_meta(conn) } /// M1: add `harness TEXT` column to `agent_metric_index` and rebuild index @@ -323,3 +325,135 @@ fn migrate_add_cache_write_and_pricing(conn: &Connection) -> Result<(), String> Ok(()) } + +/// M4: create the retention config storage — `archive_meta` (k/v state holding +/// the observer-frame retention window and the Phase-2 prune timestamp) and the +/// `archived_at`-covering scope-age index — then seed the default observer +/// retention window (`observer_retention_days = 30`). +/// +/// Unlike M1–M3, M4 CANNOT use the "check marker → `BEGIN DEFERRED`" pattern. +/// On a fresh DB two connections can open concurrently (the observer- and +/// metric-archive seed hooks each `open_archive_db`), both read no marker, and +/// a `DEFERRED` transaction lets both proceed on the same snapshot — the loser +/// hits `SQLITE_BUSY_SNAPSHOT` or double-seeds. Instead M4 takes the write lock +/// up front with `BEGIN IMMEDIATE` and **rechecks the marker inside the lock**: +/// the race loser blocks on `busy_timeout`, then observes the winner's +/// committed marker and no-ops. The cheap pre-lock guard keeps steady-state +/// opens off the write lock entirely (M4 only takes it until the marker lands). +/// +/// The table uses plain `CREATE TABLE` behind a fail-closed guard (a +/// pre-existing `archive_meta` with no marker is an externally-created object +/// M4 refuses to certify) and the index is dropped and recreated +/// unconditionally; the seed is `ON CONFLICT DO NOTHING`. The marker is written +/// last inside the same transaction, so a crash before COMMIT rolls back every +/// object and the next open re-runs from scratch. +fn migrate_add_archive_meta(conn: &Connection) -> Result<(), String> { + // Cheap pre-lock guard: steady-state opens (marker already present) never + // take the write lock. The marker is written last in M4's transaction, so + // its presence implies the full schema + seed committed. + if archive_meta_migration_applied(conn)? { + return Ok(()); + } + + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|e| format!("migration M4: begin immediate: {e}"))?; + + let result = migrate_add_archive_meta_locked(conn); + + if result.is_ok() { + conn.execute_batch("COMMIT") + .map_err(|e| format!("migration M4: commit: {e}"))?; + } else { + // Best-effort rollback; surface the original error to the caller. + let _ = conn.execute_batch("ROLLBACK"); + } + result +} + +/// M4 body, run under the `BEGIN IMMEDIATE` write lock held by +/// `migrate_add_archive_meta`. +fn migrate_add_archive_meta_locked(conn: &Connection) -> Result<(), String> { + // In-lock recheck: a concurrent first-opener may have committed the marker + // while we were blocked on the write lock. If so, this connection has + // nothing to do — the winner already created the schema and seeded. + if archive_meta_migration_applied(conn)? { + return Ok(()); + } + + // Fail closed on an externally-created table. M4's whole body runs inside + // one `BEGIN IMMEDIATE` transaction with the marker written last, and + // SQLite DDL is transactional — a crash anywhere rolls the whole thing + // back. So no shipped code path can leave the table present without the + // marker; the only way to reach here with it already existing is a + // hand-edited DB or a foreign tool. Rather than certify a table we did not + // create, refuse: roll back with no marker and let a corrected DB re-run. + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'archive_meta'", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| format!("migration M4: probe archive_meta: {e}"))? + > 0; + if exists { + return Err( + "migration M4: archive_meta already exists without the M4 marker — \ + refusing to certify an externally-created table" + .to_string(), + ); + } + + conn.execute_batch(super::retention::ARCHIVE_META_SCHEMA) + .map_err(|e| format!("migration M4: create archive_meta: {e}"))?; + + // Unconditionally rebuild the scope-age index. It carries no data, so a + // fresh `CREATE` is always correct; dropping any index that happens to + // share the name costs one rebuild and needs no shape inspection. + conn.execute_batch(&format!( + "DROP INDEX IF EXISTS {}", + super::retention::SCOPE_AGE_INDEX_NAME + )) + .map_err(|e| format!("migration M4: drop any pre-existing scope-age index: {e}"))?; + conn.execute_batch(super::retention::SCOPE_AGE_INDEX_DDL) + .map_err(|e| format!("migration M4: create scope-age index: {e}"))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Seed the default observer-frame retention window. `ON CONFLICT DO NOTHING` + // in the underlying insert makes this a no-op if a value is somehow already + // present — an existing choice always wins. + conn.execute( + "INSERT INTO archive_meta (key, value) VALUES (?1, ?2) + ON CONFLICT (key) DO NOTHING", + params![ + super::retention::OBSERVER_RETENTION_DAYS_KEY, + super::retention::DEFAULT_OBSERVER_RETENTION_DAYS.to_string() + ], + ) + .map_err(|e| format!("migration M4: seed observer retention days: {e}"))?; + + conn.execute( + "INSERT OR IGNORE INTO archive_migrations (name, applied_at) \ + VALUES ('add_archive_meta', ?1)", + params![now], + ) + .map_err(|e| format!("migration M4: record marker: {e}"))?; + + Ok(()) +} + +/// Whether the M4 marker is present. Its presence implies the retention schema +/// and default seed committed (the marker is written last in M4's transaction). +fn archive_meta_migration_applied(conn: &Connection) -> Result { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archive_migrations WHERE name = 'add_archive_meta'", + [], + |r| r.get(0), + ) + .map_err(|e| format!("migration M4: guard check: {e}"))?; + Ok(count > 0) +} diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index bbd15391e75..c0f85430d4d 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -10,6 +10,9 @@ fn in_memory() -> Connection { conn.pragma_update(None, "journal_mode", "WAL").unwrap(); conn.pragma_update(None, "busy_timeout", 5000).unwrap(); conn.execute_batch(SCHEMA).unwrap(); + // Match production `open_archive_db`: apply every schema migration (incl. + // M4) so tests run against the same shape production connections see. + apply_schema_migrations(&conn).unwrap(); conn } diff --git a/desktop/src-tauri/src/archive/sync.rs b/desktop/src-tauri/src/archive/sync.rs index 4fe4452c818..3730774e952 100644 --- a/desktop/src-tauri/src/archive/sync.rs +++ b/desktop/src-tauri/src/archive/sync.rs @@ -280,10 +280,12 @@ impl ArchiveSyncIo for AppIo { let state: State<'_, AppState> = self.app.state(); let identity_pk = super::identity_pubkey(&state)?; let relay_url = crate::relay::relay_ws_url_with_override(&state); - super::run_archive_db_task(move |conn| { - super::store::list_save_subscriptions(conn, &identity_pk, &relay_url) - }) - .await + state + .archive_db + .with_conn(move |conn| { + super::store::list_save_subscriptions(conn, &identity_pk, &relay_url) + }) + .await }) } diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index abf40c028fe..e0c6e3bc5ba 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -121,6 +121,24 @@ pub async fn get_channel_details( .ok_or_else(|| "channel not found".to_string()) } +/// Cap for the kind:0 profile join in `get_channel_members`. Enriching a +/// huge roster required an `authors` filter carrying every member pubkey — a +/// query whose size and relay cost grow linearly with membership and which +/// dominated channel-open latency on large channels. Members past the cap +/// keep `display_name: None` (the UI falls back to pubkey-derived labels and +/// resolves visible names through its profile caches); `role == "bot"` agent +/// flags are roster-derived and unaffected by the cap. +const MEMBER_PROFILE_JOIN_LIMIT: usize = 500; + +/// The pubkeys eligible for the kind:0 profile join: roster order, capped. +fn profile_join_pubkeys(members: &[crate::models::ChannelMemberInfo], limit: usize) -> Vec { + members + .iter() + .take(limit) + .map(|member| member.pubkey.clone()) + .collect() +} + #[tauri::command] pub async fn get_channel_members( channel_id: String, @@ -142,8 +160,9 @@ pub async fn get_channel_members( .transpose()? .ok_or_else(|| "channel members not found".to_string())?; - // Batch-fetch kind:0 profiles to populate display names. - let pubkeys: Vec = response.members.iter().map(|m| m.pubkey.clone()).collect(); + // Batch-fetch kind:0 profiles to populate display names, capped so the + // query cost is bounded on large rosters (see MEMBER_PROFILE_JOIN_LIMIT). + let pubkeys = profile_join_pubkeys(&response.members, MEMBER_PROFILE_JOIN_LIMIT); if !pubkeys.is_empty() { let profile_events = query_relay( &state, diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index fb43bb7a70b..91e636d5f20 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -490,3 +490,26 @@ fn last_message_filters_stay_within_relay_channel_cap() { ); assert_eq!(batches.concat(), filters); } + +fn member(pubkey: &str) -> crate::models::ChannelMemberInfo { + crate::models::ChannelMemberInfo { + pubkey: pubkey.to_string(), + role: "member".to_string(), + is_agent: false, + joined_at: None, + display_name: None, + } +} + +#[test] +fn profile_join_pubkeys_caps_in_roster_order() { + let members = vec![member(PK_A), member(PK_B), member(PK_C)]; + + assert_eq!( + profile_join_pubkeys(&members, 2), + vec![PK_A.to_string(), PK_B.to_string()] + ); + assert_eq!(profile_join_pubkeys(&members, 3).len(), 3); + assert_eq!(profile_join_pubkeys(&members, 10).len(), 3); + assert!(profile_join_pubkeys(&[], 10).is_empty()); +} diff --git a/desktop/src-tauri/src/events/message_tags.rs b/desktop/src-tauri/src/events/message_tags.rs index c43a8874def..1d719beaa66 100644 --- a/desktop/src-tauri/src/events/message_tags.rs +++ b/desktop/src-tauri/src/events/message_tags.rs @@ -4,6 +4,7 @@ use super::check_pubkey; const MAX_THREAD_ROOT_EXCERPT_CHARS: usize = 64; const SENT_FROM_THREAD_TAG: &str = "buzz:sent-from-thread"; +const AGENT_ADDRESS_MENTION_MARKER: &str = "agent-address"; pub(super) fn mention_reference_tags( mentions: &[Vec], @@ -19,10 +20,20 @@ pub(super) fn mention_reference_tags( let Some(pubkey) = mention.get(1) else { return Err("mention reference tag missing pubkey".into()); }; + if mention.len() > 3 + || (mention.len() == 3 + && mention.get(2).map(String::as_str) != Some(AGENT_ADDRESS_MENTION_MARKER)) + { + return Err("mention reference tag has invalid display metadata".into()); + } check_pubkey(pubkey)?; + let normalized_pubkey = pubkey.to_ascii_lowercase(); + let mut parts = vec!["mention", normalized_pubkey.as_str()]; + if mention.len() == 3 { + parts.push(AGENT_ADDRESS_MENTION_MARKER); + } tags.push( - Tag::parse(vec!["mention", &pubkey.to_ascii_lowercase()]) - .map_err(|error| format!("invalid mention reference tag: {error}"))?, + Tag::parse(parts).map_err(|error| format!("invalid mention reference tag: {error}"))?, ); } Ok(()) @@ -115,8 +126,39 @@ pub(super) fn append_client_tags( mod tests { use super::*; + const PUBKEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const ROOT_HEX: &str = "d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1"; + #[test] + fn mention_reference_preserves_agent_address_display_metadata() { + let mut tags = Vec::new(); + mention_reference_tags( + &[vec![ + "mention".into(), + PUBKEY.to_ascii_uppercase(), + AGENT_ADDRESS_MENTION_MARKER.into(), + ]], + &mut tags, + ) + .unwrap(); + + assert_eq!( + tags[0].as_slice(), + &["mention", PUBKEY, AGENT_ADDRESS_MENTION_MARKER] + ); + } + + #[test] + fn mention_reference_rejects_unknown_display_metadata() { + let mut tags = Vec::new(); + let result = mention_reference_tags( + &[vec!["mention".into(), PUBKEY.into(), "unknown".into()]], + &mut tags, + ); + + assert!(result.is_err()); + } + #[test] fn message_accepts_only_valid_sent_from_thread_provenance() { let source_tag = vec![ diff --git a/desktop/src-tauri/src/huddle/agent_tts_publisher.rs b/desktop/src-tauri/src/huddle/agent_tts_publisher.rs new file mode 100644 index 00000000000..a1d42692666 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_tts_publisher.rs @@ -0,0 +1,75 @@ +//! Establishes agent-authenticated publishers for locally synthesized speech. + +use std::sync::Arc; + +use super::{relay_api, tts}; +use crate::app_state::AppState; + +pub(super) async fn ensure( + app: &tauri::AppHandle, + state: &AppState, + pipeline: &tts::TtsPipeline, + speaker_pubkey: &str, +) -> Result { + if pipeline.has_audio_publisher(speaker_pubkey) { + return Ok(true); + } + + let app_for_load = app.clone(); + let speaker_for_load = speaker_pubkey.to_ascii_lowercase(); + let record = tokio::task::spawn_blocking(move || { + crate::managed_agents::load_managed_agents(&app_for_load).map(|agents| { + agents.into_iter().find(|agent| { + agent.pubkey.eq_ignore_ascii_case(&speaker_for_load) + && !agent.private_key_nsec.trim().is_empty() + }) + }) + }) + .await + .map_err(|error| format!("managed-agent identity task failed: {error}"))??; + let Some(record) = record else { + return Ok(false); + }; + + let keys = nostr::Keys::parse(record.private_key_nsec.trim()) + .map_err(|error| format!("managed-agent identity is unavailable: {error}"))?; + if !keys + .public_key() + .to_hex() + .eq_ignore_ascii_case(speaker_pubkey) + { + return Err("managed-agent identity does not match the Huddle speaker".to_string()); + } + let (ephemeral_channel_id, parent_channel_id, local_tts_publishers) = { + let huddle = state.huddle()?; + ( + huddle + .ephemeral_channel_id + .clone() + .ok_or("active Huddle has no backing channel")?, + huddle.parent_channel_id.clone(), + Arc::clone(&huddle.local_tts_publishers), + ) + }; + let has_bot_membership = + relay_api::fetch_channel_members_with_roles(&ephemeral_channel_id, state) + .await? + .into_iter() + .any(|(pubkey, role)| { + pubkey.eq_ignore_ascii_case(speaker_pubkey) && role.as_deref() == Some("bot") + }); + if !has_bot_membership { + return Err("agent is not an active bot member of the Huddle".to_string()); + } + let publisher = relay_api::connect_tts_audio_publisher( + &ephemeral_channel_id, + parent_channel_id.as_deref(), + state, + &keys, + record.auth_tag.as_deref(), + local_tts_publishers, + ) + .await?; + pipeline.register_audio_publisher(speaker_pubkey, publisher); + Ok(true) +} diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 2b5b601de49..2bdf0544260 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -37,11 +37,11 @@ use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; pub fn voice_mode_guidelines(parent_channel_id: &str) -> String { format!( "\ -You are in a live voice huddle attached to channel {parent_channel_id}. -Only messages sent with `buzz messages send` to this huddle channel are spoken aloud, in the order sent; everything else you produce is silent. -When a user addresses you, your FIRST tool call must send a brief spoken reply to this channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them. +You are in a live voice huddle. Its attached main channel is {parent_channel_id}; that is not the live huddle channel. +The channel UUID in the current `[Context]` block is the live huddle channel. Only messages sent with `buzz messages send` to that current Context channel are spoken aloud, in the order sent; everything else you produce is silent. +When a user addresses you, your FIRST tool call must send a brief spoken reply to the current Context channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them. Then work, sending each useful sentence as its own message the moment it is ready—a few sentences per answer, not a monologue. -Speak plainly without markdown; post code or long detail to the attached channel instead. +Speak plainly without markdown; post code or long detail to the attached main channel instead. If you are not addressed, stay silent." ) } @@ -303,11 +303,13 @@ mod tests { fn voice_mode_guidelines_pin_spoken_reply_as_first_tool_call() { let guidelines = voice_mode_guidelines("parent-channel"); assert_eq!(guidelines.lines().count(), 6); - assert!(guidelines.contains("Only messages sent with `buzz messages send`")); + assert!(guidelines.contains("Its attached main channel is parent-channel")); + assert!(guidelines.contains("that is not the live huddle channel")); + assert!(guidelines.contains("current `[Context]` block is the live huddle channel")); + assert!(guidelines.contains("buzz messages send` to that current Context channel")); assert!(guidelines.contains("your FIRST tool call must send a brief spoken reply")); assert!(guidelines.contains("before any file read, search, or other tool call")); assert!(guidelines.contains("rule against bare acknowledgments does not apply here")); - assert!(guidelines.contains("parent-channel")); } #[test] diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index 34dec53094b..383a7e8210a 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -97,3 +97,70 @@ pub(crate) fn open_output_sink_by_name( rodio::DeviceSinkBuilder::open_default_sink().map_err(|e| format!("audio output: {e}")) } + +fn device_type_is_isolated(device_type: rodio::cpal::DeviceType) -> bool { + use rodio::cpal::DeviceType; + matches!( + device_type, + DeviceType::Headphones + | DeviceType::Headset + | DeviceType::Earpiece + | DeviceType::HearingAid + ) +} + +/// Conservative route-isolation query using cpal's safe structured device +/// description. This is intentionally re-evaluated at confirmed local onset, +/// so a route change cannot leave a stale isolated capability behind. +pub(crate) fn output_route_is_isolated(preferred: Option<&str>) -> bool { + use rodio::cpal::traits::HostTrait; + use rodio::DeviceTrait; + + let host = rodio::cpal::default_host(); + let device = match preferred.filter(|name| !name.is_empty()) { + Some(name) => { + let Ok(devices) = host.output_devices() else { + return false; + }; + let mut matches = devices.filter(|device| { + device + .description() + .ok() + .map(|description| description.name().to_owned()) + == Some(name.to_owned()) + }); + let Some(device) = matches.next() else { + return false; + }; + if matches.next().is_some() { + return false; + } + device + } + None => match host.default_output_device() { + Some(device) => device, + None => return false, + }, + }; + + device + .description() + .is_ok_and(|description| device_type_is_isolated(description.device_type())) +} + +#[cfg(test)] +mod route_isolation_tests { + use super::device_type_is_isolated; + use rodio::cpal::DeviceType; + + #[test] + fn only_positive_isolated_terminal_types_are_accepted() { + assert!(device_type_is_isolated(DeviceType::Headphones)); + assert!(device_type_is_isolated(DeviceType::Headset)); + assert!(device_type_is_isolated(DeviceType::Earpiece)); + assert!(device_type_is_isolated(DeviceType::HearingAid)); + assert!(!device_type_is_isolated(DeviceType::Speaker)); + assert!(!device_type_is_isolated(DeviceType::Virtual)); + assert!(!device_type_is_isolated(DeviceType::Unknown)); + } +} diff --git a/desktop/src-tauri/src/huddle/human_floor.rs b/desktop/src-tauri/src/huddle/human_floor.rs new file mode 100644 index 00000000000..1643880c42c --- /dev/null +++ b/desktop/src-tauri/src/huddle/human_floor.rs @@ -0,0 +1,73 @@ +//! Shared human-floor handle backed by the TTS playback coordinator. + +use std::sync::Arc; + +use super::tts_playback::{HumanFloorAuthorization, PlaybackCoordinator}; + +#[derive(Clone)] +pub(crate) struct HumanFloor { + playback: Arc, +} + +impl std::fmt::Debug for HumanFloor { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("HumanFloor").finish_non_exhaustive() + } +} + +impl Default for HumanFloor { + fn default() -> Self { + Self::new() + } +} + +impl HumanFloor { + pub(crate) fn new() -> Self { + Self { + playback: Arc::new(PlaybackCoordinator::unbound()), + } + } + + pub(super) fn playback(&self) -> Arc { + Arc::clone(&self.playback) + } + + #[cfg(test)] + pub(crate) fn is_blocked(&self) -> bool { + self.playback.human_floor_blocked() + } + + pub(crate) fn epoch(&self) -> u64 { + self.playback.human_floor_epoch() + } + + pub(super) fn authorization(&self, epoch: u64) -> HumanFloorAuthorization { + self.playback.human_floor_authorization(epoch) + } + + #[cfg(test)] + pub(crate) fn permits(&self, epoch: u64) -> bool { + self.authorization(epoch) == HumanFloorAuthorization::Permitted + } + + pub(crate) fn enter_local(&self, route_isolated: bool, sustained_coupled_speech: bool) -> bool { + self.playback + .enter_local_human_floor(route_isolated, sustained_coupled_speech) + } + + pub(crate) fn leave_local(&self) { + self.playback.leave_local_human_floor(); + } + + pub(crate) fn enter_remote(&self, peer: u8) { + self.playback.enter_remote_human_floor(peer); + } + + pub(crate) fn leave_remote(&self, peer: u8) { + self.playback.leave_remote_human_floor(peer); + } + + pub(crate) fn clear_remote(&self) { + self.playback.clear_remote_human_floor(); + } +} diff --git a/desktop/src-tauri/src/huddle/latency_bench.rs b/desktop/src-tauri/src/huddle/latency_bench.rs index 710854b5337..f928ddbce0f 100644 --- a/desktop/src-tauri/src/huddle/latency_bench.rs +++ b/desktop/src-tauri/src/huddle/latency_bench.rs @@ -141,6 +141,7 @@ fn baseline_stt_fake_llm_tts_first_audio() { tts_dir, Arc::clone(&tts_active), Arc::clone(&tts_cancel), + super::human_floor::HumanFloor::new(), "eve", None, // default output device None, // no Tauri app handle @@ -152,7 +153,14 @@ fn baseline_stt_fake_llm_tts_first_audio() { ); let t = Instant::now(); - let (stt, mut text_rx) = SttPipeline::new(stt_dir, None, None).expect("stt pipeline"); + let (stt, mut text_rx) = SttPipeline::new( + stt_dir, + None, + None, + super::human_floor::HumanFloor::new(), + None, + ) + .expect("stt pipeline"); // Recognizer loads inside the worker thread; give it time, then verify // liveness via a first throwaway feed below. std::thread::sleep(Duration::from_secs(2)); diff --git a/desktop/src-tauri/src/huddle/local_barge_in.rs b/desktop/src-tauri/src/huddle/local_barge_in.rs new file mode 100644 index 00000000000..ac57c685ad6 --- /dev/null +++ b/desktop/src-tauri/src/huddle/local_barge_in.rs @@ -0,0 +1,151 @@ +//! Local VAD barge-in policy and coupled-output debounce. + +use super::human_floor::HumanFloor; + +/// Whether local audio should use VAD barge-in for this frame. +/// +/// This currently matches STT's `vad_flush_allowed`, but the two decisions are +/// kept separate deliberately: one assigns cancellation ownership and the +/// other controls utterance endpointing. +pub(super) fn enabled(ptt_mode: bool, manually_open: bool, ptt_held: bool) -> bool { + !ptt_mode || (manually_open && !ptt_held) +} + +/// Consecutive 16 ms VAD-positive frames required to restore local barge-in +/// on acoustically coupled output. The prior implementation shipped 20 frames +/// after 5 frames caused speaker-bleed self-cancellation (`b29c8cdaa^`). +const COUPLED_BARGE_IN_FRAMES: usize = 20; + +#[derive(Debug, Default)] +pub(super) struct LocalBargeIn { + acquired_floor: bool, + coupled_positive_frames: usize, +} + +impl LocalBargeIn { + pub(super) fn observe( + &mut self, + probability: f32, + confirmed_onset: bool, + human_floor: &HumanFloor, + output_device: Option<&str>, + onset_threshold: f32, + ) { + if self.acquired_floor { + return; + } + let sustained_coupled = self.track_sustained_coupled(probability, onset_threshold); + if !confirmed_onset && !sustained_coupled { + return; + } + let route_isolated = super::audio_output::output_route_is_isolated(output_device); + self.acquire(human_floor, route_isolated, sustained_coupled); + } + + pub(super) fn acquire( + &mut self, + human_floor: &HumanFloor, + route_isolated: bool, + sustained_coupled: bool, + ) { + self.acquired_floor = human_floor.enter_local(route_isolated, sustained_coupled); + } + + fn track_sustained_coupled(&mut self, probability: f32, onset_threshold: f32) -> bool { + if probability > onset_threshold { + self.coupled_positive_frames = self.coupled_positive_frames.saturating_add(1); + } else { + self.coupled_positive_frames = 0; + } + self.coupled_positive_frames >= COUPLED_BARGE_IN_FRAMES + } + + pub(super) fn release(&mut self, human_floor: &HumanFloor) { + if self.acquired_floor { + human_floor.leave_local(); + } + *self = Self::default(); + } +} + +#[derive(Debug)] +pub(super) struct WorkerLocalBargeIn { + state: LocalBargeIn, + human_floor: HumanFloor, +} + +impl WorkerLocalBargeIn { + pub(super) fn new(human_floor: HumanFloor) -> Self { + Self { + state: LocalBargeIn::default(), + human_floor, + } + } +} + +impl std::ops::Deref for WorkerLocalBargeIn { + type Target = LocalBargeIn; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl std::ops::DerefMut for WorkerLocalBargeIn { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.state + } +} + +impl Drop for WorkerLocalBargeIn { + fn drop(&mut self) { + self.state.release(&self.human_floor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_open_mic_enables_vad_barge_in_in_ptt_mode() { + assert!(enabled(true, true, false)); + assert!(!enabled(true, false, false)); + assert!(!enabled(true, true, true)); + assert!(enabled(false, false, false)); + } + + #[test] + fn manual_open_ptt_sustained_speech_acquires_coupled_floor() { + assert!(enabled(true, true, false)); + let human_floor = HumanFloor::new(); + let mut barge_in = LocalBargeIn::default(); + for _ in 0..COUPLED_BARGE_IN_FRAMES { + let sustained = barge_in.track_sustained_coupled(0.9, 0.5); + if sustained { + barge_in.acquire(&human_floor, false, true); + } + } + assert!(barge_in.acquired_floor); + assert!(human_floor.is_blocked()); + } + + #[test] + fn coupled_barge_in_requires_twenty_consecutive_positive_frames() { + let mut barge_in = LocalBargeIn::default(); + for _ in 0..COUPLED_BARGE_IN_FRAMES - 1 { + assert!(!barge_in.track_sustained_coupled(0.9, 0.5)); + } + assert!(barge_in.track_sustained_coupled(0.9, 0.5)); + } + + #[test] + fn coupled_barge_in_debounce_resets_on_a_non_speech_frame() { + let mut barge_in = LocalBargeIn::default(); + for _ in 0..COUPLED_BARGE_IN_FRAMES - 1 { + assert!(!barge_in.track_sustained_coupled(0.9, 0.5)); + } + assert!(!barge_in.track_sustained_coupled(0.1, 0.5)); + assert!(!barge_in.track_sustained_coupled(0.9, 0.5)); + } +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 1feb2073b09..e219b2f75fa 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -23,14 +23,17 @@ //! takes `stt_pipeline`/`tts_pipeline` out of the lock, then calls `shutdown()` //! and drops them outside the lock (thread joins can block ~200ms). +mod agent_tts_publisher; mod agent_tts_routing; pub mod agent_voice; pub mod agents; pub mod audio_output; mod commands; +mod human_floor; pub mod jitter; #[cfg(test)] mod latency_bench; +mod local_barge_in; pub mod models; pub mod pipeline; pub mod playout; @@ -42,6 +45,8 @@ pub mod state; pub mod stt; pub mod transcription; pub mod tts; +#[path = "tts_playback.rs"] +mod tts_playback; pub mod tts_settings; mod tts_voice_import; mod tts_voice_registry; @@ -81,7 +86,7 @@ pub use window::{close_huddle_companion, open_huddle_window}; // ── Imports ─────────────────────────────────────────────────────────────────── -use std::sync::atomic::Ordering; +use std::sync::{atomic::Ordering, Arc}; use tauri::State; use uuid::Uuid; @@ -489,7 +494,7 @@ fn teardown_huddle(state: &AppState) -> Result<(), String> { // Increment generation first — this immediately invalidates any // in-flight transcription task, even before pipelines shut down. hs.session_generation.fetch_add(1, Ordering::Release); - let stt = hs.stt_pipeline.take(); + let stt = hs.take_stt_pipeline(); let tts = hs.tts_pipeline.take(); let cancel = hs.audio_ws_cancel.take(); // Cancel the relay token BEFORE dropping the sender. If we drop @@ -873,7 +878,7 @@ pub async fn speak_agent_message( })?; } - let sender = { + let pipeline = { let hs = state.huddle()?; let agent_is_present = hs .agent_pubkeys @@ -887,20 +892,27 @@ pub async fn speak_agent_message( ); return Ok(()); } - hs.tts_pipeline - .as_ref() - .map(|pipeline| pipeline.text_sender()) - .map(|sender| { - let speaker_generation = sender.speaker_generation(&speaker_pubkey); - (sender, speaker_generation) - }) + hs.tts_pipeline.as_ref().map(Arc::clone) }; - let Some((sender, speaker_generation)) = sender else { + let Some(pipeline) = pipeline else { eprintln!( "buzz-desktop: tts stage=invoke status=failed reason=unavailable route_id={route_id}" ); return Err("Agent text to speech is enabled but its audio pipeline is unavailable".into()); }; + match agent_tts_publisher::ensure(&app, &state, &pipeline, &speaker_pubkey).await { + Ok(true) => eprintln!( + "buzz-desktop: tts broadcast status=ready route_id={route_id}" + ), + Ok(false) => eprintln!( + "buzz-desktop: tts broadcast status=unavailable reason=agent_identity_not_local route_id={route_id}" + ), + Err(error) => eprintln!( + "buzz-desktop: tts broadcast status=unavailable reason=publisher_setup_failed route_id={route_id} error={error}" + ), + } + let sender = pipeline.text_sender(); + let speaker_generation = sender.speaker_generation(&speaker_pubkey); enqueue_agent_tts_text(route_id, text, move |route_id, text| { sender .send( diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index afa7aed8e05..47d4aeb43d1 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -55,7 +55,7 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S let mut hs = state.huddle()?; if let Some(ref p) = hs.stt_pipeline { if p.is_finished() { - hs.stt_pipeline = None; + hs.take_stt_pipeline(); } } if let Some(ref p) = hs.tts_pipeline { @@ -311,6 +311,8 @@ pub(crate) async fn maybe_start_stt_pipeline( stt_starting, ptt_active_for_stt, manual_mic_unmuted_for_stt, + human_floor, + output_device, old_stt, ) = { let mut hs = state.huddle()?; @@ -325,7 +327,7 @@ pub(crate) async fn maybe_start_stt_pipeline( if hs.stt_pipeline.is_some() { hs.session_generation.fetch_add(1, Ordering::Release); } - let old = hs.stt_pipeline.take(); + let old = hs.take_stt_pipeline(); if let Some(ref p) = old { p.shutdown(); } @@ -346,6 +348,13 @@ pub(crate) async fn maybe_start_stt_pipeline( stt_starting, ptt, manual_mic_unmuted, + hs.human_floor.clone(), + state + .huddle_audio + .output_device + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), old, ) }; @@ -353,7 +362,13 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, ptt_active_for_stt, manual_mic_unmuted_for_stt) + stt::SttPipeline::new( + model_dir, + ptt_active_for_stt, + manual_mic_unmuted_for_stt, + human_floor, + output_device, + ) }) .await; let (pipeline, text_rx) = match constructed { @@ -382,7 +397,7 @@ pub(crate) async fn maybe_start_stt_pipeline( { return Ok(false); } - hs.stt_pipeline = Some(Arc::clone(&pipeline)); + hs.set_stt_pipeline(Arc::clone(&pipeline)); } spawn_transcription_task(text_rx, channel_uuid, agent_pubkeys_arc, session_gen, state); @@ -457,7 +472,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result Result f32 { ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) } +fn update_remote_release_deadline( + peer: u8, + is_dtx: bool, + remote_floor_owners: &std::collections::HashSet, + deadlines: &mut std::collections::HashMap, + now: tokio::time::Instant, +) { + if !is_dtx { + deadlines.remove(&peer); + } else if remote_floor_owners.contains(&peer) { + deadlines + .entry(peer) + .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + } +} + +fn release_expired_remote_floors( + now: tokio::time::Instant, + owners: &mut std::collections::HashSet, + deadlines: &mut std::collections::HashMap, + human_floor: &HumanFloor, +) { + let released: Vec = deadlines + .iter() + .filter_map(|(peer, deadline)| (*deadline <= now).then_some(*peer)) + .collect(); + for peer in released { + deadlines.remove(&peer); + owners.remove(&peer); + human_floor.leave_remote(peer); + } +} + fn should_recover_playout(depth: usize, currently_recovering: bool) -> bool { if currently_recovering { depth > PLAYOUT_QUEUE_RECOVERY_END @@ -92,11 +127,77 @@ fn should_recover_playout(depth: usize, currently_recovering: bool) -> bool { } } +fn is_locally_synthesized_peer( + peer_idx: u8, + local_tts_publishers: &super::tts::LocalTtsPublishers, +) -> bool { + local_tts_publishers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .contains_key(&peer_idx) +} + +fn is_agent_peer( + peer_idx: u8, + index_to_pubkey: &std::collections::HashMap, + agent_pubkeys: &[String], +) -> bool { + index_to_pubkey.get(&peer_idx).is_some_and(|pubkey| { + agent_pubkeys + .iter() + .any(|agent| agent.eq_ignore_ascii_case(pubkey)) + }) +} + +/// Whether `peer_idx` is currently occupied at exactly `epoch`, per the +/// authoritative roster. A frame is deliverable only when both match: an index +/// absent from the roster is stale, and a slot reused by a later occupant has +/// advanced its epoch, so a departed occupant's in-flight frame is fenced +/// rather than mis-attributed to the new occupant. A legacy relay omits the +/// epoch, which degrades to `0` on both sides, making the fence a no-op. +fn is_current_occupant( + peer_idx: u8, + epoch: u8, + index_to_epoch: &std::collections::HashMap, +) -> bool { + index_to_epoch.get(&peer_idx) == Some(&epoch) +} + +fn same_occupancy( + peer_idx: u8, + pubkey: &str, + epoch: u8, + index_to_pubkey: &std::collections::HashMap, + index_to_epoch: &std::collections::HashMap, +) -> bool { + index_to_pubkey + .get(&peer_idx) + .is_some_and(|current| current == pubkey) + && index_to_epoch.get(&peer_idx) == Some(&epoch) +} + +fn mix_remote_stt_samples(mix: &mut Vec, samples: &[f32]) { + if mix.len() < samples.len() { + mix.resize(samples.len(), 0.0); + } + for (mixed, sample) in mix.iter_mut().zip(samples) { + *mixed = (*mixed + *sample).clamp(-1.0, 1.0); + } +} + +fn f32_samples_to_le_bytes(samples: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(std::mem::size_of_val(samples)); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes +} + /// One remote peer's slot: jitter buffer + dedicated rodio Player. /// /// Per-frame seq/timestamp come from the v2 wire header (sender-authored). -/// The relay forwards `peer_index | header | opus_bytes` opaquely; we parse -/// the header here and pass the sender's own monotonic seq + 48 kHz media +/// The relay forwards `peer_index | epoch | header | opus_bytes` opaquely; we +/// parse the header here and pass the sender's own monotonic seq + 48 kHz media /// timestamp into NetEq. struct PeerSlot { jitter: PeerJitterBuffer, @@ -168,9 +269,13 @@ pub(crate) async fn run_playout_recv_loop( sink_handle: rodio::MixerDeviceSink, cancel: CancellationToken, app_handle: Option, - initial_peers: Vec<(u8, String)>, + initial_peers: Vec<(u8, String, u8)>, tts_active: Arc, tts_cancel: Arc, + local_tts_publishers: super::tts::LocalTtsPublishers, + remote_stt_pipeline: Arc>>>, + agent_pubkeys: Arc>>, + human_floor: HumanFloor, ) { use rodio::buffer::SamplesBuffer; use std::num::NonZero; @@ -180,12 +285,23 @@ pub(crate) async fn run_playout_recv_loop( let rate = NonZero::new(SAMPLE_RATE_HZ).expect("48k is non-zero"); let mut index_to_pubkey: std::collections::HashMap = - initial_peers.into_iter().collect(); + std::collections::HashMap::new(); + // Occupancy epoch per index, mirroring the authoritative roster. Advances + // each time a slot is reused by a new occupant, so a frame authored by a + // departed occupant that arrives after its index is reassigned carries the + // old epoch and is fenced rather than mis-attributed to the new occupant. + let mut index_to_epoch: std::collections::HashMap = std::collections::HashMap::new(); + for (idx, pubkey, epoch) in initial_peers { + index_to_pubkey.insert(idx, pubkey); + index_to_epoch.insert(idx, epoch); + } let mut active_indices: std::collections::HashSet = std::collections::HashSet::new(); let mut speaker_levels: std::collections::HashMap = std::collections::HashMap::new(); + let mut remote_release_deadlines: std::collections::HashMap = + std::collections::HashMap::new(); + let mut remote_floor_owners: std::collections::HashSet = std::collections::HashSet::new(); let mut frame_counts: std::collections::HashMap = std::collections::HashMap::new(); let mut last_frame_reset = tokio::time::Instant::now(); - let mut tts_was_active = false; let mut speaker_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_TICK_MS)); speaker_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -215,6 +331,7 @@ pub(crate) async fn run_playout_recv_loop( // per idle peer into rodio forever. `is_active` is a 500 ms // grace past the last received packet, far longer than typical // DTX comfort-noise cadence. + let mut remote_stt_mix = Vec::new(); for (peer_idx, slot) in peers.iter_mut() { if !slot.is_active() { // Still drain the frame to keep NetEq's internal clock @@ -236,6 +353,17 @@ pub(crate) async fn run_playout_recv_loop( ); slot.player.skip_one(); } + if !is_locally_synthesized_peer(*peer_idx, &local_tts_publishers) { + let remote_agent = { + let agents = agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + is_agent_peer(*peer_idx, &index_to_pubkey, &agents) + }; + if !remote_agent { + mix_remote_stt_samples(&mut remote_stt_mix, &samples); + } + } slot.player.append(SamplesBuffer::new(channels, rate, samples)); } Err(e) => { @@ -245,8 +373,26 @@ pub(crate) async fn run_playout_recv_loop( } } } + if !remote_stt_mix.is_empty() { + let pipeline = remote_stt_pipeline + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .and_then(std::sync::Weak::upgrade); + if let Some(pipeline) = pipeline { + let _ = pipeline.push_remote_audio(f32_samples_to_le_bytes( + &remote_stt_mix, + )); + } + } } _ = speaker_tick.tick() => { + release_expired_remote_floors( + tokio::time::Instant::now(), + &mut remote_floor_owners, + &mut remote_release_deadlines, + &human_floor, + ); if let Some(ref app) = app_handle { use tauri::Emitter; let pubkeys: Vec = active_indices @@ -276,13 +422,31 @@ pub(crate) async fn run_playout_recv_loop( msg = ws_rx.next() => { match msg { Some(Ok(WsMsg::Binary(data))) => { - // Wire shape (v2): [peer_index: u8][header: 8 bytes][opus payload...] - // The minimum size is 1 (peer_index) + 8 (header) + ≥1 Opus byte. - if data.len() <= 1 + V2_HEADER_LEN { + // Wire shape (v2): [peer_index: u8][epoch: u8][header: 8 bytes][opus payload...] + // The minimum size is 2 (peer_index + epoch) + 8 (header) + ≥1 Opus byte. + if data.len() <= 2 + V2_HEADER_LEN { continue; } let peer_idx = data[0]; - let after_idx = &data[1..]; + let epoch = data[1]; + // Fence the peer-index reuse race: a frame authored by a + // departed occupant that arrives after its index is + // reassigned carries the old epoch. Drop it rather than + // mis-attribute stale audio (and the new occupant's + // human/agent STT policy) to whoever grabbed the index. + // An index absent from the roster is also stale. A slot + // with no known epoch (legacy relay) degrades to 0 on + // both sides, so the fence is a no-op there. + if !is_current_occupant(peer_idx, epoch, &index_to_epoch) { + continue; + } + // Suppress only an agent stream synthesized and + // published by this desktop. Other bot-role peers may + // publish their own legitimate audio and must play. + if is_locally_synthesized_peer(peer_idx, &local_tts_publishers) { + continue; + } + let after_idx = &data[2..]; let Some((header, opus_bytes)) = FrameHeader::parse(after_idx) else { // Malformed v2 frame: header parse only fails when @@ -303,6 +467,13 @@ pub(crate) async fn run_playout_recv_loop( // by an idle peer to keep the codec alive — they // don't mean the peer is speaking, and shouldn't // make their tile flash for the 500 ms speaker tick. + update_remote_release_deadline( + peer_idx, + is_dtx, + &remote_floor_owners, + &mut remote_release_deadlines, + tokio::time::Instant::now(), + ); if !is_dtx { active_indices.insert(peer_idx); let level = normalized_speaker_level(header.level_dbov); @@ -312,14 +483,9 @@ pub(crate) async fn run_playout_recv_loop( .or_insert(level); } - // TTS interrupt frame counter — reset on TTS rising edge. - let tts_now = tts_active.load(Ordering::Acquire); - if tts_now && !tts_was_active { - frame_counts.clear(); - last_frame_reset = tokio::time::Instant::now(); - } - tts_was_active = tts_now; - + // Track remote speech independently of TTS liveness so a + // human who starts while output is idle still owns the + // floor and rejects delayed synthesis. let slot = match peers.entry(peer_idx) { std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), std::collections::hash_map::Entry::Vacant(e) => { @@ -347,11 +513,16 @@ pub(crate) async fn run_playout_recv_loop( slot.last_packet_at = tokio::time::Instant::now(); } - // Count remote-speech frame arrivals for the TTS - // interrupt. DTX/comfort frames don't count — they - // mean the peer is silent, just keeping the codec - // state alive. - if tts_now && !is_dtx { + let remote_human = { + let agents = agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + !is_agent_peer(peer_idx, &index_to_pubkey, &agents) + }; + // Count only remote-human speech toward floor onset. + // Agent audio still plays, but it must not acquire the + // human floor or suppress another agent's response. + if !is_dtx && remote_human { if last_frame_reset.elapsed() >= FRAME_WINDOW { frame_counts.clear(); last_frame_reset = tokio::time::Instant::now(); @@ -359,7 +530,11 @@ pub(crate) async fn run_playout_recv_loop( let count = frame_counts.entry(peer_idx).or_insert(0); *count = count.saturating_add(1); if *count >= REMOTE_SPEECH_THRESHOLD { - tts_cancel.store(true, Ordering::Release); + human_floor.enter_remote(peer_idx); + remote_floor_owners.insert(peer_idx); + if tts_active.load(Ordering::Acquire) { + tts_cancel.store(true, Ordering::Release); + } } } } @@ -374,20 +549,30 @@ pub(crate) async fn run_playout_recv_loop( p["peer_index"].as_u64(), ) { let key = idx as u8; - // peer_index reuse with a new pubkey: + // Absent `epoch` (legacy relay) degrades to + // 0 so the fence stays a no-op. + let epoch = + p["epoch"].as_u64().unwrap_or(0) as u8; + // Any new occupancy (pubkey or epoch) must // flush the old peer's NetEq + Player so // the next frame starts clean. - if index_to_pubkey - .get(&key) - .map(|s| s.as_str()) - != Some(pk) - { + if !same_occupancy( + key, + pk, + epoch, + &index_to_pubkey, + &index_to_epoch, + ) { peers.remove(&key); frame_counts.remove(&key); + remote_release_deadlines.remove(&key); + remote_floor_owners.remove(&key); + human_floor.leave_remote(key); active_indices.remove(&key); speaker_levels.remove(&key); } index_to_pubkey.insert(key, pk.to_string()); + index_to_epoch.insert(key, epoch); } } } @@ -395,29 +580,60 @@ pub(crate) async fn run_playout_recv_loop( Some("roster") => { if let Some(peer_list) = v["peers"].as_array() { let mut replacement = std::collections::HashMap::new(); + let mut replacement_epochs = + std::collections::HashMap::new(); for p in peer_list { if let (Some(pk), Some(idx)) = ( p["pubkey"].as_str(), p["peer_index"].as_u64(), ) { - replacement.insert(idx as u8, pk.to_string()); + let key = idx as u8; + let epoch = + p["epoch"].as_u64().unwrap_or(0) as u8; + replacement.insert(key, pk.to_string()); + replacement_epochs.insert(key, epoch); } } let identity_unchanged = |idx: &u8| { - replacement.get(idx) == index_to_pubkey.get(idx) + replacement.get(idx).is_some_and(|pubkey| { + replacement_epochs.get(idx).is_some_and(|epoch| { + same_occupancy( + *idx, + pubkey, + *epoch, + &index_to_pubkey, + &index_to_epoch, + ) + }) + }) }; peers.retain(|idx, _| identity_unchanged(idx)); + for idx in index_to_pubkey + .keys() + .filter(|idx| !identity_unchanged(idx)) + .copied() + .collect::>() + { + human_floor.leave_remote(idx); + remote_release_deadlines.remove(&idx); + remote_floor_owners.remove(&idx); + } frame_counts.retain(|idx, _| identity_unchanged(idx)); active_indices.retain(identity_unchanged); speaker_levels.retain(|idx, _| identity_unchanged(idx)); index_to_pubkey = replacement; + index_to_epoch = replacement_epochs; } } Some("left") => { if let Some(idx) = v["peer_index"].as_u64() { let key = idx as u8; index_to_pubkey.remove(&key); + index_to_epoch.remove(&key); frame_counts.remove(&key); + remote_release_deadlines.remove(&key); + remote_floor_owners.remove(&key); + human_floor.leave_remote(key); active_indices.remove(&key); speaker_levels.remove(&key); // Dropping Player detaches its queue from the @@ -441,6 +657,7 @@ pub(crate) async fn run_playout_recv_loop( } } + human_floor.clear_remote(); if let Some(ref app) = app_handle { use tauri::Emitter; let _ = app.emit( @@ -454,6 +671,50 @@ pub(crate) async fn run_playout_recv_loop( mod tests { use super::*; + #[test] + fn continuous_dtx_does_not_extend_remote_floor_deadline() { + let peer = 7; + let started = tokio::time::Instant::now(); + let owners = std::collections::HashSet::from([peer]); + let mut deadlines = std::collections::HashMap::new(); + + update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + let armed = deadlines[&peer]; + for elapsed_ms in [100, 200, 300, 400] { + update_remote_release_deadline( + peer, + true, + &owners, + &mut deadlines, + started + std::time::Duration::from_millis(elapsed_ms), + ); + } + + assert_eq!(deadlines[&peer], armed); + assert!(armed <= started + REMOTE_RELEASE_DEBOUNCE); + + let human_floor = HumanFloor::new(); + human_floor.enter_remote(peer); + let mut owners = owners; + release_expired_remote_floors(armed, &mut owners, &mut deadlines, &human_floor); + assert!(!human_floor.is_blocked()); + assert!(owners.is_empty()); + assert!(deadlines.is_empty()); + } + + #[test] + fn dtx_from_non_owner_does_not_arm_remote_floor_deadline() { + let mut deadlines = std::collections::HashMap::new(); + update_remote_release_deadline( + 7, + true, + &std::collections::HashSet::new(), + &mut deadlines, + tokio::time::Instant::now(), + ); + assert!(deadlines.is_empty()); + } + #[test] fn speaker_level_maps_conversational_range() { assert_eq!(normalized_speaker_level(-127), 0.0); @@ -470,4 +731,87 @@ mod tests { assert!(should_recover_playout(5, true)); assert!(!should_recover_playout(4, true)); } + + #[test] + fn only_the_local_socket_is_suppressed_for_a_shared_agent_identity() { + let local_publishers = super::super::tts::LocalTtsPublishers::default(); + local_publishers + .lock() + .expect("local publishers") + .insert(3, 1); + + assert!(is_locally_synthesized_peer(3, &local_publishers)); + assert!( + !is_locally_synthesized_peer(4, &local_publishers), + "a second socket for the same agent remains audible" + ); + assert!(!is_locally_synthesized_peer(9, &local_publishers)); + } + + #[test] + fn remote_agent_identity_is_excluded_from_human_stt() { + let peers = + std::collections::HashMap::from([(3, "human".to_owned()), (4, "AGENT".to_owned())]); + let agents = vec!["agent".to_owned()]; + + assert!(!is_agent_peer(3, &peers, &agents)); + assert!(is_agent_peer(4, &peers, &agents)); + assert!(!is_agent_peer(9, &peers, &agents)); + } + + #[test] + fn occupancy_identity_includes_epoch_for_same_pubkey_rejoin() { + let pubkeys = std::collections::HashMap::from([(3_u8, "alice".to_owned())]); + let epochs = std::collections::HashMap::from([(3_u8, 4_u8)]); + + assert!(same_occupancy(3, "alice", 4, &pubkeys, &epochs)); + assert!( + !same_occupancy(3, "alice", 5, &pubkeys, &epochs), + "same pubkey with a new epoch must reset decoder and playout state" + ); + } + + /// Causal regression for the peer-index reuse race (Jude's blocking + /// finding): a frame authored by a departed occupant that arrives after + /// its slot is reassigned to a new occupant carries the stale epoch and + /// must be fenced, never mis-attributed to the new occupant. + #[test] + fn stale_epoch_frame_is_fenced_after_its_index_is_reused() { + let mut index_to_epoch = std::collections::HashMap::new(); + // Slot 3 first occupied at epoch 0. + index_to_epoch.insert(3_u8, 0_u8); + assert!( + is_current_occupant(3, 0, &index_to_epoch), + "current occupant's frame is delivered" + ); + + // The occupant departs and a new peer reuses slot 3 at epoch 1. + index_to_epoch.insert(3, 1); + assert!( + !is_current_occupant(3, 0, &index_to_epoch), + "in-flight frame from the departed occupant (epoch 0) is fenced" + ); + assert!( + is_current_occupant(3, 1, &index_to_epoch), + "the new occupant's frame (epoch 1) is delivered" + ); + + // A frame for an index absent from the roster is stale. + assert!( + !is_current_occupant(9, 0, &index_to_epoch), + "frame for an unoccupied index is dropped" + ); + } + + #[test] + fn remote_human_stt_mix_sums_and_clamps_concurrent_speakers() { + let mut mix = Vec::new(); + mix_remote_stt_samples(&mut mix, &[0.4, -0.7, 0.2]); + mix_remote_stt_samples(&mut mix, &[0.8, -0.6, -0.1]); + + assert_eq!(mix, vec![1.0, -1.0, 0.1]); + let bytes = f32_samples_to_le_bytes(&mix); + assert_eq!(bytes.len(), std::mem::size_of_val(mix.as_slice())); + assert_eq!(f32::from_le_bytes(bytes[0..4].try_into().unwrap()), 1.0); + } } diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 3f2aa76a560..20a2be57652 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -41,30 +41,44 @@ pub(crate) fn parse_channel_uuid(channel_id: &str) -> Result { /// Handshake timeout — matches the server's AUTH_TIMEOUT (5 s). const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -/// Connect to the relay's audio WebSocket and run the Opus encode/decode pipeline. -/// -/// Returns `(cancel_token, pcm_sender)` — caller stores both in `HuddleState`. -/// Dropping the sender or calling `cancel.cancel()` shuts down the relay task. -pub(crate) async fn connect_audio_relay( +fn build_audio_auth_event( + keys: &nostr::Keys, + relay_url: &str, + challenge: &str, + auth_tag_json: Option<&str>, +) -> Result { + let mut tags = vec![ + nostr::Tag::parse(["relay", relay_url]).map_err(|e| format!("tag relay: {e}"))?, + nostr::Tag::parse(["challenge", challenge]).map_err(|e| format!("tag challenge: {e}"))?, + ]; + if let Some(auth_tag_json) = auth_tag_json { + let compat_pubkey = nostr::PublicKey::from_hex(&keys.public_key().to_hex()) + .map_err(|e| format!("agent pubkey conversion failed: {e}"))?; + buzz_sdk_pkg::nip_oa::verify_auth_tag(auth_tag_json, &compat_pubkey) + .map_err(|e| format!("agent auth tag verification failed: {e}"))?; + let compat_tag = buzz_sdk_pkg::nip_oa::parse_auth_tag(auth_tag_json) + .map_err(|e| format!("agent auth tag parse failed: {e}"))?; + tags.push( + nostr::Tag::parse(compat_tag.as_slice()) + .map_err(|e| format!("agent auth tag conversion failed: {e}"))?, + ); + } + nostr::EventBuilder::new(nostr::Kind::Custom(22242), "") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| format!("sign: {e}")) +} + +async fn connect_authenticated_audio_socket( channel_id: &str, parent_channel_id: Option<&str>, - state: &AppState, -) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { + relay_url: &str, + keys: &nostr::Keys, + auth_tag_json: Option<&str>, +) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), String> { use nostr::JsonUtil; - let relay_url = crate::relay::relay_ws_url_with_override(state); let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); - - let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); - - // TTS interrupt flags — recv task cancels TTS when remote humans speak. - let (tts_cancel, tts_active) = { - let hs = state.huddle()?; - (Arc::clone(&hs.tts_cancel), Arc::clone(&hs.tts_active)) - }; - - let app_handle = state.app_handle.lock().ok().and_then(|g| g.clone()); - let (ws_stream, _) = connect_async(&ws_url) .await .map_err(|e| format!("audio WS connect failed: {e}"))?; @@ -74,13 +88,13 @@ pub(crate) async fn connect_audio_relay( loop { match ws_rx.next().await { Some(Ok(WsMsg::Text(text))) => { - let v: serde_json::Value = serde_json::from_str(&text) + let value: serde_json::Value = serde_json::from_str(&text) .map_err(|e| format!("bad challenge JSON: {e}"))?; - if v["type"] == "challenge" { - break v["challenge"] + if value["type"] == "challenge" { + break value["challenge"] .as_str() .ok_or_else(|| "missing challenge string".to_string()) - .map(|s| s.to_string()); + .map(str::to_string); } } Some(Ok(WsMsg::Close(_))) | None => { @@ -91,29 +105,15 @@ pub(crate) async fn connect_audio_relay( } }) .await - .map_err(|_| "timeout waiting for challenge from relay".to_string())? - .map_err(|e: String| e)?; - - let tags = vec![ - nostr::Tag::parse(["relay", &relay_url]).map_err(|e| format!("tag relay: {e}"))?, - nostr::Tag::parse(["challenge", &challenge]).map_err(|e| format!("tag challenge: {e}"))?, - ]; - let event = nostr::EventBuilder::new(nostr::Kind::Custom(22242), "") - .tags(tags) - .sign_with_keys(&keys) - .map_err(|e| format!("sign: {e}"))?; + .map_err(|_| "timeout waiting for challenge from relay".to_string())??; + let event = build_audio_auth_event(keys, relay_url, &challenge, auth_tag_json)?; let event_json: serde_json::Value = serde_json::from_str(&event.as_json()) .map_err(|e| format!("failed to serialize auth event: {e}"))?; let auth_msg = serde_json::json!({ "type": "auth", "event": event_json, "parent_channel_id": parent_channel_id, - // Negotiate huddle audio protocol v2 (8-byte sender-authored header - // per Opus frame: seq | ts_48k | level_dbov | flags). See - // huddle::wire for the layout. The relay pins the first joiner's - // version per-room and rejects mismatched joiners with - // `upgrade_required`. "protocol_version": super::wire::PROTOCOL_VERSION, }); ws_tx @@ -121,30 +121,39 @@ pub(crate) async fn connect_audio_relay( .await .map_err(|e| format!("send auth: {e}"))?; - let initial_peers: Vec<(u8, String)> = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { + let (peer_index, initial_peers) = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { loop { match ws_rx.next().await { Some(Ok(WsMsg::Text(text))) => { - let v: serde_json::Value = serde_json::from_str(&text).unwrap_or_default(); - match v["type"].as_str() { + let value: serde_json::Value = serde_json::from_str(&text).unwrap_or_default(); + match value["type"].as_str() { Some("joined") => { - let peers = v["peers"] + let peers = value["peers"] .as_array() - .map(|arr| { - arr.iter() - .filter_map(|p| { + .map(|peers| { + peers + .iter() + .filter_map(|peer| { Some(( - p["peer_index"].as_u64()? as u8, - p["pubkey"].as_str()?.to_string(), + peer["peer_index"].as_u64()? as u8, + peer["pubkey"].as_str()?.to_string(), + // Absent `epoch` (legacy relay) degrades + // to 0 so the fence becomes a no-op rather + // than rejecting every frame. + peer["epoch"].as_u64().unwrap_or(0) as u8, )) }) - .collect::>() + .collect() }) .unwrap_or_default(); - break Ok(peers); + let peer_index = value["peer_index"] + .as_u64() + .and_then(|index| u8::try_from(index).ok()) + .ok_or_else(|| "joined message missing peer index".to_string())?; + break Ok((peer_index, peers)); } Some("error") => { - break Err(format!("audio relay auth error: {}", v["message"])); + break Err(format!("audio relay auth error: {}", value["message"])); } _ => continue, } @@ -157,8 +166,48 @@ pub(crate) async fn connect_audio_relay( } }) .await - .map_err(|_| "timeout waiting for joined from relay".to_string())? - .map_err(|e: String| e)?; + .map_err(|_| "timeout waiting for joined from relay".to_string())??; + + Ok((ws_tx, ws_rx, peer_index, initial_peers)) +} + +/// Connect to the relay's audio WebSocket and run the Opus encode/decode pipeline. +/// +/// Returns `(cancel_token, pcm_sender)` — caller stores both in `HuddleState`. +/// Dropping the sender or calling `cancel.cancel()` shuts down the relay task. +pub(crate) async fn connect_audio_relay( + channel_id: &str, + parent_channel_id: Option<&str>, + state: &AppState, +) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + + // TTS interrupt flags — recv task cancels TTS when remote humans speak. + let ( + tts_cancel, + tts_active, + local_tts_publishers, + remote_stt_pipeline, + agent_pubkeys, + human_floor, + ) = { + let hs = state.huddle()?; + ( + Arc::clone(&hs.tts_cancel), + Arc::clone(&hs.tts_active), + Arc::clone(&hs.local_tts_publishers), + Arc::clone(&hs.remote_stt_pipeline), + Arc::clone(&hs.agent_pubkeys), + hs.human_floor.clone(), + ) + }; + + let app_handle = state.app_handle.lock().ok().and_then(|g| g.clone()); + + let (ws_tx, ws_rx, _peer_index, initial_peers) = + connect_authenticated_audio_socket(channel_id, parent_channel_id, &relay_url, &keys, None) + .await?; let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); @@ -180,6 +229,10 @@ pub(crate) async fn connect_audio_relay( initial_peers, tts_cancel, tts_active, + local_tts_publishers, + remote_stt_pipeline, + agent_pubkeys, + human_floor, output_device_name, }) .await @@ -204,6 +257,193 @@ pub(crate) async fn connect_audio_relay( /// Background Opus encode/decode pipeline spawned by `connect_audio_relay`. pub(crate) type WsStream = tokio_tungstenite::WebSocketStream>; +type WsSink = futures_util::stream::SplitSink; +type WsReceiver = futures_util::stream::SplitStream; + +const TTS_BROADCAST_QUEUE_DEPTH: usize = 8; +const TTS_BROADCAST_MAX_FRAMES: usize = 1_500; // 30 seconds at 20 ms/frame. + +struct QueuedTtsFrame { + epoch: u64, + speaker_generation: u64, + samples_48k: Vec, +} + +fn upsample_tts_24k_to_48k(samples_24k: &[f32]) -> Vec { + let mut samples_48k = Vec::with_capacity(samples_24k.len().saturating_mul(2)); + for (index, sample) in samples_24k.iter().copied().enumerate() { + let next = samples_24k.get(index + 1).copied().unwrap_or(sample); + samples_48k.push(sample); + samples_48k.push((sample + next) * 0.5); + } + samples_48k +} + +fn queue_tts_broadcast_packet( + queue: &mut std::collections::VecDeque, + packet: super::tts::TtsBroadcastPacket, + current_epoch: u64, + current_speaker_generation: u64, +) { + if packet.epoch != current_epoch + || packet.speaker_generation != current_speaker_generation + || packet.samples_24k.is_empty() + { + return; + } + let samples_48k = upsample_tts_24k_to_48k(&packet.samples_24k); + for chunk in samples_48k.chunks(960) { + if queue.len() >= TTS_BROADCAST_MAX_FRAMES { + eprintln!("buzz-desktop: tts broadcast status=dropped reason=queue_duration_limit"); + break; + } + let mut frame = chunk.to_vec(); + frame.resize(960, 0.0); + queue.push_back(QueuedTtsFrame { + epoch: packet.epoch, + speaker_generation: packet.speaker_generation, + samples_48k: frame, + }); + } +} + +/// Open a send-only v2 Huddle audio peer authenticated as a locally managed +/// agent. The relay therefore assigns the synthesized stream to that agent's +/// existing pubkey; no backend or wire-protocol extension is required. +pub(crate) async fn connect_tts_audio_publisher( + channel_id: &str, + parent_channel_id: Option<&str>, + state: &AppState, + keys: &nostr::Keys, + auth_tag_json: Option<&str>, + local_tts_publishers: super::tts::LocalTtsPublishers, +) -> Result { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let (ws_tx, ws_rx, peer_index, _) = connect_authenticated_audio_socket( + channel_id, + parent_channel_id, + &relay_url, + keys, + auth_tag_json, + ) + .await?; + + let cancel = CancellationToken::new(); + let publisher_cancel = cancel.clone(); + let (tx, rx) = tokio::sync::mpsc::channel(TTS_BROADCAST_QUEUE_DEPTH); + let publisher = super::tts::TtsAudioPublisher::new(tx, cancel); + let (epoch, speaker_generation) = publisher.version_state(); + let local_publisher = super::tts::LocalTtsPublisherLease::new(peer_index, local_tts_publishers); + tokio::spawn(async move { + let _local_publisher = local_publisher; + if let Err(error) = run_tts_audio_publisher( + ws_tx, + ws_rx, + rx, + publisher_cancel.clone(), + epoch, + speaker_generation, + ) + .await + { + eprintln!("buzz-desktop: tts broadcast status=disconnected error={error}"); + } + publisher_cancel.cancel(); + }); + Ok(publisher) +} + +async fn run_tts_audio_publisher( + mut ws_tx: WsSink, + mut ws_rx: WsReceiver, + mut audio_rx: tokio::sync::mpsc::Receiver, + cancel: CancellationToken, + epoch: Arc, + speaker_generation: Arc, +) -> Result<(), String> { + use super::wire::{audio_level_dbov, FrameHeader, V2_HEADER_LEN}; + use std::sync::atomic::Ordering; + + let mut encoder = opus::Encoder::new(48_000, opus::Channels::Mono, opus::Application::Voip) + .map_err(|error| format!("tts opus encoder: {error}"))?; + encoder + .set_bitrate(opus::Bitrate::Bits(32_000)) + .map_err(|error| format!("tts opus bitrate: {error}"))?; + encoder + .set_dtx(true) + .map_err(|error| format!("tts opus dtx: {error}"))?; + + let mut sequence = 0_u16; + let mut timestamp_48k = 0_u32; + let mut encoded = vec![0_u8; 4_000]; + let mut queue = std::collections::VecDeque::::new(); + let mut send_tick = tokio::time::interval(std::time::Duration::from_millis(20)); + send_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = send_tick.tick() => { + let current_epoch = epoch.load(Ordering::Acquire); + let current_generation = speaker_generation.load(Ordering::Acquire); + while queue.front().is_some_and(|frame| { + frame.epoch != current_epoch + || frame.speaker_generation != current_generation + }) { + queue.pop_front(); + } + let Some(frame) = queue.pop_front() else { continue }; + let level = audio_level_dbov(&frame.samples_48k); + let encoded_len = encoder + .encode_float(&frame.samples_48k, &mut encoded) + .map_err(|error| format!("tts opus encode: {error}"))?; + if encoded_len == 0 { + continue; + } + let flags = if encoded_len <= 2 { super::wire::FLAG_DTX } else { 0 }; + let header = FrameHeader { + seq: sequence, + ts_48k: timestamp_48k, + level_dbov: level, + flags, + } + .encode(); + let mut payload = Vec::with_capacity(V2_HEADER_LEN + encoded_len); + payload.extend_from_slice(&header); + payload.extend_from_slice(&encoded[..encoded_len]); + ws_tx + .send(WsMsg::Binary(payload.into())) + .await + .map_err(|error| format!("tts audio send: {error}"))?; + sequence = sequence.wrapping_add(1); + timestamp_48k = timestamp_48k.wrapping_add(super::jitter::FRAME_TIMESTAMP_DELTA); + } + message = ws_rx.next() => { + match message { + Some(Ok(WsMsg::Ping(data))) => { + ws_tx.send(WsMsg::Pong(data)).await + .map_err(|error| format!("tts audio pong: {error}"))?; + } + Some(Ok(WsMsg::Close(_))) | None => break, + Some(Err(error)) => return Err(format!("tts audio receive: {error}")), + Some(Ok(_)) => {} + } + } + packet = audio_rx.recv() => { + let Some(packet) = packet else { break }; + queue_tts_broadcast_packet( + &mut queue, + packet, + epoch.load(Ordering::Acquire), + speaker_generation.load(Ordering::Acquire), + ); + } + } + } + let _ = ws_tx.send(WsMsg::Close(None)).await; + Ok(()) +} struct AudioRelayPipelineArgs { ws_tx: futures_util::stream::SplitSink, @@ -211,9 +451,13 @@ struct AudioRelayPipelineArgs { pcm_rx: tokio::sync::mpsc::Receiver>, cancel: CancellationToken, app_handle: Option, - initial_peers: Vec<(u8, String)>, + initial_peers: Vec<(u8, String, u8)>, tts_cancel: Arc, tts_active: Arc, + local_tts_publishers: super::tts::LocalTtsPublishers, + remote_stt_pipeline: Arc>>>, + agent_pubkeys: Arc>>, + human_floor: super::human_floor::HumanFloor, output_device_name: Option, } @@ -227,6 +471,10 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String initial_peers, tts_cancel, tts_active, + local_tts_publishers, + remote_stt_pipeline, + agent_pubkeys, + human_floor, output_device_name, } = args; @@ -336,6 +584,10 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String initial_peers, tts_active, tts_cancel, + local_tts_publishers, + remote_stt_pipeline, + agent_pubkeys, + human_floor, )); // Wait for either task to finish, then abort the survivor. @@ -414,3 +666,45 @@ pub(crate) async fn count_human_members( .filter(|(_, role)| role.as_deref() != Some("bot")) .count()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tts_upsampling_doubles_rate_with_linear_midpoints() { + assert_eq!( + upsample_tts_24k_to_48k(&[0.0, 1.0, -1.0]), + vec![0.0, 0.5, 1.0, 0.0, -1.0, -1.0] + ); + } + + #[test] + fn tts_queue_rejects_cancelled_versions_and_pads_twenty_ms_frames() { + let mut queue = std::collections::VecDeque::new(); + queue_tts_broadcast_packet( + &mut queue, + super::super::tts::TtsBroadcastPacket { + epoch: 1, + speaker_generation: 7, + samples_24k: vec![0.25; 480], + }, + 1, + 7, + ); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].samples_48k.len(), 960); + + queue_tts_broadcast_packet( + &mut queue, + super::super::tts::TtsBroadcastPacket { + epoch: 1, + speaker_generation: 7, + samples_24k: vec![0.5; 480], + }, + 2, + 7, + ); + assert_eq!(queue.len(), 1, "cancelled epoch must not enqueue"); + } +} diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index c615ff19c2e..c7aff1bf7e2 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -7,10 +7,11 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex, + Arc, Mutex, Weak, }; use super::agent_voice::AgentVoiceSettings; +use super::human_floor::HumanFloor; use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). @@ -78,9 +79,20 @@ pub struct HuddleState { /// Active STT pipeline — not serialized, not cloned. #[serde(skip)] pub stt_pipeline: Option>, + /// Weak STT handle shared with the audio receive loop so remote human + /// speech can reach transcription even when the pipeline hot-starts after + /// the Huddle audio socket was connected. The state-owned strong handle + /// above remains the sole owner and teardown clears both atomically. + #[serde(skip)] + pub remote_stt_pipeline: Arc>>>, /// Active TTS pipeline — not serialized, not cloned. #[serde(skip)] pub tts_pipeline: Option>, + /// Peer indices currently publishing locally synthesized TTS sockets. The + /// receive loop uses this live registry to suppress only this desktop's + /// echo, never another socket authenticated as the same bot. + #[serde(skip)] + pub local_tts_publishers: tts::LocalTtsPublishers, /// Whether this client created the huddle (vs. joined it). /// Used to enforce that only the creator can end/archive the huddle. pub is_creator: bool, @@ -106,6 +118,10 @@ pub struct HuddleState { /// restarts — both STT and TTS reference the same flag for the entire huddle. #[serde(skip)] pub tts_cancel: Arc, + /// Shared human-floor state. Confirmed local or remote human speech hard + /// cancels TTS and blocks stale/new playback until every source releases. + #[serde(skip)] + pub human_floor: HumanFloor, /// Sentinel: true while a TTS pipeline is being constructed (outside the lock). /// Prevents TOCTOU races where two concurrent callers both pass the `is_some()` /// check and both spawn TTS worker threads — the loser's thread would leak. @@ -182,13 +198,16 @@ impl Clone for HuddleState { agent_pubkeys: Arc::new(Mutex::new(agent_pubkeys_snapshot)), agent_voice_settings: self.agent_voice_settings.clone(), stt_pipeline: None, // Never clone the pipeline handle. + remote_stt_pipeline: Arc::new(Mutex::new(None)), tts_pipeline: None, // Never clone the pipeline handle. + local_tts_publishers: Arc::clone(&self.local_tts_publishers), is_creator: self.is_creator, tts_enabled: self.tts_enabled, transcription_enabled: self.transcription_enabled, transcription_user_controlled: self.transcription_user_controlled, tts_active: Arc::clone(&self.tts_active), tts_cancel: Arc::clone(&self.tts_cancel), + human_floor: self.human_floor.clone(), tts_starting: Arc::clone(&self.tts_starting), stt_starting: Arc::clone(&self.stt_starting), last_agent_refresh: self.last_agent_refresh, @@ -203,6 +222,8 @@ impl Clone for HuddleState { impl Default for HuddleState { fn default() -> Self { + let tts_cancel = Arc::new(AtomicBool::new(false)); + let human_floor = HumanFloor::new(); Self { phase: HuddlePhase::Idle, parent_channel_id: None, @@ -214,13 +235,16 @@ impl Default for HuddleState { agent_pubkeys: Arc::new(Mutex::new(Vec::new())), agent_voice_settings: BTreeMap::new(), stt_pipeline: None, + remote_stt_pipeline: Arc::new(Mutex::new(None)), tts_pipeline: None, + local_tts_publishers: tts::LocalTtsPublishers::default(), is_creator: false, tts_enabled: true, transcription_enabled: false, transcription_user_controlled: false, tts_active: Arc::new(AtomicBool::new(false)), - tts_cancel: Arc::new(AtomicBool::new(false)), + tts_cancel, + human_floor, tts_starting: Arc::new(AtomicBool::new(false)), stt_starting: Arc::new(AtomicBool::new(false)), last_agent_refresh: None, @@ -234,6 +258,22 @@ impl Default for HuddleState { } impl HuddleState { + pub(crate) fn set_stt_pipeline(&mut self, pipeline: Arc) { + *self + .remote_stt_pipeline + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(Arc::downgrade(&pipeline)); + self.stt_pipeline = Some(pipeline); + } + + pub(crate) fn take_stt_pipeline(&mut self) -> Option> { + self.remote_stt_pipeline + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + self.stt_pipeline.take() + } + /// Begin a new local huddle lifetime and return its identity. pub(crate) fn begin_huddle_lifetime(&mut self) -> u64 { self.huddle_generation = self.huddle_generation.wrapping_add(1); diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 8185944834a..c27bf38b649 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -32,6 +32,8 @@ use std::{ use tokio::sync::mpsc as tokio_mpsc; +use super::{human_floor::HumanFloor, local_barge_in}; + // ── Public pipeline handle ──────────────────────────────────────────────────── /// Bounded audio queue capacity. @@ -52,21 +54,33 @@ const MAX_SPEECH_SAMPLES: usize = 16_000 * 30; #[derive(Debug)] pub struct SttPipeline { /// Send raw PCM bytes (f32 LE, 48 kHz mono) into the pipeline. - audio_tx: SyncSender>, + audio_tx: SyncSender, /// Signals the worker thread to stop. shutdown: Arc, /// Worker thread handle — taken on drop to join cleanly. thread: Option>, } +#[derive(Debug)] +struct SttAudioInput { + pcm_bytes: Vec, + origin: SttAudioOrigin, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SttAudioOrigin { + Local, + RemoteHuman, +} + impl SttPipeline { /// Spawn the pipeline thread. /// - /// Mic input is transcribed even while agent TTS is playing: the huddle UI - /// already tells users to wear headphones, so speaker bleed is accepted in - /// exchange for never dropping human speech that overlaps agent audio. - /// Local mic frames still never cancel TTS — push-to-talk and remote - /// participant speech remain the explicit barge-in paths. + /// Mic input is transcribed even while agent TTS is playing. In open-mic + /// VAD mode, confirmed speech acquires the shared human floor: immediately + /// on an isolated output route, or after the restored 320 ms sustained- + /// speech debounce on an acoustically coupled route. Push-to-talk retains + /// its explicit shortcut cancellation path. /// /// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut /// is enabled. The pipeline accepts speech while either input path is open; @@ -85,8 +99,10 @@ impl SttPipeline { model_dir: PathBuf, ptt_active: Option>, manual_mic_unmuted: Option>, + human_floor: HumanFloor, + output_device: Option, ) -> Result<(Self, tokio_mpsc::Receiver), String> { - let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); + let (audio_tx, audio_rx) = mpsc::sync_channel::(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); let shutdown = Arc::new(AtomicBool::new(false)); @@ -103,6 +119,8 @@ impl SttPipeline { shutdown_worker, ptt_active_worker, manual_mic_unmuted_worker, + human_floor, + output_device, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -131,6 +149,18 @@ impl SttPipeline { /// Non-blocking. Drops audio silently if the pipeline can't keep up — /// better to lose frames than to stall the UI thread. pub fn push_audio(&self, pcm_bytes: Vec) -> Result<(), String> { + self.push_audio_from(pcm_bytes, SttAudioOrigin::Local) + } + + /// Feed decoded remote-human PCM into transcription. Unlike the desktop + /// microphone path, this is not gated by the desktop PTT or mute state: the + /// remote participant already made their transmission choice on their own + /// device before the relay delivered these samples. + pub fn push_remote_audio(&self, pcm_bytes: Vec) -> Result<(), String> { + self.push_audio_from(pcm_bytes, SttAudioOrigin::RemoteHuman) + } + + fn push_audio_from(&self, pcm_bytes: Vec, origin: SttAudioOrigin) -> Result<(), String> { // Reject non-4-byte-aligned input — would silently truncate in bytes_to_f32. if !pcm_bytes.len().is_multiple_of(4) { return Err(format!( @@ -139,7 +169,7 @@ impl SttPipeline { )); } // Drop audio if the pipeline can't keep up — better than blocking the UI. - let _ = self.audio_tx.try_send(pcm_bytes); + let _ = self.audio_tx.try_send(SttAudioInput { pcm_bytes, origin }); Ok(()) } } @@ -205,6 +235,7 @@ const MIN_VOICED_FRAMES: usize = 12; #[derive(Debug, PartialEq, Eq)] enum VadFrameAction { None, + ConfirmedOnset, Speech, FirstSilence, Flush, @@ -268,7 +299,7 @@ impl VadEndpoint { for buffered in self.pre_roll.drain(..) { self.speech_buf.extend_from_slice(&buffered); } - return VadFrameAction::Speech; + return VadFrameAction::ConfirmedOnset; } if probability > VAD_OFFSET_THRESHOLD { @@ -342,31 +373,85 @@ fn stt_speculative_decode() -> bool { std::env::var("BUZZ_STT_SPECULATIVE").is_ok_and(|v| v == "1") } +struct SttStreamState { + resampler: rubato::Fft, + chunk_in: usize, + input_buf_48k: Vec, + leftover_16k: Vec, + vad: earshot::Detector, + endpoint: VadEndpoint, + speculative: Option<(String, usize)>, +} + +impl SttStreamState { + fn new() -> Result { + use rubato::{FixedSync, Resampler}; + + let resampler = rubato::Fft::::new(48_000, 16_000, 1024, 2, 1, FixedSync::Input) + .map_err(|error| format!("STT resampler init failed: {error}"))?; + let chunk_in = resampler.input_frames_next(); + Ok(Self { + resampler, + chunk_in, + input_buf_48k: Vec::with_capacity(chunk_in * 2), + leftover_16k: Vec::new(), + vad: earshot::Detector::new(earshot::DefaultPredictor::new()), + endpoint: VadEndpoint::new(), + speculative: None, + }) + } +} + +#[derive(Debug)] +enum SttLoopInput { + Tick, + Batch(Vec), +} + +fn run_stt_receive_loop( + audio_rx: Receiver, + shutdown: &AtomicBool, + human_floor: HumanFloor, + mut process: impl FnMut(SttLoopInput, &mut local_barge_in::LocalBargeIn), +) { + let mut local_barge_in_state = local_barge_in::WorkerLocalBargeIn::new(human_floor); + + loop { + // Check shutdown flag before blocking. + if shutdown.load(Ordering::Acquire) { + break; + } + + process(SttLoopInput::Tick, &mut local_barge_in_state); + + // Use recv_timeout so we can periodically check the shutdown flag. + let input = match audio_rx.recv_timeout(RECV_TIMEOUT) { + Ok(input) => input, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, // Sender dropped. + }; + + // Drain any additional pending messages to batch-process. + let mut batch = vec![input]; + while let Ok(input) = audio_rx.try_recv() { + batch.push(input); + } + process(SttLoopInput::Batch(batch), &mut local_barge_in_state); + } +} + +#[allow(clippy::too_many_arguments)] fn stt_worker( model_dir: PathBuf, - audio_rx: Receiver>, + audio_rx: Receiver, text_tx: tokio_mpsc::Sender, shutdown: Arc, ptt_active: Option>, manual_mic_unmuted: Option>, + human_floor: HumanFloor, + output_device: Option, ) { - // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── - use rubato::{Fft, FixedSync, Resampler}; - - let mut resampler = match Fft::::new(48_000, 16_000, 1024, 2, 1, FixedSync::Input) { - Ok(r) => r, - Err(e) => { - eprintln!("buzz-desktop: STT resampler init failed: {e}"); - return; - } - }; - let chunk_in = resampler.input_frames_next(); - - // ── 2. Initialise earshot VAD ───────────────────────────────────────────── - use earshot::{DefaultPredictor, Detector}; - let mut vad = Detector::new(DefaultPredictor::new()); - - // ── 3. Initialise sherpa-onnx recognizer ───────────────────────────────── + // ── 1. Initialise sherpa-onnx recognizer ───────────────────────────────── // // Parakeet TDT-CTC 110M ships as a single `model.int8.onnx` (CTC head) plus // `tokens.txt`. sherpa-onnx infers the model family from which inner config @@ -403,100 +488,137 @@ fn stt_worker( } }; - // ── 4. Processing state ─────────────────────────────────────────────────── - // Leftover 48 kHz samples that didn't fill a full resampler chunk. - let mut input_buf_48k: Vec = Vec::with_capacity(chunk_in * 2); - // Leftover 16 kHz samples that didn't fill a full VAD frame. - let mut leftover_16k: Vec = Vec::new(); - // Model-independent endpointing state around Earshot's frame probabilities. - let mut endpoint = VadEndpoint::new(); - // Silence flush window (frames) — fixed at the production value. - let flush_frames = SILENCE_FLUSH_FRAMES; - // EXPERIMENTAL: speculative decode result + the voiced-frame count it was - // computed at. Valid only while no new voiced frame has arrived since. + // ── 2. Independent local and remote processing state ───────────────────── + // Separate resampler/VAD state prevents simultaneous desktop and remote + // speech from being serialized into one artificial utterance. + let mut local_stream = match SttStreamState::new() { + Ok(stream) => stream, + Err(error) => { + eprintln!("buzz-desktop: {error}"); + return; + } + }; + let mut remote_stream = match SttStreamState::new() { + Ok(stream) => stream, + Err(error) => { + eprintln!("buzz-desktop: {error}"); + return; + } + }; let speculative_enabled = stt_speculative_decode(); - let mut speculative: Option<(String, usize)> = None; - - // ── 5. Main loop ────────────────────────────────────────────────────────── let mut transmit_was_active = ptt_active .as_ref() .is_some_and(|ptt| ptt.load(Ordering::Acquire)) || manual_mic_unmuted .as_ref() .is_some_and(|manual| manual.load(Ordering::Acquire)); - loop { - // Check shutdown flag before blocking. - if shutdown.load(Ordering::Acquire) { - break; - } - // Track the combined manual/PTT transmission edge. When both paths - // close, the worklet stops sending frames, so flush here rather than - // waiting for silence that will never arrive. - if let Some(ref ptt) = ptt_active { - let transmit_now = ptt.load(Ordering::Acquire) - || manual_mic_unmuted - .as_ref() - .is_some_and(|manual| manual.load(Ordering::Acquire)); - if transmit_was_active - && !transmit_now - && endpoint.in_speech - && !endpoint.speech_buf.is_empty() - { - flush_to_stt( - &endpoint.speech_buf, - endpoint.voiced_frames, - &recognizer, - &text_tx, - ); - endpoint.reset_segment(); + run_stt_receive_loop( + audio_rx, + &shutdown, + human_floor.clone(), + |input, local_barge_in_state| match input { + SttLoopInput::Tick => { + // The worklet stops sending frames when both local transmit + // paths close, so flush on that edge instead of waiting for + // silence that will never arrive. + if let Some(ref ptt) = ptt_active { + let transmit_now = ptt.load(Ordering::Acquire) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); + if transmit_was_active + && !transmit_now + && local_stream.endpoint.in_speech + && !local_stream.endpoint.speech_buf.is_empty() + { + flush_to_stt( + &local_stream.endpoint.speech_buf, + local_stream.endpoint.voiced_frames, + &recognizer, + &text_tx, + ); + local_stream.endpoint.reset_segment(); + local_stream.speculative.take(); + local_barge_in_state.release(&human_floor); + } + transmit_was_active = transmit_now; + } } - transmit_was_active = transmit_now; - } - - // Use recv_timeout so we can periodically check the shutdown flag. - let bytes = match audio_rx.recv_timeout(RECV_TIMEOUT) { - Ok(b) => b, - Err(mpsc::RecvTimeoutError::Timeout) => continue, - Err(mpsc::RecvTimeoutError::Disconnected) => break, // Sender dropped. - }; - - // Drain any additional pending messages to batch-process. - let mut batch = vec![bytes]; - while let Ok(b) = audio_rx.try_recv() { - batch.push(b); - } - - for bytes in batch { - // Convert raw bytes to f32 samples (little-endian). - let samples_48k = bytes_to_f32(&bytes); - input_buf_48k.extend_from_slice(&samples_48k); - - // Resample in chunk_in-sized blocks. - while input_buf_48k.len() >= chunk_in { - let chunk: Vec = input_buf_48k.drain(..chunk_in).collect(); - let resampled = resample_chunk(&mut resampler, &chunk); - process_16k_samples( - &resampled, - &mut leftover_16k, - &mut vad, - &mut endpoint, - flush_frames, - (speculative_enabled, &mut speculative), - &recognizer, - &text_tx, - ptt_active.as_ref(), - manual_mic_unmuted.as_ref(), - ); + SttLoopInput::Batch(batch) => { + for input in batch { + let (stream, ptt_gate, manual_gate, track_local_floor) = match input.origin { + SttAudioOrigin::Local => ( + &mut local_stream, + ptt_active.as_ref(), + manual_mic_unmuted.as_ref(), + true, + ), + SttAudioOrigin::RemoteHuman => (&mut remote_stream, None, None, false), + }; + process_stt_input( + stream, + &input.pcm_bytes, + speculative_enabled, + &recognizer, + &text_tx, + ptt_gate, + manual_gate, + &human_floor, + local_barge_in_state, + output_device.as_deref(), + track_local_floor, + ); + } } - } - } + }, + ); // No final flush — leave_huddle/end_huddle emit lifecycle events before // the STT worker exits, so a final flush would post a kind:9 message AFTER // the user has "left." Losing the last partial utterance is acceptable. } +#[allow(clippy::too_many_arguments)] +fn process_stt_input( + stream: &mut SttStreamState, + pcm_bytes: &[u8], + speculative_enabled: bool, + recognizer: &sherpa_onnx::OfflineRecognizer, + text_tx: &tokio_mpsc::Sender, + ptt_active: Option<&Arc>, + manual_mic_unmuted: Option<&Arc>, + human_floor: &HumanFloor, + local_barge_in_state: &mut local_barge_in::LocalBargeIn, + output_device: Option<&str>, + track_local_floor: bool, +) { + stream + .input_buf_48k + .extend_from_slice(&bytes_to_f32(pcm_bytes)); + + while stream.input_buf_48k.len() >= stream.chunk_in { + let chunk: Vec = stream.input_buf_48k.drain(..stream.chunk_in).collect(); + let resampled = resample_chunk(&mut stream.resampler, &chunk); + process_16k_samples( + &resampled, + &mut stream.leftover_16k, + &mut stream.vad, + &mut stream.endpoint, + SILENCE_FLUSH_FRAMES, + (speculative_enabled, &mut stream.speculative), + recognizer, + text_tx, + ptt_active, + manual_mic_unmuted, + human_floor, + local_barge_in_state, + output_device, + track_local_floor, + ); + } +} + /// Resample a mono 48 kHz chunk to 16 kHz using rubato. /// Returns the resampled samples (may be empty on error). fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec { @@ -546,6 +668,10 @@ fn process_16k_samples( text_tx: &tokio_mpsc::Sender, ptt_active: Option<&Arc>, manual_mic_unmuted: Option<&Arc>, + human_floor: &HumanFloor, + local_barge_in_state: &mut local_barge_in::LocalBargeIn, + output_device: Option<&str>, + track_local_floor: bool, ) { let (speculative_enabled, speculative) = speculative; leftover.extend_from_slice(samples); @@ -562,7 +688,30 @@ fn process_16k_samples( // VAD mode, or with a manually open mic once the shortcut is up. let flush_allowed = vad_flush_allowed(ptt_active.is_some(), manually_open, ptt_held); - match endpoint.process_frame(frame, prob, accepts_audio, flush_allowed, flush_frames) { + let action = + endpoint.process_frame(frame, prob, accepts_audio, flush_allowed, flush_frames); + // Open-mic VAD semantics also apply when a PTT-mode user manually + // opens the mic. A held shortcut keeps its explicit key-down cancel. + let local_barge_in = track_local_floor + && local_barge_in::enabled(ptt_active.is_some(), manually_open, ptt_held); + if track_local_floor { + if local_barge_in { + local_barge_in_state.observe( + prob, + action == VadFrameAction::ConfirmedOnset, + human_floor, + output_device, + VAD_ONSET_THRESHOLD, + ); + } else { + local_barge_in_state.release(human_floor); + } + } + + match action { + VadFrameAction::ConfirmedOnset => { + speculative.take(); + } VadFrameAction::Speech => { // New voiced audio invalidates any speculative decode. speculative.take(); @@ -594,6 +743,9 @@ fn process_16k_samples( ), } endpoint.reset_segment(); + if local_barge_in { + local_barge_in_state.release(human_floor); + } } VadFrameAction::None => {} } @@ -607,6 +759,9 @@ fn process_16k_samples( text_tx, ); endpoint.reset_segment(); + if local_barge_in { + local_barge_in_state.release(human_floor); + } speculative.take(); } } @@ -686,200 +841,5 @@ fn bytes_to_f32(bytes: &[u8]) -> Vec { use super::drain_until_shutdown; #[cfg(test)] -mod tests { - use super::{ - has_enough_voiced_audio, vad_flush_allowed, VadEndpoint, VadFrameAction, MIN_VOICED_FRAMES, - SILENCE_FLUSH_FRAMES, VAD_FRAME_SAMPLES, VAD_ONSET_FRAMES, VAD_PRE_ROLL_FRAMES, - }; - - fn frame(value: f32) -> Vec { - vec![value; VAD_FRAME_SAMPLES] - } - - #[test] - fn short_vad_blips_do_not_reach_the_recognizer() { - assert!(!has_enough_voiced_audio(1)); - assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); - assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); - } - - #[test] - fn confirmed_onset_prepends_pre_roll_once() { - let mut endpoint = VadEndpoint::new(); - for value in 0..VAD_PRE_ROLL_FRAMES - VAD_ONSET_FRAMES { - assert_eq!( - endpoint.process_frame(frame(value as f32), 0.0, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::None - ); - } - for value in 0..VAD_ONSET_FRAMES { - let action = endpoint.process_frame( - frame(100.0 + value as f32), - 0.9, - true, - true, - SILENCE_FLUSH_FRAMES, - ); - if value + 1 == VAD_ONSET_FRAMES { - assert_eq!(action, VadFrameAction::Speech); - } else { - assert_eq!(action, VadFrameAction::None); - } - } - - assert_eq!( - endpoint.speech_buf.len(), - VAD_PRE_ROLL_FRAMES * VAD_FRAME_SAMPLES - ); - assert_eq!(endpoint.speech_buf[0], 0.0); - assert_eq!(endpoint.speech_buf[VAD_FRAME_SAMPLES], 1.0); - assert_eq!(endpoint.pre_roll.len(), 0); - endpoint.process_frame(frame(200.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - assert_eq!( - endpoint.speech_buf.len(), - (VAD_PRE_ROLL_FRAMES + 1) * VAD_FRAME_SAMPLES - ); - } - - #[test] - fn onset_requires_consecutive_high_frames() { - let mut endpoint = VadEndpoint::new(); - for probability in [0.9, 0.9, 0.2, 0.9, 0.9] { - assert_eq!( - endpoint.process_frame(frame(1.0), probability, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::None - ); - } - assert_eq!( - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::Speech - ); - } - - #[test] - fn offset_hysteresis_preserves_borderline_speech() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - assert_eq!( - endpoint.process_frame(frame(2.0), 0.4, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::Speech - ); - assert_eq!(endpoint.silence_frames, 0); - } - - #[test] - fn below_offset_threshold_starts_silence() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - assert_eq!( - endpoint.process_frame(frame(0.0), 0.3, true, true, SILENCE_FLUSH_FRAMES), - VadFrameAction::FirstSilence - ); - assert_eq!(endpoint.silence_frames, 1); - } - - #[test] - fn short_segment_reaches_the_visible_drop_path() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let mut action = VadFrameAction::None; - for _ in 0..SILENCE_FLUSH_FRAMES { - action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); - } - assert_eq!(action, VadFrameAction::Flush); - assert!(!has_enough_voiced_audio(endpoint.voiced_frames)); - assert!(!endpoint.speech_buf.is_empty()); - } - - #[test] - fn silence_flush_retains_only_hangover_audio() { - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let speech_len = endpoint.speech_buf.len(); - for index in 1..=SILENCE_FLUSH_FRAMES { - let action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); - if index == SILENCE_FLUSH_FRAMES { - assert_eq!(action, VadFrameAction::Flush); - } - } - assert_eq!( - endpoint.speech_buf.len(), - speech_len + 6 * VAD_FRAME_SAMPLES - ); - } - - #[test] - fn flush_boundary_never_double_includes_audio() { - const SEGMENT_N_MARKER: f32 = 777.0; - let mut endpoint = VadEndpoint::new(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame( - frame(SEGMENT_N_MARKER), - 0.9, - true, - true, - SILENCE_FLUSH_FRAMES, - ); - } - for _ in 0..SILENCE_FLUSH_FRAMES { - endpoint.process_frame( - frame(SEGMENT_N_MARKER), - 0.0, - true, - true, - SILENCE_FLUSH_FRAMES, - ); - } - endpoint.reset_segment(); - - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let leaked = endpoint - .speech_buf - .iter() - .filter(|sample| **sample == SEGMENT_N_MARKER) - .count(); - assert_eq!(leaked, 0, "segment N audio leaked into segment N+1"); - } - - #[test] - fn reset_prevents_pre_roll_from_leaking_between_segments() { - const SEGMENT_N_MARKER: f32 = 777.0; - let mut endpoint = VadEndpoint::new(); - endpoint.pre_roll.push_back(frame(SEGMENT_N_MARKER)); - endpoint.reset_segment(); - for _ in 0..VAD_ONSET_FRAMES { - endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); - } - let leaked = endpoint - .speech_buf - .iter() - .filter(|sample| **sample == SEGMENT_N_MARKER) - .count(); - assert_eq!(leaked, 0, "segment N pre-roll leaked into segment N+1"); - } - - #[test] - fn held_push_to_talk_never_silence_flushes() { - // Pure VAD mode: silence always ends the utterance. - assert!(vad_flush_allowed(false, false, false)); - // Shortcut configured, nothing transmitting: nothing to flush anyway, - // but the pause path stays closed. - assert!(!vad_flush_allowed(true, false, false)); - // Shortcut held: "I am not done talking" — never flush on silence, - // regardless of the manual mic state. - assert!(!vad_flush_allowed(true, false, true)); - assert!(!vad_flush_allowed(true, true, true)); - // Manually open mic with the shortcut up: normal VAD behavior. - assert!(vad_flush_allowed(true, true, false)); - } -} +#[path = "stt_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/stt_tests.rs b/desktop/src-tauri/src/huddle/stt_tests.rs new file mode 100644 index 00000000000..d7425970ced --- /dev/null +++ b/desktop/src-tauri/src/huddle/stt_tests.rs @@ -0,0 +1,260 @@ +use std::sync::{atomic::AtomicBool, mpsc, Arc, Barrier}; + +use super::{ + has_enough_voiced_audio, run_stt_receive_loop, vad_flush_allowed, HumanFloor, SttAudioInput, + SttAudioOrigin, SttLoopInput, VadEndpoint, VadFrameAction, MIN_VOICED_FRAMES, + SILENCE_FLUSH_FRAMES, VAD_FRAME_SAMPLES, VAD_ONSET_FRAMES, VAD_PRE_ROLL_FRAMES, +}; + +#[derive(Clone, Copy)] +enum WorkerExit { + Shutdown, + SenderDisconnect, +} + +fn assert_worker_exit_releases_floor(exit: WorkerExit) { + let human_floor = HumanFloor::new(); + let shutdown = Arc::new(AtomicBool::new(false)); + let (audio_tx, audio_rx) = mpsc::channel(); + let acquired = Arc::new(Barrier::new(2)); + let worker_floor = human_floor.clone(); + let worker_shutdown = Arc::clone(&shutdown); + let worker_acquired = Arc::clone(&acquired); + let worker = std::thread::spawn(move || { + run_stt_receive_loop( + audio_rx, + &worker_shutdown, + worker_floor.clone(), + |input, local_barge_in_state| { + if matches!(input, SttLoopInput::Batch(_)) && !worker_floor.is_blocked() { + local_barge_in_state.acquire(&worker_floor, true, false); + worker_acquired.wait(); + } + }, + ); + }); + + audio_tx + .send(SttAudioInput { + pcm_bytes: Vec::new(), + origin: SttAudioOrigin::Local, + }) + .expect("worker receiver is open"); + acquired.wait(); + assert!(human_floor.is_blocked()); + match exit { + WorkerExit::Shutdown => { + shutdown.store(true, std::sync::atomic::Ordering::Release); + } + WorkerExit::SenderDisconnect => drop(audio_tx), + } + worker.join().expect("worker exits cleanly"); + + let replacement_epoch = human_floor.epoch(); + assert!( + human_floor.permits(replacement_epoch), + "fresh TTS authorization must proceed after worker exit" + ); + assert!(human_floor.enter_local(true, false)); +} + +#[test] +fn worker_shutdown_releases_local_floor_for_replacement() { + assert_worker_exit_releases_floor(WorkerExit::Shutdown); +} + +#[test] +fn worker_channel_disconnect_releases_local_floor_for_replacement() { + assert_worker_exit_releases_floor(WorkerExit::SenderDisconnect); +} + +fn frame(value: f32) -> Vec { + vec![value; VAD_FRAME_SAMPLES] +} + +#[test] +fn short_vad_blips_do_not_reach_the_recognizer() { + assert!(!has_enough_voiced_audio(1)); + assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); + assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); +} + +#[test] +fn confirmed_onset_prepends_pre_roll_once() { + let mut endpoint = VadEndpoint::new(); + for value in 0..VAD_PRE_ROLL_FRAMES - VAD_ONSET_FRAMES { + assert_eq!( + endpoint.process_frame(frame(value as f32), 0.0, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::None + ); + } + for value in 0..VAD_ONSET_FRAMES { + let action = endpoint.process_frame( + frame(100.0 + value as f32), + 0.9, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + if value + 1 == VAD_ONSET_FRAMES { + assert_eq!(action, VadFrameAction::ConfirmedOnset); + } else { + assert_eq!(action, VadFrameAction::None); + } + } + + assert_eq!( + endpoint.speech_buf.len(), + VAD_PRE_ROLL_FRAMES * VAD_FRAME_SAMPLES + ); + assert_eq!(endpoint.speech_buf[0], 0.0); + assert_eq!(endpoint.speech_buf[VAD_FRAME_SAMPLES], 1.0); + assert_eq!(endpoint.pre_roll.len(), 0); + endpoint.process_frame(frame(200.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + assert_eq!( + endpoint.speech_buf.len(), + (VAD_PRE_ROLL_FRAMES + 1) * VAD_FRAME_SAMPLES + ); +} + +#[test] +fn onset_requires_consecutive_high_frames() { + let mut endpoint = VadEndpoint::new(); + for probability in [0.9, 0.9, 0.2, 0.9, 0.9] { + assert_eq!( + endpoint.process_frame(frame(1.0), probability, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::None + ); + } + assert_eq!( + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::ConfirmedOnset + ); +} + +#[test] +fn offset_hysteresis_preserves_borderline_speech() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!( + endpoint.process_frame(frame(2.0), 0.4, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::Speech + ); + assert_eq!(endpoint.silence_frames, 0); +} + +#[test] +fn below_offset_threshold_starts_silence() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!( + endpoint.process_frame(frame(0.0), 0.3, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::FirstSilence + ); + assert_eq!(endpoint.silence_frames, 1); +} + +#[test] +fn short_segment_reaches_the_visible_drop_path() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let mut action = VadFrameAction::None; + for _ in 0..SILENCE_FLUSH_FRAMES { + action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!(action, VadFrameAction::Flush); + assert!(!has_enough_voiced_audio(endpoint.voiced_frames)); + assert!(!endpoint.speech_buf.is_empty()); +} + +#[test] +fn silence_flush_retains_only_hangover_audio() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let speech_len = endpoint.speech_buf.len(); + for index in 1..=SILENCE_FLUSH_FRAMES { + let action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); + if index == SILENCE_FLUSH_FRAMES { + assert_eq!(action, VadFrameAction::Flush); + } + } + assert_eq!( + endpoint.speech_buf.len(), + speech_len + 6 * VAD_FRAME_SAMPLES + ); +} + +#[test] +fn flush_boundary_never_double_includes_audio() { + const SEGMENT_N_MARKER: f32 = 777.0; + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame( + frame(SEGMENT_N_MARKER), + 0.9, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + } + for _ in 0..SILENCE_FLUSH_FRAMES { + endpoint.process_frame( + frame(SEGMENT_N_MARKER), + 0.0, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + } + endpoint.reset_segment(); + + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let leaked = endpoint + .speech_buf + .iter() + .filter(|sample| **sample == SEGMENT_N_MARKER) + .count(); + assert_eq!(leaked, 0, "segment N audio leaked into segment N+1"); +} + +#[test] +fn reset_prevents_pre_roll_from_leaking_between_segments() { + const SEGMENT_N_MARKER: f32 = 777.0; + let mut endpoint = VadEndpoint::new(); + endpoint.pre_roll.push_back(frame(SEGMENT_N_MARKER)); + endpoint.reset_segment(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let leaked = endpoint + .speech_buf + .iter() + .filter(|sample| **sample == SEGMENT_N_MARKER) + .count(); + assert_eq!(leaked, 0, "segment N pre-roll leaked into segment N+1"); +} + +#[test] +fn held_push_to_talk_never_silence_flushes() { + // Pure VAD mode: silence always ends the utterance. + assert!(vad_flush_allowed(false, false, false)); + // Shortcut configured, nothing transmitting: nothing to flush anyway, + // but the pause path stays closed. + assert!(!vad_flush_allowed(true, false, false)); + // Shortcut held: "I am not done talking" — never flush on silence, + // regardless of the manual mic state. + assert!(!vad_flush_allowed(true, false, true)); + assert!(!vad_flush_allowed(true, true, true)); + // Manually open mic with the shortcut up: normal VAD behavior. + assert!(vad_flush_allowed(true, true, false)); +} diff --git a/desktop/src-tauri/src/huddle/transcription.rs b/desktop/src-tauri/src/huddle/transcription.rs index 5962f57cf43..4825d4b8fbd 100644 --- a/desktop/src-tauri/src/huddle/transcription.rs +++ b/desktop/src-tauri/src/huddle/transcription.rs @@ -51,7 +51,7 @@ pub async fn set_huddle_transcription_enabled( (ephemeral_channel_id, None) } else { hs.invalidate_transcription_pipeline(); - (ephemeral_channel_id, hs.stt_pipeline.take()) + (ephemeral_channel_id, hs.take_stt_pipeline()) } }; diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 5c6bbc4c777..3f12f883ba7 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -46,6 +46,7 @@ use std::{ time::{Duration, Instant}, }; +use super::human_floor::HumanFloor; use super::pocket::{ load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, }; @@ -53,10 +54,11 @@ use super::preprocessing::preprocess_for_tts; #[path = "tts_voice_transition.rs"] mod voice_transition; +use super::tts_playback::*; +#[path = "tts_append.rs"] +mod append; +use append::*; use voice_transition::*; -#[path = "tts_playback.rs"] -mod playback; -use playback::*; #[path = "tts_startup.rs"] mod startup; use startup::await_worker_startup; @@ -74,6 +76,12 @@ use speaker_cancellation::*; #[path = "tts_streaming.rs"] mod streaming; use streaming::*; +#[path = "tts_broadcast.rs"] +mod broadcast; +use broadcast::TtsBroadcasters; +pub(crate) use broadcast::{ + LocalTtsPublisherLease, LocalTtsPublishers, TtsAudioPublisher, TtsBroadcastPacket, +}; // ── Constants ───────────────────────────────────────────────────────────────── @@ -133,6 +141,7 @@ type WorkerControlState = ( ActiveSpeaker, SpeakerCancellation, PlaybackProbe, + TtsBroadcasters, ); // ── Public pipeline handle ──────────────────────────────────────────────────── @@ -153,6 +162,7 @@ pub struct TtsPipeline { /// Kept alive here so the Arc isn't dropped — the worker holds a clone. #[allow(dead_code)] cancel: Arc, + human_floor: HumanFloor, /// Internal cancellation used only for voice changes. Kept separate so a /// concurrent human barge-in always clears every queued message. voice_cancel: Arc, @@ -172,6 +182,9 @@ pub struct TtsPipeline { playback_probe: PlaybackProbe, /// Completed after the worker drains pre-change text and installs the new style. voice_change_ack: VoiceChangeAck, + /// Agent-authenticated Huddle publishers used to carry synthesized speech + /// to remote clients without impersonating the hosting human. + broadcasters: TtsBroadcasters, /// Worker thread handle — taken on drop to join cleanly. thread: Option>, } @@ -185,6 +198,7 @@ impl TtsPipeline { model_dir: PathBuf, tts_active: Arc, cancel: Arc, + human_floor: HumanFloor, voice: &str, output_device: Option, activity_app: Option, @@ -196,6 +210,7 @@ impl TtsPipeline { let shutdown_worker = Arc::clone(&shutdown); let cancel_worker = Arc::clone(&cancel); + let worker_human_floor = human_floor.clone(); let voice_cancel = Arc::new(AtomicBool::new(false)); let worker_voice_cancel = Arc::clone(&voice_cancel); let tts_active_worker = Arc::clone(&tts_active); @@ -213,6 +228,8 @@ impl TtsPipeline { let worker_playback_probe = playback_probe.clone(); let voice_change_ack = Arc::new(Mutex::new(None)); let worker_voice_change_ack = Arc::clone(&voice_change_ack); + let broadcasters = TtsBroadcasters::default(); + let worker_broadcasters = broadcasters.clone(); let model_dir_worker = model_dir.clone(); let (startup_tx, startup_rx) = mpsc::sync_channel(1); @@ -227,6 +244,7 @@ impl TtsPipeline { worker_voice_change_ack, ), text_rx, + worker_human_floor, ( tts_active_worker, shutdown_worker, @@ -235,6 +253,7 @@ impl TtsPipeline { worker_active_speaker, worker_speaker_cancel, worker_playback_probe, + worker_broadcasters, ), output_device, activity_app, @@ -249,6 +268,7 @@ impl TtsPipeline { tts_active, shutdown, cancel, + human_floor, voice_cancel, voice, voice_generation, @@ -257,6 +277,7 @@ impl TtsPipeline { speaker_cancel, playback_probe, voice_change_ack, + broadcasters, thread: Some(handle), }) } @@ -265,6 +286,7 @@ impl TtsPipeline { impl Drop for TtsPipeline { fn drop(&mut self) { self.shutdown.store(true, Ordering::Release); + self.broadcasters.shutdown(); // Dropping `text_tx` unblocks the worker's recv_timeout loop. // Join to ensure the audio thread exits cleanly. if let Some(thread) = self.thread.take() { @@ -275,10 +297,33 @@ impl Drop for TtsPipeline { // ── Worker thread ───────────────────────────────────────────────────────────── +fn authorize_or_defer_queued_text( + human_floor: &HumanFloor, + deferred_text: &mut VecDeque, + queued_text: QueuedText, +) -> Result { + match human_floor.authorization(queued_text.floor_epoch) { + HumanFloorAuthorization::Blocked => { + deferred_text.push_front(queued_text); + Err(HumanFloorAuthorization::Blocked) + } + HumanFloorAuthorization::Stale => { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=barge_in route_id={}", + queued_text.route_id + ); + Err(HumanFloorAuthorization::Stale) + } + HumanFloorAuthorization::Permitted => Ok(queued_text), + } +} + +#[allow(clippy::too_many_arguments)] fn tts_worker( model_dir: PathBuf, voice_state: WorkerVoiceState, text_rx: mpsc::Receiver, + human_floor: HumanFloor, control_state: WorkerControlState, output_device: Option, activity_app: Option, @@ -293,6 +338,7 @@ fn tts_worker( active_speaker, speaker_cancel, playback_probe, + broadcasters, ) = control_state; let (cancel, voice_cancel) = cancel_signals; // ── 1. Initialise TTS engine ────────────────────────────────────────────── @@ -377,10 +423,11 @@ fn tts_worker( } }; - // One coordinator owns the current Player and every operation on it. - // Cancellation replaces its queue while the output stream and mixer stay - // alive, preserving cross-item pipelining without exposing Player handles. - let playback = Arc::new(PlaybackCoordinator::new(sink_handle.mixer())); + // One coordinator owns the current Player, floor state, and every operation. + // It was allocated with the huddle state so onset and playback share the + // same serialization boundary even before the TTS worker starts. + let playback = human_floor.playback(); + playback.bind_mixer(sink_handle.mixer()); playback_probe.install(Arc::clone(&playback)); // Prime the audio output stream with a short silent buffer. @@ -421,6 +468,7 @@ fn tts_worker( activity_frames: Arc::clone(&activity_frames), active_speaker: Arc::clone(&active_speaker), speaker_cancel: Arc::clone(&speaker_cancel), + broadcasters: broadcasters.clone(), activity_app, }); if let Err(ref e) = monitor { @@ -442,76 +490,40 @@ fn tts_worker( let tts_streaming = streaming_emit_frames(); let mut last_route_id = 0; let mut deferred_text = VecDeque::new(); + let append_context = TtsAppendContext { + playback: &playback, + #[cfg(test)] + human_floor: &human_floor, + cancel: &cancel, + voice_cancel: &voice_cancel, + shutdown: &shutdown, + tts_active: &tts_active, + speaker_generations: &speaker_generations, + active_speaker: &active_speaker, + activity_frames: &activity_frames, + broadcasters: &broadcasters, + channels, + rate, + }; let append_audio = |prepared: PreparedModelAudio, route_id: u64, speaker_pubkey: Option<&str>, - speaker_generation: u64| { - let sample_count = prepared.sample_count; - let chunk_index = prepared.chunk_index; - let activity = speaker_pubkey.map(|pubkey| { - build_tts_speaker_activity_frames(&prepared.buffer, pubkey, SAMPLE_RATE as usize) - }); - let accepted = playback.append_if( - SamplesBuffer::new(channels, rate, prepared.buffer), - |player_empty| { - if cancel.load(Ordering::Acquire) - || voice_cancel.load(Ordering::Acquire) - || shutdown.load(Ordering::Acquire) - { - let reason = if shutdown.load(Ordering::Acquire) { - "shutdown" - } else if cancel.load(Ordering::Acquire) { - "barge_in" - } else { - "voice_switch" - }; - eprintln!( - "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" - ); - return false; + speaker_generation: u64, + floor_epoch: u64| { + let broadcast_samples = speaker_pubkey.map(|_| prepared.buffer.clone()); + append_worker_audio( + &append_context, + prepared, + route_id, + speaker_pubkey, + speaker_generation, + floor_epoch, + || { + if let (Some(pubkey), Some(samples)) = (speaker_pubkey, broadcast_samples) { + broadcasters.publish(pubkey, speaker_generation, samples); } - if speaker_pubkey.is_some_and(|pubkey| { - current_speaker_generation(&speaker_generations, pubkey) != speaker_generation - }) { - eprintln!( - "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" - ); - return false; - } - if let Some(pubkey) = speaker_pubkey { - let mut active = active_speaker - .lock() - .unwrap_or_else(|error| error.into_inner()); - if player_empty { - active.take(); - } - if active - .as_deref() - .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) - { - return false; - } - active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); - activity_frames - .lock() - .unwrap_or_else(|error| error.into_inner()) - .extend(activity.unwrap_or_default()); - } - true }, - // Publish activity under the coordinator: an append and the mic - // gate it implies are one transition, so a cancellation that - // replaces this player cannot have its release overwritten by a - // `true` landing after the fact. - || tts_active.store(true, Ordering::Release), - ); - if !accepted { - return false; - } - eprintln!( - "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={chunk_index} sample_count={sample_count}" - ); - true + ) }; loop { @@ -526,6 +538,12 @@ fn tts_worker( ) { continue; } + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + broadcasters.cancel_all(); + } if handle_cancel_or_shutdown( (&cancel, &voice_cancel), &shutdown, @@ -585,6 +603,12 @@ fn tts_worker( // Check cancel again after unblocking — a cancel may have arrived // while we were waiting. let pending_route_id = queued_text.as_ref().map(|queued| queued.route_id); + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + broadcasters.cancel_all(); + } if handle_cancel_or_shutdown( (&cancel, &voice_cancel), &shutdown, @@ -632,7 +656,19 @@ fn tts_worker( thread::sleep(RECV_TIMEOUT); continue; } - let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { + let mut queued_text = + match authorize_or_defer_queued_text(&human_floor, &mut deferred_text, queued_text) { + Ok(queued_text) => queued_text, + Err(HumanFloorAuthorization::Blocked) => { + thread::sleep(RECV_TIMEOUT); + continue; + } + Err(HumanFloorAuthorization::Stale) => continue, + Err(HumanFloorAuthorization::Permitted) => { + unreachable!("permitted text is returned") + } + }; + let requested_voice = queued_text.voice_reference.take().unwrap_or_else(|| { selected_voice .lock() .unwrap_or_else(|error| error.into_inner()) @@ -641,6 +677,7 @@ fn tts_worker( let raw_text = queued_text.text; let speaker_pubkey = queued_text.speaker_pubkey; let speaker_generation = queued_text.speaker_generation; + let floor_epoch = queued_text.floor_epoch; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); @@ -714,6 +751,12 @@ fn tts_worker( let mut model_unit_index = 0_usize; 'playback_chunks: for chunk in &chunks { let mut no_current_text = None; + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + broadcasters.cancel_all(); + } if handle_cancel_or_shutdown( (&cancel, &voice_cancel), &shutdown, @@ -751,6 +794,7 @@ fn tts_worker( route_id, speaker_pubkey.as_deref(), speaker_generation, + floor_epoch, ) { return false; } @@ -787,6 +831,12 @@ fn tts_worker( let chunk_index = model_unit_index; model_unit_index += 1; let mut no_current_text = None; + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + broadcasters.cancel_all(); + } if handle_cancel_or_shutdown( (&cancel, &voice_cancel), &shutdown, @@ -832,6 +882,7 @@ fn tts_worker( route_id, speaker_pubkey.as_deref(), speaker_generation, + floor_epoch, ) { synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -860,6 +911,7 @@ fn tts_worker( route_id, speaker_pubkey.as_deref(), speaker_generation, + floor_epoch, ) { synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_append.rs b/desktop/src-tauri/src/huddle/tts_append.rs new file mode 100644 index 00000000000..274c806c95e --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_append.rs @@ -0,0 +1,107 @@ +//! Commits synthesized audio to local playback and remote broadcast atomically. + +use super::*; + +pub(super) struct TtsAppendContext<'a> { + pub(super) playback: &'a PlaybackCoordinator, + #[cfg(test)] + pub(super) human_floor: &'a HumanFloor, + pub(super) cancel: &'a AtomicBool, + pub(super) voice_cancel: &'a AtomicBool, + pub(super) shutdown: &'a AtomicBool, + pub(super) tts_active: &'a AtomicBool, + pub(super) speaker_generations: &'a SpeakerGenerations, + pub(super) active_speaker: &'a ActiveSpeaker, + pub(super) activity_frames: &'a Mutex>, + pub(super) broadcasters: &'a TtsBroadcasters, + pub(super) channels: NonZero, + pub(super) rate: NonZero, +} + +pub(super) fn append_worker_audio( + context: &TtsAppendContext<'_>, + prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>, + speaker_generation: u64, + floor_epoch: u64, + publish_broadcast: impl FnOnce(), +) -> bool { + // Keep the shared floor in this context so the regression can mutation-check + // that authorization never moves back inside the coordinator callback. + #[cfg(test)] + let _ = context.human_floor; + let sample_count = prepared.sample_count; + let chunk_index = prepared.chunk_index; + let activity = speaker_pubkey.map(|pubkey| { + build_tts_speaker_activity_frames(&prepared.buffer, pubkey, SAMPLE_RATE as usize) + }); + let floor_authorization = context.playback.append_if_human_floor_permits( + rodio::buffer::SamplesBuffer::new(context.channels, context.rate, prepared.buffer), + floor_epoch, + |player_empty| { + if context.cancel.load(Ordering::Acquire) + || context.voice_cancel.load(Ordering::Acquire) + || context.shutdown.load(Ordering::Acquire) + { + context.broadcasters.cancel_all(); + let reason = if context.shutdown.load(Ordering::Acquire) { + "shutdown" + } else if context.cancel.load(Ordering::Acquire) { + "barge_in" + } else { + "voice_switch" + }; + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" + ); + return false; + } + if speaker_pubkey.is_some_and(|pubkey| { + current_speaker_generation(context.speaker_generations, pubkey) + != speaker_generation + }) { + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" + ); + return false; + } + if let Some(pubkey) = speaker_pubkey { + let mut active = context + .active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if player_empty { + active.take(); + } + if active + .as_deref() + .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) + { + return false; + } + active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); + context + .activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .extend(activity.unwrap_or_default()); + } + true + }, + // Commit local and published activity under the same playback lock. + // A concurrent floor onset/cancel therefore cannot invalidate the + // player and then let this remote packet escape afterward. + || { + publish_broadcast(); + context.tts_active.store(true, Ordering::Release); + }, + ); + if floor_authorization != HumanFloorAuthorization::Permitted { + return false; + } + eprintln!( + "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={chunk_index} sample_count={sample_count}" + ); + true +} diff --git a/desktop/src-tauri/src/huddle/tts_broadcast.rs b/desktop/src-tauri/src/huddle/tts_broadcast.rs new file mode 100644 index 00000000000..ea8507d4b65 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_broadcast.rs @@ -0,0 +1,270 @@ +//! Huddle-audio publishing handles for locally synthesized agent speech. +//! +//! The relay socket itself lives in `relay_api`; this module owns the small, +//! synchronous seam the TTS worker needs. Each publisher is authenticated as +//! the agent whose speech it carries, so the existing peer-index roster keeps +//! remote playback attributed to the agent instead of the hosting human. + +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, +}; + +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +/// One prepared Pocket-TTS buffer, still at the model's native 24 kHz rate. +#[derive(Debug)] +pub(crate) struct TtsBroadcastPacket { + pub(crate) epoch: u64, + pub(crate) speaker_generation: u64, + pub(crate) samples_24k: Vec, +} + +/// `peer_index -> active local publisher count` for sockets publishing Pocket +/// TTS synthesized by this desktop. +pub(crate) type LocalTtsPublishers = Arc>>; + +/// A live registration for one locally synthesized publisher socket. The lease +/// is owned by the socket task, so receive-side suppression ends immediately +/// when that socket exits even if its command handle has not been replaced yet. +pub(crate) struct LocalTtsPublisherLease { + peer_index: u8, + local_publishers: LocalTtsPublishers, +} + +impl LocalTtsPublisherLease { + pub(crate) fn new(peer_index: u8, local_publishers: LocalTtsPublishers) -> Self { + *local_publishers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .entry(peer_index) + .or_default() += 1; + Self { + peer_index, + local_publishers, + } + } +} + +impl Drop for LocalTtsPublisherLease { + fn drop(&mut self) { + let mut local_publishers = self + .local_publishers + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(count) = local_publishers.get_mut(&self.peer_index) else { + return; + }; + *count -= 1; + if *count == 0 { + local_publishers.remove(&self.peer_index); + } + } +} + +/// A live, agent-authenticated audio publisher. +#[derive(Debug)] +pub(crate) struct TtsAudioPublisher { + tx: mpsc::Sender, + cancel: CancellationToken, + epoch: Arc, + speaker_generation: Arc, +} + +impl TtsAudioPublisher { + pub(crate) fn new(tx: mpsc::Sender, cancel: CancellationToken) -> Self { + Self { + tx, + cancel, + epoch: Arc::new(AtomicU64::new(0)), + speaker_generation: Arc::new(AtomicU64::new(0)), + } + } + + pub(crate) fn version_state(&self) -> (Arc, Arc) { + ( + Arc::clone(&self.epoch), + Arc::clone(&self.speaker_generation), + ) + } + + fn set_speaker_generation(&self, generation: u64) { + self.speaker_generation.store(generation, Ordering::Release); + } + + fn is_closed(&self) -> bool { + self.cancel.is_cancelled() || self.tx.is_closed() + } + + fn publish(&self, speaker_generation: u64, samples_24k: Vec) { + if speaker_generation != self.speaker_generation.load(Ordering::Acquire) { + return; + } + let packet = TtsBroadcastPacket { + epoch: self.epoch.load(Ordering::Acquire), + speaker_generation, + samples_24k, + }; + if let Err(error) = self.tx.try_send(packet) { + eprintln!( + "buzz-desktop: tts broadcast status=dropped reason=publisher_backpressure error={error}" + ); + } + } + + fn cancel_pending(&self) { + self.epoch.fetch_add(1, Ordering::AcqRel); + } + + fn shutdown(&self) { + self.cancel.cancel(); + } +} + +/// Thread-safe registry shared by the TTS worker, cancellation monitor, and +/// async command path that establishes publishers before speech is queued. +#[derive(Clone, Debug, Default)] +pub(super) struct TtsBroadcasters { + publishers: Arc>>, +} + +impl TtsBroadcasters { + pub(super) fn contains(&self, speaker_pubkey: &str) -> bool { + self.publishers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&speaker_pubkey.to_ascii_lowercase()) + .is_some_and(|publisher| !publisher.is_closed()) + } + + pub(super) fn register( + &self, + speaker_pubkey: &str, + publisher: TtsAudioPublisher, + speaker_generation: u64, + ) { + publisher.set_speaker_generation(speaker_generation); + let replaced = self + .publishers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(speaker_pubkey.to_ascii_lowercase(), publisher); + if let Some(replaced) = replaced { + replaced.shutdown(); + } + } + + pub(super) fn publish( + &self, + speaker_pubkey: &str, + speaker_generation: u64, + samples_24k: Vec, + ) { + let publishers = self + .publishers + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(publisher) = publishers.get(&speaker_pubkey.to_ascii_lowercase()) { + publisher.publish(speaker_generation, samples_24k); + } + } + + pub(super) fn cancel_speaker(&self, speaker_pubkey: &str, speaker_generation: u64) { + let publishers = self + .publishers + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(publisher) = publishers.get(&speaker_pubkey.to_ascii_lowercase()) { + publisher.set_speaker_generation(speaker_generation); + publisher.cancel_pending(); + } + } + + pub(super) fn remove_speaker(&self, speaker_pubkey: &str) { + let removed = self + .publishers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&speaker_pubkey.to_ascii_lowercase()); + if let Some(removed) = removed { + removed.shutdown(); + } + } + + pub(super) fn cancel_all(&self) { + for publisher in self + .publishers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .values() + { + publisher.cancel_pending(); + } + } + + pub(super) fn shutdown(&self) { + let mut publishers = self + .publishers + .lock() + .unwrap_or_else(|error| error.into_inner()); + for publisher in publishers.values() { + publisher.shutdown(); + } + publishers.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publisher_lifetime_tracks_local_synthesis_without_replacement_gaps() { + let local_publishers = LocalTtsPublishers::default(); + let first = LocalTtsPublisherLease::new(3, Arc::clone(&local_publishers)); + assert_eq!( + local_publishers.lock().expect("local publishers").get(&3), + Some(&1) + ); + + let replacement = LocalTtsPublisherLease::new(3, Arc::clone(&local_publishers)); + drop(first); + assert_eq!( + local_publishers.lock().expect("local publishers").get(&3), + Some(&1), + "dropping a replaced socket must not expose its live replacement" + ); + + drop(replacement); + assert!(local_publishers + .lock() + .expect("local publishers") + .is_empty()); + } + + #[test] + fn cancellation_invalidates_queued_packet_versions() { + let (tx, mut rx) = mpsc::channel(2); + let publisher = TtsAudioPublisher::new(tx, CancellationToken::new()); + let (epoch, generation) = publisher.version_state(); + publisher.set_speaker_generation(4); + + publisher.publish(4, vec![0.25]); + let queued = rx.try_recv().expect("queued audio"); + assert_eq!(queued.epoch, 0); + assert_eq!(queued.speaker_generation, 4); + + publisher.cancel_pending(); + assert_ne!(queued.epoch, epoch.load(Ordering::Acquire)); + + publisher.set_speaker_generation(5); + publisher.publish(4, vec![0.5]); + assert!( + rx.try_recv().is_err(), + "stale speaker audio must be dropped" + ); + assert_eq!(generation.load(Ordering::Acquire), 5); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs index 0ee472f0fd1..2737de94959 100644 --- a/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs +++ b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs @@ -1,14 +1,32 @@ use super::*; impl TtsPipeline { + pub(crate) fn has_audio_publisher(&self, speaker_pubkey: &str) -> bool { + self.broadcasters.contains(speaker_pubkey) + } + + pub(crate) fn register_audio_publisher( + &self, + speaker_pubkey: &str, + publisher: TtsAudioPublisher, + ) { + self.broadcasters.register( + speaker_pubkey, + publisher, + current_speaker_generation(&self.speaker_generations, speaker_pubkey), + ); + } + /// Queue `text` for TTS synthesis and playback. /// /// Non-blocking. Returns `Err` if the queue is full (bounded at /// `TEXT_QUEUE_DEPTH`) — caller may log and discard. pub fn speak(&self, text: String) -> Result<(), String> { + let floor_epoch = self.human_floor.epoch(); self.text_tx .try_send(QueuedText { generation: self.voice_generation.load(Ordering::Acquire), + floor_epoch, route_id: 0, speaker_pubkey: None, speaker_generation: 0, @@ -28,6 +46,7 @@ impl TtsPipeline { TtsTextSender { text_tx: self.text_tx.clone(), generation: self.voice_generation.load(Ordering::Acquire), + human_floor: self.human_floor.clone(), speaker_generations: Arc::clone(&self.speaker_generations), } } @@ -41,6 +60,7 @@ impl TtsPipeline { &self.speaker_cancel, speaker_pubkey, ); + self.broadcasters.remove_speaker(speaker_pubkey); } /// Cancel exactly the speaker utterance currently owning playback. @@ -49,13 +69,20 @@ impl TtsPipeline { /// stale Stop click cannot cancel a later utterance that starts after the /// observed one drains. pub(crate) fn cancel_active_speaker(&self, expected_speaker_pubkey: &str) -> bool { - request_active_speaker_cancel( + let cancelled = request_active_speaker_cancel( &self.speaker_generations, &self.active_speaker, &self.speaker_cancel, &self.playback_probe, expected_speaker_pubkey, - ) + ); + if cancelled { + self.broadcasters.cancel_speaker( + expected_speaker_pubkey, + current_speaker_generation(&self.speaker_generations, expected_speaker_pubkey), + ); + } + cancelled } /// Select a bundled Pocket voice for subsequent speech. @@ -72,6 +99,7 @@ impl TtsPipeline { voice, ); if acknowledged.is_some() { + self.broadcasters.cancel_all(); eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0"); } acknowledged @@ -89,6 +117,7 @@ impl TtsPipeline { /// Signal the worker thread to stop. pub fn shutdown(&self) { eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0"); + self.broadcasters.shutdown(); self.shutdown.store(true, Ordering::Release); } diff --git a/desktop/src-tauri/src/huddle/tts_playback.rs b/desktop/src-tauri/src/huddle/tts_playback.rs index 93a5b16b8c8..8a90c018994 100644 --- a/desktop/src-tauri/src/huddle/tts_playback.rs +++ b/desktop/src-tauri/src/huddle/tts_playback.rs @@ -1,7 +1,17 @@ -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::{ + collections::HashSet, + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::{Duration, Instant}, +}; use rodio::{mixer::Mixer, Player, Source}; +/// Conservative guard after rodio reports drained. Max measured about 12 ms +/// of cancellation tail on current-main CoreAudio and about 1 ms after player +/// replacement; 100 ms safely bounds those observed paths while the phase-1 +/// route matrix determines whether this can be narrowed. +const OUTPUT_TAIL_HANGOVER: Duration = Duration::from_millis(100); + /// Serializes every operation on the TTS player and owns the utterance-boundary /// bookkeeping that must change atomically when playback is replaced. /// @@ -10,18 +20,62 @@ use rodio::{mixer::Mixer, Player, Source}; /// independently valid at either value, and no mutable reference to the state /// leaves the locked operation that created it. pub(super) struct PlaybackCoordinator { - mixer: Mixer, + mixer: Mutex>, state: Mutex, } struct PlaybackState { - player: Player, + player: Option, /// `true` while no append has been committed since the last utterance /// boundary. Only `append_if` clears it, so it records appends that were /// actually queued — never one the authorization refused. first_append: bool, synthesis_in_flight: bool, synthesis_generation: u64, + output_lease: OutputLease, + human_floor: HumanFloorState, +} + +#[derive(Default)] +enum OutputLease { + #[default] + Inactive, + Active, + HangoverUntil(Instant), +} + +impl OutputLease { + fn is_live_at(&mut self, now: Instant) -> bool { + match self { + Self::Inactive => false, + Self::Active => true, + Self::HangoverUntil(deadline) if now < *deadline => true, + Self::HangoverUntil(_) => { + *self = Self::Inactive; + false + } + } + } + + fn begin_hangover(&mut self, now: Instant) { + if !matches!(self, Self::Inactive) { + *self = Self::HangoverUntil(now + OUTPUT_TAIL_HANGOVER); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum HumanFloorAuthorization { + Permitted, + Blocked, + Stale, +} + +#[derive(Default)] +struct HumanFloorState { + epoch: u64, + local: bool, + remote: HashSet, } pub(super) struct SynthesisFlightGuard { @@ -39,18 +93,35 @@ impl Drop for SynthesisFlightGuard { } impl PlaybackCoordinator { + #[cfg(test)] pub(super) fn new(mixer: &Mixer) -> Self { + let coordinator = Self::unbound(); + coordinator.bind_mixer(mixer); + coordinator + } + + pub(super) fn unbound() -> Self { Self { - mixer: mixer.clone(), + mixer: Mutex::new(None), state: Mutex::new(PlaybackState { - player: Player::connect_new(mixer), + player: None, first_append: true, synthesis_in_flight: false, synthesis_generation: 0, + output_lease: OutputLease::Inactive, + human_floor: HumanFloorState::default(), }), } } + pub(super) fn bind_mixer(&self, mixer: &Mixer) { + *self.mixer.lock().unwrap_or_else(PoisonError::into_inner) = Some(mixer.clone()); + let mut state = self.lock(); + if state.player.is_none() { + state.player = Some(Player::connect_new(mixer)); + } + } + fn lock(&self) -> MutexGuard<'_, PlaybackState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } @@ -61,6 +132,7 @@ impl PlaybackCoordinator { /// concurrent cancellation either replaces the queue before this append is /// authorized, or observes the committed state after it — never lands its /// own release between the two and gets overwritten. + #[cfg(test)] pub(super) fn append_if( &self, source: S, @@ -71,11 +143,15 @@ impl PlaybackCoordinator { S: Source + Send + 'static, { let mut state = self.lock(); - if !authorize(state.player.empty()) { + if !authorize(state.player.as_ref().is_none_or(Player::empty)) { return false; } - state.player.append(source); + let Some(player) = state.player.as_ref() else { + return false; + }; + player.append(source); state.first_append = false; + state.output_lease = OutputLease::Active; commit(); true } @@ -84,28 +160,31 @@ impl PlaybackCoordinator { where S: Source + Send + 'static, { - self.lock().player.append(source); + if let Some(player) = self.lock().player.as_ref() { + player.append(source); + } } pub(super) fn empty(&self) -> bool { - self.lock().player.empty() + self.lock().player.as_ref().is_none_or(Player::empty) } /// Observe playback emptiness under the coordinator so the onset decision /// for the audio being built is serialized with append and cancellation. pub(super) fn prepare_audio(&self, prepare: impl FnOnce(bool) -> R) -> R { let state = self.lock(); - let empty = state.player.empty(); + let empty = state.player.as_ref().is_none_or(Player::empty); prepare(empty) } pub(super) fn release_if_drained(&self, release: impl FnOnce()) -> bool { let mut state = self.lock(); - if !state.player.empty() || state.first_append { + if !state.player.as_ref().is_none_or(Player::empty) || state.first_append { return false; } release(); state.first_append = true; + state.output_lease.begin_hangover(Instant::now()); true } @@ -124,7 +203,7 @@ impl PlaybackCoordinator { pub(super) fn with_playback_live(&self, observe: impl FnOnce(bool) -> R) -> R { let state = self.lock(); - observe(!state.player.empty() || state.synthesis_in_flight) + observe(!state.player.as_ref().is_none_or(Player::empty) || state.synthesis_in_flight) } /// Replace live playback with a fresh queue, publishing the replacement @@ -142,21 +221,162 @@ impl PlaybackCoordinator { authorize: impl FnOnce() -> bool, commit: impl FnOnce(), ) -> bool { + let replacement = self + .mixer + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Player::connect_new); let old_player = { let mut state = self.lock(); - if (state.player.empty() && !state.synthesis_in_flight) || !authorize() { + if (state.player.as_ref().is_none_or(Player::empty) && !state.synthesis_in_flight) + || !authorize() + { return false; } state.first_append = true; state.synthesis_in_flight = false; state.synthesis_generation = state.synthesis_generation.wrapping_add(1); - let old_player = std::mem::replace(&mut state.player, Player::connect_new(&self.mixer)); + state.output_lease.begin_hangover(Instant::now()); + let old_player = std::mem::replace(&mut state.player, replacement); commit(); old_player }; drop(old_player); true } + + #[cfg(test)] + pub(super) fn human_floor_blocked(&self) -> bool { + let state = self.lock(); + state.human_floor.local || !state.human_floor.remote.is_empty() + } + + pub(super) fn human_floor_epoch(&self) -> u64 { + self.lock().human_floor.epoch + } + + pub(super) fn human_floor_authorization(&self, epoch: u64) -> HumanFloorAuthorization { + Self::human_floor_authorization_locked(&self.lock(), epoch) + } + + fn human_floor_authorization_locked( + state: &PlaybackState, + epoch: u64, + ) -> HumanFloorAuthorization { + if state.human_floor.local || !state.human_floor.remote.is_empty() { + HumanFloorAuthorization::Blocked + } else if state.human_floor.epoch != epoch { + HumanFloorAuthorization::Stale + } else { + HumanFloorAuthorization::Permitted + } + } + + #[cfg(test)] + pub(super) fn human_floor_permits(&self, epoch: u64) -> bool { + self.human_floor_authorization(epoch) == HumanFloorAuthorization::Permitted + } + + pub(super) fn append_if_human_floor_permits( + &self, + source: S, + epoch: u64, + authorize: impl FnOnce(bool) -> bool, + commit: impl FnOnce(), + ) -> HumanFloorAuthorization + where + S: Source + Send + 'static, + { + let mut state = self.lock(); + let floor_authorization = Self::human_floor_authorization_locked(&state, epoch); + if floor_authorization != HumanFloorAuthorization::Permitted { + return floor_authorization; + } + if !authorize(state.player.as_ref().is_none_or(Player::empty)) { + return HumanFloorAuthorization::Stale; + } + let Some(player) = state.player.as_ref() else { + return HumanFloorAuthorization::Stale; + }; + player.append(source); + state.first_append = false; + state.output_lease = OutputLease::Active; + commit(); + HumanFloorAuthorization::Permitted + } + + pub(super) fn enter_local_human_floor( + &self, + route_isolated: bool, + sustained_coupled_speech: bool, + ) -> bool { + let replacement = self + .mixer + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Player::connect_new); + let old_player = { + let mut state = self.lock(); + let output_live = + state.synthesis_in_flight || state.output_lease.is_live_at(Instant::now()); + if state.human_floor.local + || (output_live && !route_isolated && !sustained_coupled_speech) + { + return false; + } + state.human_floor.local = true; + Self::commit_human_floor_onset(&mut state, replacement) + }; + drop(old_player); + true + } + + pub(super) fn leave_local_human_floor(&self) { + self.lock().human_floor.local = false; + } + + pub(super) fn enter_remote_human_floor(&self, peer: u8) { + self.enter_human_floor(|floor| floor.remote.insert(peer)); + } + + pub(super) fn leave_remote_human_floor(&self, peer: u8) { + self.lock().human_floor.remote.remove(&peer); + } + + pub(super) fn clear_remote_human_floor(&self) { + self.lock().human_floor.remote.clear(); + } + + fn enter_human_floor(&self, enter: impl FnOnce(&mut HumanFloorState) -> bool) { + let replacement = self + .mixer + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Player::connect_new); + let old_player = { + let mut state = self.lock(); + if !enter(&mut state.human_floor) { + return; + } + Self::commit_human_floor_onset(&mut state, replacement) + }; + drop(old_player); + } + + fn commit_human_floor_onset( + state: &mut PlaybackState, + replacement: Option, + ) -> Option { + state.human_floor.epoch = state.human_floor.epoch.wrapping_add(1); + state.first_append = true; + state.synthesis_in_flight = false; + state.synthesis_generation = state.synthesis_generation.wrapping_add(1); + state.output_lease.begin_hangover(Instant::now()); + std::mem::replace(&mut state.player, replacement) + } } #[cfg(test)] @@ -194,6 +414,185 @@ mod tests { ); } + fn one_second_source() -> SamplesBuffer { + SamplesBuffer::new( + NonZero::new(1).expect("nonzero channels"), + NonZero::new(24_000).expect("nonzero rate"), + vec![0.25; 24_000], + ) + } + + #[test] + fn floor_authorized_append_does_not_reenter_the_coordinator_lock() { + let (playback, _unpulled_source) = coordinator(); + let epoch = playback.human_floor_epoch(); + let (completed_tx, completed_rx) = std::sync::mpsc::sync_channel(1); + let worker = thread::spawn(move || { + let authorization = + playback.append_if_human_floor_permits(one_second_source(), epoch, |_| true, || {}); + completed_tx + .send(authorization) + .expect("completion receiver"); + }); + + assert_eq!( + completed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("floor-authorized append must not deadlock"), + HumanFloorAuthorization::Permitted + ); + worker.join().expect("append worker"); + } + + #[test] + fn text_queued_during_a_held_floor_is_permitted_after_release() { + let (playback, _unpulled_source) = coordinator(); + assert!(playback.enter_local_human_floor(true, false)); + let queued_epoch = playback.human_floor_epoch(); + + assert_eq!( + playback.human_floor_authorization(queued_epoch), + HumanFloorAuthorization::Blocked + ); + playback.leave_local_human_floor(); + assert_eq!( + playback.human_floor_authorization(queued_epoch), + HumanFloorAuthorization::Permitted + ); + assert_eq!( + playback.append_if_human_floor_permits( + one_second_source(), + queued_epoch, + |_| true, + || {}, + ), + HumanFloorAuthorization::Permitted + ); + } + + #[test] + fn human_onset_replaces_playback_and_invalidates_late_append() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let stale_epoch = playback.human_floor_epoch(); + + assert!(playback.enter_local_human_floor(true, false)); + + assert!(playback.empty()); + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(stale_epoch)); + playback.leave_local_human_floor(); + assert!(!playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(stale_epoch)); + } + + #[test] + fn coupled_local_onset_while_idle_blocks_delayed_tts() { + let (playback, _unpulled_source) = coordinator(); + let delayed_tts_epoch = playback.human_floor_epoch(); + + assert!(playback.enter_local_human_floor(false, false)); + + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(delayed_tts_epoch)); + } + + #[test] + fn coupled_local_onset_during_output_is_rejected_as_ambiguous_echo() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let epoch = playback.human_floor_epoch(); + + assert!(!playback.enter_local_human_floor(false, false)); + + assert!(!playback.human_floor_blocked()); + assert!(playback.human_floor_permits(epoch)); + assert!(!playback.empty()); + } + + #[test] + fn sustained_coupled_speech_overrides_live_output_suppression() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let stale_epoch = playback.human_floor_epoch(); + + assert!(playback.enter_local_human_floor(false, true)); + + assert!(playback.empty()); + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(stale_epoch)); + } + + #[test] + fn coupled_local_onset_during_output_tail_hangover_is_rejected() { + let (playback, mut source) = coordinator(); + append_second(&playback); + while !playback.empty() { + assert!( + source.next().is_some(), + "the mixer source outlives the queue" + ); + } + assert!(playback.release_if_drained(|| {})); + + assert!(!playback.enter_local_human_floor(false, false)); + assert!(!playback.human_floor_blocked()); + } + + #[test] + fn coupled_local_onset_after_output_tail_hangover_is_accepted() { + let (playback, mut source) = coordinator(); + append_second(&playback); + while !playback.empty() { + assert!( + source.next().is_some(), + "the mixer source outlives the queue" + ); + } + assert!(playback.release_if_drained(|| {})); + playback.lock().output_lease = + OutputLease::HangoverUntil(Instant::now() - Duration::from_millis(1)); + + assert!(playback.enter_local_human_floor(false, false)); + assert!(playback.human_floor_blocked()); + } + + #[test] + fn accepted_append_renews_an_expiring_output_lease() { + let (playback, _unpulled_source) = coordinator(); + playback.lock().output_lease = + OutputLease::HangoverUntil(Instant::now() + Duration::from_millis(1)); + + append_second(&playback); + + assert!(matches!(playback.lock().output_lease, OutputLease::Active)); + } + + #[test] + fn remote_onset_while_idle_blocks_delayed_tts() { + let (playback, _unpulled_source) = coordinator(); + let delayed_tts_epoch = playback.human_floor_epoch(); + + playback.enter_remote_human_floor(7); + + assert!(playback.human_floor_blocked()); + assert!(!playback.human_floor_permits(delayed_tts_epoch)); + } + + #[test] + fn local_and_remote_sources_hold_the_same_floor_until_each_releases() { + let (playback, _unpulled_source) = coordinator(); + assert!(playback.enter_local_human_floor(true, false)); + let local_epoch = playback.human_floor_epoch(); + playback.enter_remote_human_floor(7); + assert_ne!(playback.human_floor_epoch(), local_epoch); + + playback.leave_local_human_floor(); + assert!(playback.human_floor_blocked()); + playback.leave_remote_human_floor(7); + assert!(!playback.human_floor_blocked()); + } + #[test] fn cancel_replaces_playback_without_waiting_for_the_mixer() { let (playback, _unpulled_source) = coordinator(); diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 64fd6d8a945..75cfef26e55 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -622,6 +622,7 @@ pub async fn preview_pocket_voice( model_dir, active.clone(), cancel, + super::human_floor::HumanFloor::new(), &voice_name, output_device, None, diff --git a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs index 185ca56d08c..98bc66824c6 100644 --- a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs +++ b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs @@ -9,6 +9,7 @@ pub(super) struct TtsMonitorState { pub(super) activity_frames: Arc>>, pub(super) active_speaker: ActiveSpeaker, pub(super) speaker_cancel: SpeakerCancellation, + pub(super) broadcasters: TtsBroadcasters, pub(super) activity_app: Option, } @@ -31,6 +32,7 @@ pub(super) fn spawn_tts_monitor(state: TtsMonitorState) -> std::io::Result QueuedText { + QueuedText { + generation: 1, + floor_epoch, + route_id, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text: "queued while a human is speaking".to_string(), + } +} + +#[test] +fn production_worker_append_authorization_completes() { + let (completed_tx, completed_rx) = mpsc::sync_channel(1); + let worker = std::thread::spawn(move || { + let human_floor = HumanFloor::new(); + let playback = human_floor.playback(); + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(SAMPLE_RATE).expect("nonzero rate"); + let (mixer, _unpulled_source) = rodio::mixer::mixer(channels, rate); + playback.bind_mixer(&mixer); + let floor_epoch = human_floor.epoch(); + let cancel = AtomicBool::new(false); + let voice_cancel = AtomicBool::new(false); + let shutdown = AtomicBool::new(false); + let tts_active = AtomicBool::new(false); + let speaker_generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(None)); + let activity_frames = Mutex::new(VecDeque::new()); + let context = TtsAppendContext { + playback: &playback, + human_floor: &human_floor, + cancel: &cancel, + voice_cancel: &voice_cancel, + shutdown: &shutdown, + tts_active: &tts_active, + speaker_generations: &speaker_generations, + active_speaker: &active_speaker, + activity_frames: &activity_frames, + broadcasters: &TtsBroadcasters::default(), + channels, + rate, + }; + + let accepted = append_worker_audio( + &context, + PreparedModelAudio { + buffer: vec![0.25; SAMPLE_RATE as usize], + sample_count: SAMPLE_RATE as usize, + chunk_index: 0, + }, + 40, + None, + 0, + floor_epoch, + || {}, + ); + completed_tx + .send((accepted, tts_active.load(Ordering::Acquire))) + .expect("completion receiver"); + }); + + assert_eq!( + completed_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("production worker append authorization must not deadlock"), + (true, true) + ); + worker.join().expect("append worker"); +} + +#[test] +fn worker_queue_defers_text_while_floor_is_held_then_releases_it() { + let human_floor = HumanFloor::new(); + assert!(human_floor.enter_local(true, false)); + let floor_epoch = human_floor.epoch(); + let mut deferred = VecDeque::new(); + + assert!(matches!( + authorize_or_defer_queued_text(&human_floor, &mut deferred, queued_text(41, floor_epoch),), + Err(HumanFloorAuthorization::Blocked) + )); + assert_eq!(deferred.len(), 1, "held-floor text must stay queued"); + + human_floor.leave_local(); + let queued = deferred.pop_front().expect("deferred text"); + let released = authorize_or_defer_queued_text(&human_floor, &mut deferred, queued) + .expect("the same queue item is eligible after floor release"); + + assert_eq!(released.route_id, 41); + assert!(deferred.is_empty()); +} + +#[test] +fn worker_queue_drops_text_from_before_human_onset() { + let human_floor = HumanFloor::new(); + let stale_epoch = human_floor.epoch(); + assert!(human_floor.enter_local(true, false)); + human_floor.leave_local(); + let mut deferred = VecDeque::new(); + + assert!(matches!( + authorize_or_defer_queued_text(&human_floor, &mut deferred, queued_text(42, stale_epoch),), + Err(HumanFloorAuthorization::Stale) + )); + assert!(deferred.is_empty(), "pre-barge-in text must not replay"); +} + // ── Remote interrupt tracker ────────────────────────────────────────────── // // Models the per-peer frame counting logic in the recv task of diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index bff5ab4f76b..260239521e3 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -16,6 +16,7 @@ fn inert_pipeline(cancel: Arc) -> TtsPipeline { tts_active: Arc::new(AtomicBool::new(false)), shutdown, cancel, + human_floor: HumanFloor::new(), voice_cancel: Arc::new(AtomicBool::new(false)), voice: Arc::new(std::sync::Mutex::new("reference_sample".to_string())), voice_generation: Arc::new(AtomicU64::new(1)), @@ -24,6 +25,7 @@ fn inert_pipeline(cancel: Arc) -> TtsPipeline { speaker_cancel: Arc::new(std::sync::Mutex::new(None)), playback_probe: PlaybackProbe::new(), voice_change_ack: Arc::new(std::sync::Mutex::new(None)), + broadcasters: TtsBroadcasters::default(), thread: Some(thread), } } @@ -169,6 +171,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { )); text_tx .send(QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 1, speaker_pubkey: None, @@ -183,6 +186,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { let active = AtomicBool::new(true); let mut deferred_text = VecDeque::from([ QueuedText { + floor_epoch: 0, generation: 1, route_id: 2, speaker_pubkey: None, @@ -191,6 +195,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { text: "old message".to_string(), }, QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 3, speaker_pubkey: None, @@ -250,6 +255,7 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { ) .expect("first voice change"); deferred_text.push_back(QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 4, speaker_pubkey: None, @@ -299,6 +305,7 @@ fn barge_in_clears_deferred_voice_change_messages() { let voice_change_ack = Arc::new(std::sync::Mutex::new(None)); let (_text_tx, text_rx) = std::sync::mpsc::channel(); let mut deferred_text = VecDeque::from([QueuedText { + floor_epoch: 0, generation: 2, route_id: 5, speaker_pubkey: None, @@ -343,6 +350,7 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { ) .expect("voice change"); deferred_text.push_back(QueuedText { + floor_epoch: 0, generation: voice_generation.load(Ordering::Acquire), route_id: 6, speaker_pubkey: None, @@ -375,6 +383,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() let old_sender = TtsTextSender { text_tx, generation: voice_generation.load(Ordering::Acquire), + human_floor: HumanFloor::new(), speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), }; let shutdown = AtomicBool::new(false); diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 78c9c3cc4d1..11f3acd2e4c 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -9,7 +9,7 @@ use std::{ }, }; -use super::{PlaybackCoordinator, SynthesisFlightGuard}; +use super::{HumanFloor, PlaybackCoordinator, SynthesisFlightGuard}; use crate::huddle::pocket::{load_voice_style, VoiceStyle, DEFAULT_VOICE, VOICE_FILE_EXT}; @@ -74,6 +74,7 @@ impl fmt::Debug for PlaybackProbe { #[derive(Debug)] pub(super) struct QueuedText { pub(super) generation: u64, + pub(super) floor_epoch: u64, pub(super) route_id: u64, pub(super) speaker_pubkey: Option, pub(super) speaker_generation: u64, @@ -85,6 +86,7 @@ pub(super) struct QueuedText { pub(crate) struct TtsTextSender { pub(super) text_tx: SyncSender, pub(super) generation: u64, + pub(super) human_floor: HumanFloor, pub(super) speaker_generations: SpeakerGenerations, } @@ -97,9 +99,11 @@ impl TtsTextSender { voice_reference: String, text: String, ) -> Result<(), String> { + let floor_epoch = self.human_floor.epoch(); self.text_tx .send(QueuedText { generation: self.generation, + floor_epoch, route_id, speaker_pubkey: Some(speaker_pubkey), speaker_generation, @@ -544,6 +548,7 @@ mod speaker_generation_tests { fn queued_speech(speaker_pubkey: &str, speaker_generation: u64) -> QueuedText { QueuedText { + floor_epoch: 0, generation: 1, route_id: 1, speaker_pubkey: Some(speaker_pubkey.to_string()), diff --git a/desktop/src-tauri/src/huddle/wire.rs b/desktop/src-tauri/src/huddle/wire.rs index d315dd7f238..518377a60b0 100644 --- a/desktop/src-tauri/src/huddle/wire.rs +++ b/desktop/src-tauri/src/huddle/wire.rs @@ -7,13 +7,26 @@ //! //! No per-frame metadata; receiver synthesizes sequence/timestamp on arrival. //! Kept for backward compatibility — relay still admits v1 clients into -//! v1-pinned rooms — but new clients always speak v2. +//! v1-pinned rooms — but new clients always speak v3. //! -//! ## v2 (this commit) +//! ## v2 (released) //! //! Client → relay: `` //! Relay → client: `` //! +//! ## v3 (this commit) +//! +//! Client → relay: `` +//! Relay → client: `` +//! +//! The relay prefixes each forwarded frame with the sender's stable +//! `peer_index` and the current occupancy `epoch` of that index. The epoch +//! advances each time a slot is reused by a new occupant, so a client can +//! fence a frame authored by a departed occupant that arrives after its index +//! is reassigned — it carries the stale epoch and is dropped rather than +//! mis-attributed. The client's own send path is unaffected: it emits only +//! `
` and the relay stamps the prefix. +//! //! Header layout (8 bytes, network byte order, big-endian): //! //! ```text @@ -31,13 +44,13 @@ //! * `level_dbov` is client-authored telemetry. The relay parses it for //! logging/active-speaker hints, clamps invalid values into range, and //! **never** uses it for trust decisions (admission, moderation, etc.). -//! * Negotiation lives in the WS auth message (`protocol_version: 2`), not +//! * Negotiation lives in the WS auth message (`protocol_version: 3`), not //! in any bit of `flags`. Mixed-version rooms are rejected at the relay //! with `upgrade_required`. /// Wire protocol version this client speaks. Bumped only when the frame /// layout itself changes; the relay tracks pinned per-room. -pub const PROTOCOL_VERSION: u8 = 2; +pub const PROTOCOL_VERSION: u8 = 3; /// Length of the v2 per-frame header in bytes. pub const V2_HEADER_LEN: usize = 8; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c120ac12679..71a5eb3806e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -314,16 +314,14 @@ pub fn run() { // agent spawns can resolve custom/preset runtime ids without // waiting for the frontend's discover_acp_providers call. This is // a pure directory scan — no PATH probing, no async work. - { - let custom_dir = app_handle - .path() - .app_data_dir() - .ok() - .map(|d| d.join("custom_harnesses")); - managed_agents::custom_harnesses::warm_harness_registry_from_dir( - custom_dir.as_deref(), - ); - } + let custom_harness_dir = app_handle + .path() + .app_data_dir() + .ok() + .map(|d| d.join("custom_harnesses")); + managed_agents::custom_harnesses::warm_harness_registry_from_dir( + custom_harness_dir.as_deref(), + ); // Store the AppHandle so huddle commands can emit `huddle-state-changed` // events via `huddle::emit_huddle_state` without threading the handle @@ -353,10 +351,7 @@ pub fn run() { // Route mesh-llm's download progress (model weights, runtime) // onto Tauri events so the UI can render real progress. crate::mesh_llm::install_progress_sink(&app_handle); - let mesh_app = app_handle.clone(); - tauri::async_runtime::spawn(async move { - crate::mesh_llm::start_coordinator(mesh_app).await; - }); + tauri::async_runtime::spawn(crate::mesh_llm::start_coordinator(app_handle.clone())); } // Start the localhost media streaming proxy. Uses the shared HTTP @@ -379,6 +374,7 @@ pub fn run() { if let Err(error) = ensure_nest() { eprintln!("buzz-desktop: failed to create nest: {error}"); } + archive::spawn_warm_init(app_handle.clone()); // Resolve the REPOS symlink from the persisted repos_dir BEFORE // agents are restored below, and decide whether restore is safe. @@ -842,6 +838,9 @@ pub fn run() { archive::index_observer_channel_id, archive::read_unindexed_observer_rows, archive::get_agent_usage_series, + archive::get_observer_retention_days, + archive::set_observer_retention_days, + archive::archive_size_stats, archive::sync::announce_archive_sync_epoch, archive::sync::start_archive_sync, archive::sync::stop_archive_sync, @@ -914,7 +913,6 @@ pub fn run() { RunEvent::Exit => { shut_down_app(app_handle, &run_shutdown_done); app_handle.state::().release(); - #[cfg(all(feature = "mesh-llm", target_os = "macos"))] if restart_requested.load(Ordering::SeqCst) { relaunch_after_mesh_shutdown(app_handle); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..ba880e50b1f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -68,6 +68,8 @@ mod lifecycle; #[cfg(test)] use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; +mod log_filter; + mod spawn_key; // production spawn-key derivation + its regressions pub(crate) use spawn_key::bound_runtime_key; @@ -529,7 +531,6 @@ pub fn spawn_agent_child( if let Some(ref path) = augmented_path { command.env("PATH", path); } - command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); @@ -810,6 +811,12 @@ pub fn spawn_agent_child( command.env(key, value); } + // RUST_LOG is resolved after the loop above, for the same reason as effort + // below: that loop lets a user value win wholesale, and a saved + // `buzz_acp=debug` would drop the other harness families back into the + // silence this default exists to fix. + log_filter::apply(&mut command, &descriptor.env); + // B5: carry persisted effort; harness resolves thought_level configId at first session. // Written AFTER descriptor.env so the canonical persisted value wins over any // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern @@ -928,14 +935,6 @@ pub fn spawn_agent_child( }) } -fn child_rust_log_filter() -> String { - match std::env::var("RUST_LOG") { - Ok(existing) if existing.contains("buzz_acp") => existing, - Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), - _ => "buzz_acp=info".to_string(), - } -} - /// Spawn (or adopt) the runtime pair for `record` on the caller's bound /// workspace relay. `workspace_relay` can only be produced by /// `bind_expected_relay_scope`, so this spawn consumes — by construction — the diff --git a/desktop/src-tauri/src/managed_agents/runtime/log_filter.rs b/desktop/src-tauri/src/managed_agents/runtime/log_filter.rs new file mode 100644 index 00000000000..533eebff2a7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/log_filter.rs @@ -0,0 +1,139 @@ +//! Default `RUST_LOG` for the spawned harness, and the rule for merging a +//! configured one into it. +//! +//! `buzz-acp` emits almost everything on its own target families — `acp::*`, +//! `pool::*`, `canvas::*`, `engram::*`, `observer` — rather than under the +//! crate path, and `EnvFilter` matches directives by target prefix. A bare +//! `buzz_acp=info` therefore matches none of them, so an agent can log an +//! error that its owner never sees. +//! +//! The families are enabled at `warn`, deliberately, and not at `info`: their +//! info-level events carry conversation content (`acp::stream` logs the +//! model's reply verbatim) and this log is a plaintext file on disk that a +//! long-lived process appends to. Warn and error carry timeouts, ids and +//! failures. Content stays behind an explicit opt-in. +//! +//! Keep in sync with the crate-side fallback in `crates/buzz-acp/src/lib.rs`. +pub(super) const LOG_FILTER: &str = + "buzz_acp=info,acp=warn,pool=warn,canvas=warn,engram=warn,observer=warn"; + +/// Whether a single directive is just a level, i.e. `EnvFilter`'s global +/// default. Both spellings count: the names, and the numeric forms `0`..`5` +/// the parser also accepts. +/// +/// A target-specific directive outranks the global one, so merging our +/// families into such a filter would *narrow* what the user asked for. `0` +/// matters most: it means "log nothing", and widening that would be the +/// opposite of the request. +fn is_global_level(directive: &str) -> bool { + const NAMES: [&str; 6] = ["off", "error", "warn", "info", "debug", "trace"]; + let directive = directive.trim(); + NAMES + .iter() + .any(|name| directive.eq_ignore_ascii_case(name)) + || matches!(directive, "0" | "1" | "2" | "3" | "4" | "5") +} + +/// Merge a configured `RUST_LOG` with the harness defaults. +/// +/// `configured` is the value that survives the desktop's own env layering, so +/// this must be called after that layering rather than before it. +pub(super) fn merge(configured: Option<&str>) -> String { + let Some(configured) = configured.map(str::trim).filter(|value| !value.is_empty()) else { + return LOG_FILTER.to_string(); + }; + if configured.split(',').any(is_global_level) { + return configured.to_string(); + } + // Defaults first: a later directive for the same target overwrites an + // earlier one, so an explicit `acp=debug` still wins over ours while the + // families the user did not name keep their diagnostics. + format!("{LOG_FILTER},{configured}") +} + +/// Write the harness `RUST_LOG` onto `command`. +/// +/// Takes the already-layered agent env so the value the user actually saved is +/// the one merged; calling this before that layering would let the layering +/// overwrite the result. +pub(super) fn apply( + command: &mut std::process::Command, + env: &std::collections::BTreeMap, +) { + let ambient = std::env::var("RUST_LOG").ok(); + let configured = env + .get("RUST_LOG") + .map(String::as_str) + .or(ambient.as_deref()); + command.env("RUST_LOG", merge(configured)); +} + +#[cfg(test)] +mod tests { + use super::{is_global_level, merge, LOG_FILTER}; + + #[test] + fn default_covers_every_harness_target_family() { + for family in ["buzz_acp", "acp", "pool", "canvas", "engram", "observer"] { + assert!( + LOG_FILTER.contains(&format!("{family}=")), + "default drops the `{family}` target family: {LOG_FILTER}" + ); + } + } + + #[test] + fn default_enables_no_content_bearing_target() { + // acp::stream logs assistant text verbatim at info, pool::prompt logs + // command arguments. Enabling a family at info would persist both to a + // plaintext file, so only warn and error may be on by default. + for family in ["acp", "pool", "canvas", "engram", "observer"] { + assert!( + LOG_FILTER.contains(&format!("{family}=warn")), + "`{family}` must default to warn, not info: {LOG_FILTER}" + ); + } + } + + #[test] + fn unset_or_blank_takes_the_default() { + assert_eq!(merge(None), LOG_FILTER); + assert_eq!(merge(Some(" ")), LOG_FILTER); + } + + #[test] + fn explicit_target_directive_keeps_the_other_families() { + let filter = merge(Some("buzz_acp=debug")); + assert!(filter.starts_with(LOG_FILTER), "{filter}"); + assert!(filter.ends_with(",buzz_acp=debug"), "{filter}"); + } + + #[test] + fn target_merely_containing_the_crate_name_does_not_bypass_defaults() { + assert!(merge(Some("my_buzz_acp=debug")).starts_with(LOG_FILTER)); + } + + #[test] + fn named_global_level_is_preserved_exactly() { + for value in ["debug", "TRACE", "off"] { + assert_eq!(merge(Some(value)), value, "global level must survive"); + } + } + + #[test] + fn numeric_global_levels_are_preserved_exactly() { + // EnvFilter accepts 0..5 as global levels. Treating them as targets + // would widen `0` (log nothing) and narrow `5` (trace). + for value in ["0", "1", "2", "3", "4", "5"] { + assert_eq!(merge(Some(value)), value, "numeric level must survive"); + assert!(is_global_level(value)); + } + } + + #[test] + fn unrelated_directive_is_appended_not_replaced() { + let filter = merge(Some("hyper=warn")); + assert!(filter.starts_with(LOG_FILTER), "{filter}"); + assert!(filter.ends_with(",hyper=warn"), "{filter}"); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b6cbaab514f..4a73c780641 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.17", + "version": "0.5.18", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index fa062329231..fc4a14f2cd7 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { markAllReadSources, + activateDesktopNotificationTarget, + createDesktopNotificationActivationQueue, shouldBounceForChannelNotification, } from "./AppShell.helpers.ts"; @@ -31,6 +33,177 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => { ); }); +test("notification activation queue preserves click order", async () => { + const calls = []; + const resolvers = new Map(); + const queue = createDesktopNotificationActivationQueue((target) => { + calls.push(`start:${target.channelId}`); + return new Promise((resolve) => { + resolvers.set(target.channelId, () => { + calls.push(`finish:${target.channelId}`); + resolve(); + }); + }); + }); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + queue.enqueue({ channelId: "second", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["start:first"]); + + resolvers.get("first")(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["start:first", "finish:first", "start:second"]); + + resolvers.get("second")(); +}); + +test("notification activation queue drops pending targets after cancellation", async () => { + const calls = []; + let resolveFirst; + const queue = createDesktopNotificationActivationQueue((target) => { + calls.push(target.channelId); + if (target.channelId === "first") { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(); + }); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + queue.enqueue({ channelId: "second", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["first"]); + + queue.cancel(); + resolveFirst(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["first"]); +}); + +test("notification activation queue aborts an in-flight activation", async () => { + let observedSignal; + let resolveActivation; + const queue = createDesktopNotificationActivationQueue((_target, signal) => { + observedSignal = signal; + return new Promise((resolve) => { + resolveActivation = resolve; + }); + }); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(observedSignal.aborted, false); + + queue.cancel(); + assert.equal(observedSignal.aborted, true); + resolveActivation(); +}); + +test("notification activation queue reports failures and continues", async () => { + const calls = []; + const errors = []; + const queue = createDesktopNotificationActivationQueue( + async (target) => { + calls.push(target.channelId); + if (target.channelId === "first") { + throw new Error("navigation failed"); + } + }, + (error) => errors.push(error), + ); + + queue.enqueue({ channelId: "first", eventId: null, kind: null }); + queue.enqueue({ channelId: "second", eventId: null, kind: null }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, ["first", "second"]); + assert.equal(errors.length, 1); + assert.match(errors[0].message, /navigation failed/); +}); + +test("notification activation starts routing before a hung reveal", async () => { + const calls = []; + let resolveNavigation; + let navigationSettled = false; + const activation = activateDesktopNotificationTarget( + { + channelId: "channel", + eventId: "event", + kind: 9, + }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + revealWindow: () => new Promise(() => {}), + openSearchHit: (_hit, behavior) => { + calls.push(`message:${String(behavior?.force)}`); + return new Promise((resolve) => { + resolveNavigation = () => { + navigationSettled = true; + resolve(); + }; + }); + }, + }, + ); + + assert.deepEqual(calls, ["message:true"]); + resolveNavigation(); + await activation; + assert.equal(navigationSettled, true); +}); + +test("notification activation falls back to forced channel navigation", async () => { + const calls = []; + await activateDesktopNotificationTarget( + { channelId: "channel", eventId: null, kind: null }, + { + goChannel: async (channelId, behavior) => + calls.push(`${channelId}:${String(behavior?.force)}`), + goHome: async () => calls.push("home"), + openSearchHit: async () => calls.push("message"), + revealWindow: async () => calls.push("reveal"), + }, + ); + + assert.deepEqual(calls, ["channel:true", "reveal"]); +}); + +test("notification activation ignores reveal rejection after routing starts", async () => { + const calls = []; + await activateDesktopNotificationTarget( + { channelId: "channel", eventId: null, kind: null }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: async () => calls.push("message"), + revealWindow: async () => { + calls.push("reveal"); + throw new Error("reveal failed"); + }, + }, + ); + + assert.deepEqual(calls, ["channel", "reveal"]); +}); + +test("notification activation without a channel opens home", async () => { + const calls = []; + await activateDesktopNotificationTarget( + { channelId: null, eventId: "event", kind: 9 }, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: async () => calls.push("message"), + revealWindow: async () => calls.push("reveal"), + }, + ); + + assert.deepEqual(calls, ["home", "reveal"]); +}); + test("markAllReadSources clears Inbox overrides and active thread activity", () => { const calls = []; diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e82..9fc14736c7c 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -141,6 +141,79 @@ export function toSearchHit( }; } +export function createDesktopNotificationActivationQueue( + activate: ( + target: DesktopNotificationTarget, + signal: AbortSignal, + ) => Promise, + onError?: (error: unknown) => void, +): { + cancel: () => void; + enqueue: (target: DesktopNotificationTarget) => void; +} { + const controller = new AbortController(); + let pending = Promise.resolve(); + + return { + cancel: () => { + controller.abort(); + }, + enqueue: (target) => { + // Preserve native click order when macOS drains multiple queued targets. + // Contain failures so one rejected navigation cannot poison later clicks. + pending = pending + .then(() => { + if (!controller.signal.aborted) { + return activate(target, controller.signal); + } + }) + .catch((error) => { + try { + onError?.(error); + } catch { + // Reporting must not poison the activation queue either. + } + }); + }, + }; +} + +export async function activateDesktopNotificationTarget( + target: DesktopNotificationTarget, + actions: { + goChannel: ( + channelId: string, + options?: { force?: boolean }, + ) => Promise; + goHome: () => Promise; + openSearchHit: ( + hit: SearchHit, + behavior?: { force?: boolean; signal?: AbortSignal }, + ) => Promise; + revealWindow: () => Promise; + }, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + return; + } + + let navigation: Promise; + if (!target.channelId) { + navigation = actions.goHome(); + } else { + const anchor = toSearchHit(target); + navigation = anchor + ? actions.openSearchHit(anchor, { force: true, signal }) + : actions.goChannel(target.channelId, { force: true }); + } + + // Native activation already foregrounds the app on macOS. Other platforms + // still get a best-effort reveal, but it must never gate click-through. + void actions.revealWindow().catch(() => undefined); + await navigation; +} + export function deriveShellRoute(pathname: string): { selectedChannelId: string | null; selectedView: AppView; diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs new file mode 100644 index 00000000000..74e5f108af6 --- /dev/null +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + activateDesktopNotificationTarget, + createDesktopNotificationActivationQueue, +} from "../AppShell.helpers.ts"; + +const { clearSearchHitEventCache, getCachedSearchHitEvent } = await import( + "./searchHitEventCache.ts" +); +const { openSearchHitWithNavigation } = await import( + "./searchHitNavigation.ts" +); + +const forumComment = { + eventId: "comment", + content: "reply", + kind: 45003, + pubkey: "author", + channelId: "old-community-channel", + channelName: "forum", + createdAt: 1, + score: 0, + threadRootId: null, +}; + +const plainMessage = { + ...forumComment, + eventId: "message", + kind: 9, + threadRootId: "thread-root", +}; + +test("search-hit navigation preserves forced message routing while active", async () => { + clearSearchHitEventCache(); + const calls = []; + const result = await openSearchHitWithNavigation(plainMessage, { + force: true, + goChannel: async (channelId, options) => { + calls.push({ channelId, options }); + return true; + }, + goForumPost: async () => false, + }); + + assert.equal(result, true); + assert.deepEqual(calls, [ + { + channelId: "old-community-channel", + options: { + force: true, + messageId: "message", + threadRootId: "thread-root", + }, + }, + ]); + assert.equal(getCachedSearchHitEvent("message")?.id, "message"); +}); + +test("cancelled search-hit navigation cannot repopulate cache or route", async () => { + clearSearchHitEventCache(); + let resolveLookup; + const destination = new Promise((resolve) => { + resolveLookup = resolve; + }); + const calls = []; + const controller = new AbortController(); + const navigation = openSearchHitWithNavigation( + forumComment, + { + goChannel: async () => calls.push("channel"), + goForumPost: async () => calls.push("forum"), + signal: controller.signal, + }, + () => destination, + ); + + controller.abort(); + clearSearchHitEventCache(); + resolveLookup({ + kind: "forum-post", + channelId: "old-community-channel", + postId: "old-community-post", + replyId: "comment", + }); + await navigation; + + assert.deepEqual(calls, []); + assert.equal(getCachedSearchHitEvent("comment"), null); +}); + +test("queue cancellation fences an in-flight forum-comment activation", async () => { + clearSearchHitEventCache(); + let resolveLookup; + const destination = new Promise((resolve) => { + resolveLookup = resolve; + }); + const calls = []; + const queue = createDesktopNotificationActivationQueue((target, signal) => + activateDesktopNotificationTarget( + target, + { + goChannel: async () => calls.push("channel"), + goHome: async () => calls.push("home"), + openSearchHit: (hit, behavior) => + openSearchHitWithNavigation( + hit, + { + force: behavior?.force, + goChannel: async () => calls.push("channel"), + goForumPost: async () => calls.push("forum"), + signal: behavior?.signal, + }, + () => destination, + ), + revealWindow: async () => {}, + }, + signal, + ), + ); + + queue.enqueue({ + channelId: "old-community-channel", + eventId: "comment", + kind: 45003, + }); + await new Promise((resolve) => setImmediate(resolve)); + queue.cancel(); + clearSearchHitEventCache(); + resolveLookup({ + kind: "forum-post", + channelId: "old-community-channel", + postId: "old-community-post", + replyId: "comment", + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, []); + assert.equal(getCachedSearchHitEvent("comment"), null); +}); diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts new file mode 100644 index 00000000000..8523340d481 --- /dev/null +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -0,0 +1,60 @@ +import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; +import type { SearchHit } from "@/shared/api/types"; + +type SearchHitNavigationActions = { + force?: boolean; + goChannel: ( + channelId: string, + options?: { + force?: boolean; + messageId?: string; + threadRootId?: string | null; + }, + ) => Promise; + goForumPost: ( + channelId: string, + postId: string, + options?: { force?: boolean; replyId?: string }, + ) => Promise; + signal?: AbortSignal; +}; + +export async function openSearchHitWithNavigation( + hit: SearchHit, + actions: SearchHitNavigationActions, + resolveDestination = resolveSearchHitDestination, +): Promise { + if (actions.signal?.aborted) { + return false; + } + + const isLifecycleBound = Boolean(actions.signal); + if (!isLifecycleBound) { + cacheSearchHitEvent(hit); + } + + const destination = await resolveDestination(hit); + if (!destination || actions.signal?.aborted) { + return false; + } + + if (isLifecycleBound) { + // Delay community-scoped writes for notification routing until async + // destination resolution completes and its owner is still current. + cacheSearchHitEvent(hit); + } + + if (destination.kind === "forum-post") { + return actions.goForumPost(destination.channelId, destination.postId, { + force: actions.force, + replyId: destination.replyId, + }); + } + + return actions.goChannel(destination.channelId, { + force: actions.force, + messageId: destination.messageId, + threadRootId: destination.threadRootId, + }); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 53db19d3789..c4776564e34 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,8 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; -import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; -import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { @@ -247,6 +246,10 @@ export function useAppNavigation() { * firing. Used by the Drafts panel "Send message" confirm flow. */ autoSend?: string; + /** Navigate even when the destination matches the current href. + * Used by desktop-notification activation so a click is never + * silently swallowed (block/buzz#3509). */ + force?: boolean; messageId?: string; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ @@ -275,6 +278,7 @@ export function useAppNavigation() { }, }, { + force: options?.force, replace: options?.replace, resetScroll: options?.messageId ? true : undefined, }, @@ -298,6 +302,8 @@ export function useAppNavigation() { channelId: string, postId: string, options?: { + /** Navigate even when the destination matches the current href. */ + force?: boolean; replace?: boolean; replyId?: string; }, @@ -312,6 +318,7 @@ export function useAppNavigation() { search: options?.replyId ? { replyId: options.replyId } : {}, }, { + force: options?.force, replace: options?.replace, resetScroll: false, }, @@ -362,25 +369,23 @@ export function useAppNavigation() { ); const openSearchHit = React.useCallback( - async (hit: SearchHit) => { - cacheSearchHitEvent(hit); - - const destination = await resolveSearchHitDestination(hit); - if (!destination) { - return false; - } - - if (destination.kind === "forum-post") { - return goForumPost(destination.channelId, destination.postId, { - replyId: destination.replyId, - }); - } - - return goChannel(destination.channelId, { - messageId: destination.messageId, - threadRootId: destination.threadRootId, - }); - }, + async ( + hit: SearchHit, + behavior?: { + /** Navigate even when the destination matches the current href. + * Used by desktop-notification activation so a click is never + * silently swallowed (block/buzz#3509). */ + force?: boolean; + /** Stop notification-driven routing when its owning lifecycle ends. */ + signal?: AbortSignal; + }, + ) => + openSearchHitWithNavigation(hit, { + force: behavior?.force, + goChannel, + goForumPost, + signal: behavior?.signal, + }), [goChannel, goForumPost], ); diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 6792faf21a7..b86b95363cd 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -1,10 +1,10 @@ import * as React from "react"; import { + activateDesktopNotificationTarget, + createDesktopNotificationActivationQueue, shouldBounceForChannelNotification, - toSearchHit, } from "@/app/AppShell.helpers"; -import { getThreadReference } from "@/features/messages/lib/threading"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; @@ -14,15 +14,14 @@ import { revealDesktopAppWindow, sendDesktopNotification, } from "@/features/notifications/lib/desktop"; -import { - formatNotificationTitle, - truncateNotificationBody, -} from "@/features/notifications/lib/notificationFormat"; +import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat"; +import { buildEventNotificationTarget } from "@/features/notifications/lib/target"; import { playNotificationSound, resolveSlotSound, shouldPlayNotificationSound, } from "@/features/notifications/lib/sound"; +import { useNotificationSenderName } from "@/features/notifications/useNotificationSenderName"; import type { Channel, RelayEvent } from "@/shared/api/types"; export function useAppShellDesktopNotifications({ @@ -37,11 +36,15 @@ export function useAppShellDesktopNotifications({ }: { channels: Channel[]; enabled: boolean; - goChannel: (channelId: string) => Promise; + goChannel: ( + channelId: string, + options?: { force?: boolean }, + ) => Promise; goHome: () => Promise; notificationSettings: NotificationSettings; openSearchHit: ( hit: import("@/shared/api/types").SearchHit, + behavior?: { force?: boolean }, ) => Promise; pubkey?: string; silentChannelIds?: ReadonlySet; @@ -53,6 +56,8 @@ export function useAppShellDesktopNotifications({ enabled: enabled && notificationSettings.desktopEnabled, }); + const resolveSenderName = useNotificationSenderName(); + const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { if (!enabled) return; @@ -73,22 +78,20 @@ export function useAppShellDesktopNotifications({ } const channelName = channel.name?.trim() || "Direct message"; - const body = truncateNotificationBody(event.content, "New message"); - const threadRootId = getThreadReference(event.tags).rootId ?? null; + const { title, body } = formatMessageNotification({ + source: "dm", + senderName: resolveSenderName(event.pubkey), + channelName, + content: event.content, + }); void sendDesktopNotification({ - title: channelName, + title, body, - target: { - channelId: channel.id, - channelName, - content: event.content, - createdAt: event.created_at, - eventId: event.id, - kind: event.kind, - pubkey: event.pubkey, - threadRootId, - }, + target: buildEventNotificationTarget(event, { + id: channel.id, + name: channelName, + }), }).then((didSend) => { if (!didSend) return; if (shouldPlayNotificationSound(channel.id, silentChannelIds)) { @@ -118,25 +121,20 @@ export function useAppShellDesktopNotifications({ const resolvedChannel = channels.find((c) => c.id === channelId); const channelName = resolvedChannel?.name?.trim() ?? null; - // channelLabel is "#name" for the toast title; channelName is the raw - // name stored in the navigation target for click-through routing. - const channelLabel = channelName ? `#${channelName}` : null; - const body = truncateNotificationBody(event.content, "New reply"); - const threadRootId = getThreadReference(event.tags).rootId ?? null; + const { title, body } = formatMessageNotification({ + source: "thread_reply", + senderName: resolveSenderName(event.pubkey), + channelName, + content: event.content, + }); void sendDesktopNotification({ - title: formatNotificationTitle({ prefix: "Reply", channelLabel }), + title, body, - target: { - channelId, - channelName, - content: event.content, - createdAt: event.created_at, - eventId: event.id, - kind: event.kind, - pubkey: event.pubkey, - threadRootId, - }, + target: buildEventNotificationTarget(event, { + id: channelId, + name: channelName, + }), }).then((didSend) => { if (!didSend) return; if (shouldPlayNotificationSound(channelId, silentChannelIds)) { @@ -152,21 +150,18 @@ export function useAppShellDesktopNotifications({ const handleDesktopNotificationAction = React.useEffectEvent( async ( target: import("@/features/notifications/lib/desktop").DesktopNotificationTarget, + signal: AbortSignal, ) => { - await revealDesktopAppWindow(); - - if (!target.channelId) { - void goHome(); - return; - } - - const anchor = toSearchHit(target); - if (!anchor) { - await goChannel(target.channelId); - return; - } - - await openSearchHit(anchor); + await activateDesktopNotificationTarget( + target, + { + goChannel, + goHome, + openSearchHit, + revealWindow: revealDesktopAppWindow, + }, + signal, + ); }, ); @@ -174,13 +169,19 @@ export function useAppShellDesktopNotifications({ if (!enabled) return; let isCancelled = false; let cleanup = () => {}; + const activationQueue = createDesktopNotificationActivationQueue( + (target, signal) => handleDesktopNotificationAction(target, signal), + (error) => { + console.error("Failed to activate desktop notification", error); + }, + ); void listenForDesktopNotificationActions((target) => { if (isCancelled) { return; } - void handleDesktopNotificationAction(target); + activationQueue.enqueue(target); }).then((dispose) => { if (isCancelled) { dispose(); @@ -192,6 +193,7 @@ export function useAppShellDesktopNotifications({ return () => { isCancelled = true; + activationQueue.cancel(); cleanup(); }; }, [enabled]); diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index e8b93207945..3d797b3bb72 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,9 +1,14 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; +/** + * Cmd +/- scales the real root font-size, so every rem in the app — text, + * spacing, widths, radii — zooms together. The Font size preference is a + * separate, text-only dial layered on top (see `styles/globals/typography.css`). + */ +const BASE_FONT_SIZE_PX = 16; const DEFAULT_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.75; const MAX_ZOOM_FACTOR = 1.5; @@ -75,8 +80,15 @@ function readStoredZoomFactor() { return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR); } +function applyRootZoom(zoomFactor: number) { + document.documentElement.style.fontSize = + zoomFactor === DEFAULT_ZOOM_FACTOR + ? "" + : `${BASE_FONT_SIZE_PX * zoomFactor}px`; +} + function applyTextScale(zoomFactor: number) { - applyTextZoomFactor(zoomFactor); + applyRootZoom(zoomFactor); if (zoomFactor === DEFAULT_ZOOM_FACTOR) { window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); return; @@ -95,7 +107,8 @@ export function useWebviewZoomShortcuts() { zoomFactorRef.current = storedZoomFactor; applyTextScale(storedZoomFactor); - // Keep the webview coordinate system stable; only text should scale. + // Pin the native webview zoom so the rem root is the only zoom dial and + // window/coordinate math stays stable. void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => { console.error("Failed to reset webview zoom", error); }); @@ -126,7 +139,7 @@ export function useWebviewZoomShortcuts() { const storedZoomFactor = readStoredZoomFactor(); zoomFactorRef.current = storedZoomFactor; - applyTextZoomFactor(storedZoomFactor); + applyRootZoom(storedZoomFactor); } window.addEventListener("keydown", handleKeyDown); diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts index 70cbac68fdb..37e0b8d6afe 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.ts +++ b/desktop/src/features/agents/knownAgentPubkeys.ts @@ -1,4 +1,5 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; +import { channelAgentMembers } from "@/shared/lib/rosterDerivations"; /** * Pure merge behind `useKnownAgentPubkeys`: managed agents ∪ relay agents, @@ -62,10 +63,11 @@ export function mergeChannelKnownAgentPubkeys( relayAgents: readonly { pubkey: string }[] | undefined, ): ReadonlySet { const pubkeys = new Set(mergeKnownAgentPubkeys(managedAgents, relayAgents)); - for (const member of channelMembers ?? []) { - if (member.role === "bot" || member.isAgent) { - pubkeys.add(normalizePubkey(member.pubkey)); - } + // Identity-cached agent subset: avoids walking the full roster per call. + for (const member of channelMembers + ? channelAgentMembers(channelMembers) + : []) { + pubkeys.add(normalizePubkey(member.pubkey)); } return pubkeys; } diff --git a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs new file mode 100644 index 00000000000..f57c7f8154f --- /dev/null +++ b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isOtherSetupAgent } from "./otherSetupAgent.ts"; + +const OWNER = "a".repeat(64); +const AGENT = "b".repeat(64); + +test("fails closed while the local managed directory is unresolved", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: false, + currentPubkey: OWNER, + managedAgents: [], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + false, + ); +}); + +test("labels a viewer-owned non-local identity as another setup", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: true, + currentPubkey: OWNER, + managedAgents: [], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + true, + ); +}); diff --git a/desktop/src/features/agents/lib/otherSetupAgent.ts b/desktop/src/features/agents/lib/otherSetupAgent.ts new file mode 100644 index 00000000000..63438a983fd --- /dev/null +++ b/desktop/src/features/agents/lib/otherSetupAgent.ts @@ -0,0 +1,39 @@ +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function isOtherSetupAgent({ + agentDirectoriesReady, + currentPubkey, + managedAgents, + profileOwnerPubkey, + pubkey, + relayAgents, +}: { + agentDirectoriesReady: boolean; + currentPubkey?: string; + managedAgents: readonly ManagedAgent[]; + profileOwnerPubkey?: string | null; + pubkey: string; + relayAgents: readonly RelayAgent[]; +}): boolean { + if (!agentDirectoriesReady || !currentPubkey) return false; + + const normalizedPubkey = normalizePubkey(pubkey); + if ( + managedAgents.some( + (agent) => normalizePubkey(agent.pubkey) === normalizedPubkey, + ) + ) { + return false; + } + + const relayOwnerPubkey = relayAgents.find( + (agent) => normalizePubkey(agent.pubkey) === normalizedPubkey, + )?.ownerPubkey; + const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey; + + return Boolean( + ownerPubkey && + normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), + ); +} diff --git a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx new file mode 100644 index 00000000000..e6539434696 --- /dev/null +++ b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx @@ -0,0 +1,30 @@ +import { Cloud } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; + +const OTHER_SETUP_LABEL = "From another Buzz setup"; + +export function OtherSetupAgentMarker({ + className, + testId, +}: { + className?: string; + testId?: string; +}) { + return ( + + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index f78f9d327ef..d2791b480a0 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -592,7 +592,7 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {

- Agent instruction + Agent instructions

diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index e371bf5fc30..dfb8eb22fbd 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -871,7 +871,7 @@ export function processTranscriptEvent( } } else if (event.kind === "acp_write" && method === "session/new") { // The base + persona prompts ride session/new's systemPrompt, framed by - // the harness as [Base]/[System]/[Agent Memory — core]/[Channel Canvas]. + // the harness as [Base]/[Agent Instructions]/[Agent Memory — core]/[Channel Canvas]. // claude-agent-acp uses _meta.systemPrompt.append instead; both paths // produce the same standalone card (turnId: null, acpSource "session/new"); // the bare field takes precedence when both are present. diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index f0f4cbf36da..23df1e5f2b2 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -201,6 +201,45 @@ test("parseSystemPromptSections splits both prompts into Base and System", () => ]); }); +test("parseSystemPromptSections splits current Base and Agent Instructions framing", () => { + const framed = + "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { title: "Workspace", body: "Current working directory: /workspace" }, + { title: "Agent Instructions", body: "persona text" }, + ]); +}); + +test("parseSystemPromptSections preserves a Windows workspace path", () => { + const framed = + "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: C:\\Users\\me\\buzz\n\n[Agent Instructions]\npersona text"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { + title: "Workspace", + body: "Current working directory: C:\\Users\\me\\buzz", + }, + { title: "Agent Instructions", body: "persona text" }, + ]); +}); + +test("parseSystemPromptSections preserves the former Workspace-before-Base framing", () => { + const framed = + "[Workspace]\nYour absolute working directory is `/workspace`.\n\n[Base]\nbase text\n\n[System]\npersona text"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { + title: "Workspace", + body: "Your absolute working directory is `/workspace`.", + }, + { title: "Base", body: "base text" }, + { title: "System", body: "persona text" }, + ]); +}); + test("parseSystemPromptSections yields one Base section for a base-only frame", () => { const sections = parseSystemPromptSections("[Base]\nbase text"); assert.deepEqual(sections, [{ title: "Base", body: "base text" }]); @@ -211,6 +250,15 @@ test("parseSystemPromptSections yields one System section for a persona-only fra assert.deepEqual(sections, [{ title: "System", body: "persona text" }]); }); +test("parseSystemPromptSections yields Agent Instructions for a current persona-only frame", () => { + const sections = parseSystemPromptSections( + "[Agent Instructions]\npersona text", + ); + assert.deepEqual(sections, [ + { title: "Agent Instructions", body: "persona text" }, + ]); +}); + test("parseSystemPromptSections keeps embedded bracket lines literal in bodies", () => { // A persona that itself contains a [Context]-like line must NOT split into a // spurious sub-section — the body is read literally after the first boundary. @@ -323,18 +371,15 @@ test("parseSystemPromptSections keeps exact core header literal when only a sing ]); }); -test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core harness shape", () => { - // The real Buzz harness emits [Workspace] content before [Base]. The parser - // folds [Workspace] into the Base section (existing unchanged behavior); - // core is extracted as a distinct "Core Memory" section last. +test("parseSystemPromptSections pins the current Base+Workspace+Agent Instructions+Core harness shape", () => { const framed = [ - "[Workspace]", - "You are operating inside the Buzz platform.", - "", "[Base]", "You are an assistant.", "", - "[System]", + "[Workspace]", + "Current working directory: /workspace", + "", + "[Agent Instructions]", "Custom persona instructions.", "", "[Agent Memory — core]", @@ -344,11 +389,9 @@ test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core ha ].join("\n"); const sections = parseSystemPromptSections(framed); assert.deepEqual(sections, [ - { - title: "Base", - body: "[Workspace]\nYou are operating inside the Buzz platform.\n\n[Base]\nYou are an assistant.", - }, - { title: "System", body: "Custom persona instructions." }, + { title: "Base", body: "You are an assistant." }, + { title: "Workspace", body: "Current working directory: /workspace" }, + { title: "Agent Instructions", body: "Custom persona instructions." }, { title: "Core Memory", body: "I am Duncan.\n## Lessons Learned\nAlways tag on handoff.", diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 09a2bb31cf9..87cf8ec2dfa 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -56,12 +56,12 @@ export function parsePromptText(text: string): { } /** - * Split the framed `session/new` `systemPrompt` into its `Base`/`System`/ + * Split the framed `session/new` `systemPrompt` into its `Base`/`Agent Instructions`/ * `Team Instructions`/`Core Memory`/`Channel Canvas` sub-sections * deterministically. * * The harness composes the value in order: - * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` + * `[Base]\n{base}\n\n[Agent Instructions]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` * with any section omitted when absent. Extraction runs in reverse producer * order so that each `lastIndexOf` search operates on the full input and each * extraction boundary is unambiguous. @@ -80,18 +80,19 @@ export function parsePromptText(text: string): { * 3. **Team Instructions** (`[Team Instructions]`): appended before core by * `with_team()` in `buzz-acp/src/pool.rs`. Same two cases (start-of-string * or `\n\n[Team Instructions]\n` inline), same last-occurrence guard. Output - * position: after System, before Core Memory. + * position: after Agent Instructions, before Core Memory. * - * 4. **Base/System**: remainder after the three top-level section extractions. - * Split on the first `\n[System]\n` boundary; no embedded `[...]` line - * inside a body can start a new section. + * 4. **Base/Agent Instructions**: remainder after the three top-level section + * extractions. Split on the first `\n[Agent Instructions]\n` boundary. + * Archived frames using the former `[System]` header remain supported and + * retain their historical observer label. * - * 5. **Legacy Team Instructions** (backward compat): if the `System` body + * 5. **Legacy Team Instructions** (backward compat): if the agent-instructions body * contains the exact canonical delimiter `\n\n---\n# Team Instructions\n` * (produced by the now-removed `compose_prompt()` in buzz-persona), the body * is split at the **last** occurrence of that boundary. The text before - * becomes the `System` body; the text after becomes a `Team Instructions` - * section inserted immediately after `System`. Non-canonical lookalikes + * becomes the agent-instructions body; the text after becomes a `Team Instructions` + * section inserted immediately after it. Non-canonical lookalikes * (bare `---` without the heading, a `# Team Instructions` on a different * line, or only a single preceding newline) are kept literal inside `System`. */ @@ -137,7 +138,7 @@ export function parseSystemPromptSections( // ── 3. Extract [Team Instructions] (modern runtime framing) ───────────── // with_team() in buzz-acp/src/pool.rs appends "\n\n[Team Instructions]\n{instructions}" - // after [System] and before core/canvas. Same two cases as canvas/core: + // after [Agent Instructions] and before core/canvas. Same two cases as canvas/core: // start-of-string (team-only input) or the inline double-newline marker // (last occurrence guards against embedded lookalikes preceded by a single \n). const TEAM_HEADER = "[Team Instructions]"; @@ -157,48 +158,110 @@ export function parseSystemPromptSections( } } - // ── 4. Parse Base/System from the remaining prefix ──────────────────────── + // ── 4. Parse Base/Workspace/Agent Instructions from the remaining prefix ─ // The canonical team-instructions delimiter produced by compose_prompt() in // buzz-persona/src/resolve.rs: // format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}") const TEAM_DELIMITER = "\n\n---\n# Team Instructions\n"; - // splitSystemBody: split a raw [System] body string at the last occurrence - // of the canonical team delimiter, returning { systemBody, teamBody | null }. + // splitInstructionsBody: split a raw agent-instructions body string at the last occurrence + // of the canonical team delimiter, returning { instructionsBody, teamBody | null }. // Using lastIndexOf mirrors the canvas/core last-occurrence guard: a persona // author can embed an exact delimiter-like passage inside the persona body; // only the final occurrence is the producer boundary appended by compose_prompt(). - function splitSystemBody(raw: string): { - systemBody: string; + function splitInstructionsBody(raw: string): { + instructionsBody: string; teamBody: string | null; } { const at = raw.lastIndexOf(TEAM_DELIMITER); - if (at === -1) return { systemBody: raw.trim(), teamBody: null }; + if (at === -1) return { instructionsBody: raw.trim(), teamBody: null }; return { - systemBody: raw.slice(0, at).trim(), + instructionsBody: raw.slice(0, at).trim(), teamBody: raw.slice(at + TEAM_DELIMITER.length).trim() || null, }; } - const baseAndSystem = remainder; - if (baseAndSystem) { - if (baseAndSystem.startsWith("[System]\n")) { - const raw = baseAndSystem.slice("[System]\n".length); - const { systemBody, teamBody } = splitSystemBody(raw); - if (systemBody) sections.push({ title: "System", body: systemBody }); + const instructionFrames = [ + { header: "[Agent Instructions]", title: "Agent Instructions" }, + { header: "[System]", title: "System" }, + ] as const; + + function appendBaseAndWorkspace(raw: string): void { + const BASE_HEADER = "[Base]"; + const WORKSPACE_HEADER = "[Workspace]"; + const workspaceMarker = `\n\n${WORKSPACE_HEADER}\n`; + const baseMarker = `\n\n${BASE_HEADER}\n`; + + // Current framing keeps the static base first, followed by the dynamic cwd. + if (raw.startsWith(`${BASE_HEADER}\n`)) { + const workspaceAt = raw.lastIndexOf(workspaceMarker); + if (workspaceAt !== -1) { + const baseBody = raw + .slice(`${BASE_HEADER}\n`.length, workspaceAt) + .trim(); + const workspaceBody = raw + .slice(workspaceAt + workspaceMarker.length) + .trim(); + if (baseBody) sections.push({ title: "Base", body: baseBody }); + if (workspaceBody) + sections.push({ title: "Workspace", body: workspaceBody }); + return; + } + } + + // Preserve readable transcripts for sessions captured with the former + // Workspace-before-Base framing. + if (raw.startsWith(`${WORKSPACE_HEADER}\n`)) { + const baseAt = raw.lastIndexOf(baseMarker); + if (baseAt !== -1) { + const workspaceBody = raw + .slice(`${WORKSPACE_HEADER}\n`.length, baseAt) + .trim(); + const baseBody = raw.slice(baseAt + baseMarker.length).trim(); + if (workspaceBody) + sections.push({ title: "Workspace", body: workspaceBody }); + if (baseBody) sections.push({ title: "Base", body: baseBody }); + return; + } + } + + const baseBody = raw.replace(/^\[Base]\n/, "").trim(); + if (baseBody) sections.push({ title: "Base", body: baseBody }); + } + + const baseAndInstructions = remainder; + if (baseAndInstructions) { + const leadingFrame = instructionFrames.find(({ header }) => + baseAndInstructions.startsWith(`${header}\n`), + ); + if (leadingFrame) { + const raw = baseAndInstructions.slice(`${leadingFrame.header}\n`.length); + const { instructionsBody, teamBody } = splitInstructionsBody(raw); + if (instructionsBody) + sections.push({ title: leadingFrame.title, body: instructionsBody }); if (teamBody) sections.push({ title: "Team Instructions", body: teamBody }); } else { - const marker = "\n[System]\n"; - const at = baseAndSystem.indexOf(marker); - const head = at === -1 ? baseAndSystem : baseAndSystem.slice(0, at); - const baseBody = head.replace(/^\[Base]\n/, "").trim(); - if (baseBody) sections.push({ title: "Base", body: baseBody }); - - if (at !== -1) { - const raw = baseAndSystem.slice(at + marker.length); - const { systemBody, teamBody } = splitSystemBody(raw); - if (systemBody) sections.push({ title: "System", body: systemBody }); + const boundary = instructionFrames + .map((frame) => ({ + ...frame, + marker: `\n${frame.header}\n`, + at: baseAndInstructions.indexOf(`\n${frame.header}\n`), + })) + .filter(({ at }) => at !== -1) + .sort((a, b) => a.at - b.at)[0]; + const head = boundary + ? baseAndInstructions.slice(0, boundary.at) + : baseAndInstructions; + appendBaseAndWorkspace(head); + + if (boundary) { + const raw = baseAndInstructions.slice( + boundary.at + boundary.marker.length, + ); + const { instructionsBody, teamBody } = splitInstructionsBody(raw); + if (instructionsBody) + sections.push({ title: boundary.title, body: instructionsBody }); if (teamBody) sections.push({ title: "Team Instructions", body: teamBody }); } diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 8270bea11f8..0627ad6ac36 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -15,6 +16,7 @@ import { } from "@/features/agents/hooks"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; import { usePresenceQuery } from "@/features/presence/hooks"; import type { AgentPersona, Channel, ManagedAgent } from "@/shared/api/types"; import { removeChannelMember } from "@/shared/api/tauri"; @@ -34,6 +36,7 @@ import { } from "../lib/instanceInputForDefinition"; export function useManagedAgentActions() { + const queryClient = useQueryClient(); const { globalConfig } = useGlobalAgentConfig(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); @@ -296,6 +299,9 @@ export function useManagedAgentActions() { await Promise.allSettled( channelIds.map((channelId) => removeChannelMember(channelId, pubkey)), ); + // Direct writes bypass the member mutations' invalidation; without this, + // the deleted agent stays in cached rosters for the freshness window. + await invalidateChannelMembersRosters(queryClient, channelIds); } async function handleDelete(pubkey: string) { diff --git a/desktop/src/features/channels/hooks.test.mjs b/desktop/src/features/channels/hooks.test.mjs index 8efeed8536c..7f4ee63656e 100644 --- a/desktop/src/features/channels/hooks.test.mjs +++ b/desktop/src/features/channels/hooks.test.mjs @@ -353,3 +353,22 @@ test("reconcileRefreshedCachedChannel_preservesRefreshedDmRecency", () => { ownerPubkey, ]); }); + +test("invalidateChannelMembersRosters dedupes and targets member keys", async () => { + const { invalidateChannelMembersRosters } = await import( + "./rosterFreshness.ts" + ); + const invalidated = []; + const queryClient = { + invalidateQueries: async ({ queryKey }) => { + invalidated.push(queryKey); + }, + }; + + await invalidateChannelMembersRosters(queryClient, ["ch-a", "ch-b", "ch-a"]); + + assert.deepEqual(invalidated, [ + ["channels", "ch-a", "members"], + ["channels", "ch-b", "members"], + ]); +}); diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9f612031f3b..9069b052da4 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -44,12 +44,15 @@ import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRe import { useIdentityQuery } from "@/shared/api/hooks"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; -import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission"; import { inspectChannelSnapshot, type ChannelSnapshot, writeChannelSnapshot, } from "@/features/channels/channelSnapshot"; +import { + CHANNEL_MEMBERS_STALE_TIME_MS, + channelMembersQueryKey, +} from "@/features/channels/rosterFreshness"; export const channelsQueryKey = ["channels"] as const; /** Keeps focused polling at the established one-minute cadence. */ @@ -69,8 +72,6 @@ export const channelsFocusRefetchPolicy = { const channelsSnapshotPairKey = ["channels", "_snapshot-pair"] as const; const channelDetailQueryKey = (channelId: string) => ["channels", channelId, "detail"] as const; -const channelMembersQueryKey = (channelId: string) => - ["channels", channelId, "members"] as const; const channelTypeOrder = { stream: 0, forum: 1, @@ -628,7 +629,7 @@ export function useChannelMembersQuery( return getChannelMembers(channelId); }, - staleTime: 30_000, + staleTime: CHANNEL_MEMBERS_STALE_TIME_MS, }); } @@ -795,32 +796,6 @@ export function useDeleteChannelMutation(channelId: string | null) { }); } -/** - * Whether the signed-in identity may add *another* identity to this channel, - * per {@link canAddChannelMembers}. Both queries are the ones the channel UI - * already holds, so this shares their cache rather than fetching again. - */ -export function useCanAddChannelMembers(channelId: string | null) { - const channelsQuery = useChannelsQuery(); - const membersQuery = useChannelMembersQuery(channelId); - const identityQuery = useIdentityQuery(); - - const channel = - channelsQuery.data?.find((candidate) => candidate.id === channelId) ?? null; - const selfPubkey = identityQuery.data?.pubkey ?? null; - const selfRole = selfPubkey - ? (membersQuery.data?.find( - (member) => member.pubkey.toLowerCase() === selfPubkey.toLowerCase(), - )?.role ?? null) - : null; - - return canAddChannelMembers({ - channelType: channel?.channelType, - visibility: channel?.visibility, - selfRole, - }); -} - export function useAddChannelMembersMutation(channelId: string | null) { const queryClient = useQueryClient(); diff --git a/desktop/src/features/channels/lib/huddleAvailability.test.mjs b/desktop/src/features/channels/lib/huddleAvailability.test.mjs index 061d2cdc424..8351651a759 100644 --- a/desktop/src/features/channels/lib/huddleAvailability.test.mjs +++ b/desktop/src/features/channels/lib/huddleAvailability.test.mjs @@ -87,11 +87,9 @@ test("canStartHuddleInChannel blocks non-participant DMs", () => { }); test("canStartHuddleInChannel keeps private channels member-gated", () => { - const privateChannel = channel({ visibility: "private" }); - assert.equal( canStartHuddleInChannel({ - channel: privateChannel, + channel: channel({ visibility: "private", isMember: false }), currentPubkey: SELF, selfMember: null, }), @@ -100,7 +98,7 @@ test("canStartHuddleInChannel keeps private channels member-gated", () => { assert.equal( canStartHuddleInChannel({ - channel: privateChannel, + channel: channel({ visibility: "private", isMember: false }), currentPubkey: SELF, selfMember: member(), }), @@ -108,6 +106,19 @@ test("canStartHuddleInChannel keeps private channels member-gated", () => { ); }); +test("canStartHuddleInChannel accepts channel-level membership without a roster", () => { + // The roster is fetched lazily; `channel.isMember` derives from the same + // kind:39002 event, so it must satisfy the private-channel gate on its own. + assert.equal( + canStartHuddleInChannel({ + channel: channel({ visibility: "private", isMember: true }), + currentPubkey: SELF, + selfMember: null, + }), + true, + ); +}); + test("canStartHuddleInChannel blocks archived channels and DMs", () => { assert.equal( canStartHuddleInChannel({ diff --git a/desktop/src/features/channels/lib/huddleAvailability.ts b/desktop/src/features/channels/lib/huddleAvailability.ts index 828712d9dd8..15f5f57ab43 100644 --- a/desktop/src/features/channels/lib/huddleAvailability.ts +++ b/desktop/src/features/channels/lib/huddleAvailability.ts @@ -31,5 +31,10 @@ export function canStartHuddleInChannel({ ); } - return channel.visibility === "open" || selfMember !== null; + // `channel.isMember` and the roster's self entry derive from the same + // kind:39002 event; either satisfies the private-channel gate, so callers + // that no longer fetch the roster eagerly keep huddle access. + return ( + channel.visibility === "open" || channel.isMember || selfMember !== null + ); } diff --git a/desktop/src/features/channels/rosterFreshness.ts b/desktop/src/features/channels/rosterFreshness.ts new file mode 100644 index 00000000000..a0a3603165e --- /dev/null +++ b/desktop/src/features/channels/rosterFreshness.ts @@ -0,0 +1,45 @@ +/** + * Member-roster freshness policy and the invalidation helper for write paths + * that bypass the member mutations. Split from hooks.ts to keep that file + * under the per-file line cap; behavior unchanged. + */ + +import type { useQueryClient } from "@tanstack/react-query"; + +/** Single source for the members cache key; hooks.ts imports it from here. */ +export const channelMembersQueryKey = (channelId: string) => + ["channels", channelId, "members"] as const; + +/** + * Freshness window for the full member roster. Kept long because every + * membership change the client can observe invalidates this key explicitly: + * live join/leave/removed system messages for the active channel + * (useChannelSubscription), member-added/removed notifications targeting the + * current identity (useMembershipNotifications), and every membership + * mutation (add/remove/join/leave, template apply). The residual staleness is + * a third party joining a channel the viewer is not currently subscribed to, + * which corrects within this window. The previous 30s window put a full + * roster fetch (kind:39002 + a kind:0 batch over every member) on nearly + * every channel switch. + */ +export const CHANNEL_MEMBERS_STALE_TIME_MS = 5 * 60_000; + +/** + * Invalidates cached rosters for channels whose membership was written + * through direct `removeChannelMember` calls that bypass the member + * mutations (moderation kick, agent-deletion cleanup). The roster's long + * freshness window (CHANNEL_MEMBERS_STALE_TIME_MS) means any direct write + * path that skips this leaves the removed identity visible until the window + * lapses. Accepts a minimal client shape so node unit tests can stub it. + */ +export async function invalidateChannelMembersRosters( + queryClient: Pick, "invalidateQueries">, + channelIds: Iterable, +) { + const uniqueChannelIds = [...new Set(channelIds)]; + for (const channelId of uniqueChannelIds) { + await queryClient.invalidateQueries({ + queryKey: channelMembersQueryKey(channelId), + }); + } +} diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx new file mode 100644 index 00000000000..4990168b2f6 --- /dev/null +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -0,0 +1,92 @@ +import { Bot } from "lucide-react"; +import type { UserSearchResult } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const MEMBER_ROW_INSET_DIVIDER_CLASS = + "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; + +export function formatAddCandidateName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +export function AddMemberSearchResultRow({ + disabled, + onSelect, + ownerLabel, + user, +}: { + disabled: boolean; + onSelect: (user: UserSearchResult) => void; + ownerLabel?: string | null; + user: UserSearchResult; +}) { + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 2debd9e7a9b..a347cf41bbf 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -65,7 +65,14 @@ export function ChannelMembersBar({ ); const { startHuddle, isStarting: isStartingHuddle } = useHuddle(); const queryClient = useQueryClient(); - const membersQuery = useChannelMembersQuery(channel.id); + // The roster is only needed for DM huddle composition (agent detection and + // participant naming). Streams/forums render the count from the channel + // summary and gate huddle access on `channel.isMember`, so mounting this + // bar must not put a full-roster fetch on the channel-switch path. + const membersQuery = useChannelMembersQuery( + channel.id, + channel.channelType === "dm", + ); const providersQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 82da2e42142..bccc163ed40 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -692,6 +692,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} [...people, ...bots].sort((left, right) => @@ -611,6 +610,16 @@ export function MembersSidebar({ const managedAgent = memberIsBot ? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) : undefined; + const showOtherSetupMarker = + memberIsBot && + isOtherSetupAgent({ + agentDirectoriesReady, + currentPubkey, + managedAgents: managedAgentsQuery.data ?? [], + profileOwnerPubkey: memberProfile?.ownerPubkey, + pubkey: member.pubkey, + relayAgents: relayAgentsQuery.data ?? [], + }); const managedAgentRuntime = memberIsBot && relayUrl ? findManagedAgentRuntime( @@ -675,6 +684,7 @@ export function MembersSidebar({ memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null } profileAvatarUrl={memberProfile?.avatarUrl ?? null} + showOtherSetupMarker={showOtherSetupMarker} viewerIsOwner={viewerIsOwner} /> @@ -913,78 +923,3 @@ function SearchResultSectionTitle({ ); } - -function AddMemberSearchResultRow({ - disabled, - onSelect, - ownerLabel, - user, -}: { - disabled: boolean; - onSelect: (user: UserSearchResult) => void; - ownerLabel?: string | null; - user: UserSearchResult; -}) { - return ( -
- -
- ); -} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 0257e52ebf9..76750490cde 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -18,6 +18,7 @@ import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, } from "@/features/agents/lib/managedAgentControlActions"; +import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { @@ -73,6 +74,7 @@ type MembersSidebarMemberCardProps = { onViewActivity?: (pubkey: string) => void; presenceStatus?: PresenceStatus | null; profileAvatarUrl?: string | null; + showOtherSetupMarker?: boolean; viewerIsOwner: boolean; }; @@ -141,6 +143,7 @@ export function MembersSidebarMemberCard({ onViewActivity, presenceStatus, profileAvatarUrl, + showOtherSetupMarker = false, viewerIsOwner, }: MembersSidebarMemberCardProps) { const roleLabel = formatRoleLabel(member, memberIsBot); @@ -177,21 +180,28 @@ export function MembersSidebarMemberCard({
{memberIsBot ? ( -
-
- - {memberLabel} - - -
); diff --git a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx new file mode 100644 index 00000000000..c43d6fa981e --- /dev/null +++ b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; + +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { InlineChip } from "@/shared/ui/InlineChip"; + +/** Visible send-state prefix for recipients kept in the composer address tray. */ +export function MessageAgentAddressPrefix({ + profiles, + pubkeys, +}: { + profiles?: UserProfileLookup; + pubkeys: readonly string[]; +}) { + return ( + <> + {pubkeys.map((pubkey) => { + const profile = profiles?.[pubkey]; + const label = + profile?.displayName?.trim() || + profile?.name?.trim() || + truncatePubkey(pubkey); + return ( + + {/* biome-ignore lint/a11y/useValidAriaRole: UserProfilePopover uses role for agent classification, not as an ARIA attribute. */} + + + {label} + + {" "} + + ); + })} + + ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 31a40c86b67..1adaefd8320 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,13 +1,17 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; -import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; +import { + useChannelLinks, + type ChannelSuggestion, +} from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; -import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; -import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; -import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; +import { + useEmojiAutocomplete, + type EmojiSuggestion, +} from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { findSpoileredImetaMediaUrls, @@ -24,7 +28,15 @@ import { useBackgroundMediaUpload, } from "@/features/messages/lib/backgroundMediaUploadStore"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; +import { + getPersistentAgentAudienceRevision, + getPersistentAgentAudienceScope, + usePersistentAgentAudience, +} from "@/features/messages/lib/persistentAgentAudience"; +import { + setKeepMentionedAgentsPinned, + useKeepMentionedAgentsPinned, +} from "@/features/messages/lib/autoPinMentionedAgentsPreference"; import { useIdentityQuery } from "@/shared/api/hooks"; import { hasMentionClipboardHtml, @@ -45,15 +57,16 @@ import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner"; import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; import { EmojiAutocomplete } from "./EmojiAutocomplete"; -import { - MentionAutocomplete, - type MentionSuggestion, -} from "./MentionAutocomplete"; +import { MentionAutocomplete } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; -import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; +import { useAgentAddressLockPicker } from "./useAgentAddressLockPicker"; +import { useAddressMentionPulse } from "./useAddressMentionPulse"; +import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut"; +import { useComposerMentionPicker } from "./useComposerMentionPicker"; +import { useAutoPinMentionedAgents } from "./useAutoPinMentionedAgents"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; @@ -109,6 +122,8 @@ function MessageComposerImpl({ } = useComposerLinkPreviews(previewContent, editTarget == null); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); + const [mentionOptionsOpenRequest, setMentionOptionsOpenRequest] = + React.useState(0); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< Set >(() => new Set()); @@ -122,13 +137,11 @@ function MessageComposerImpl({ const identityQuery = useIdentityQuery(); const effectiveDraftKey = draftKey ?? channelId; const ownerPubkey = identityQuery.data?.pubkey ?? null; - const audienceThreadRootId = audienceContext?.threadRootId ?? null; const audienceScope = - audienceThreadRootId && channelId && ownerPubkey + audienceContext && channelId && ownerPubkey ? getPersistentAgentAudienceScope({ ownerPubkey, channelId, - threadRootId: audienceThreadRootId, }) : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); @@ -199,7 +212,6 @@ function MessageComposerImpl({ const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); - // Sync lock: taken before any async send so rapid Enter can't double-submit. const isSubmitLockedRef = React.useRef(false); const onSendRef = React.useRef(onSend); const onEditSaveRef = React.useRef(onEditSave); @@ -223,9 +235,6 @@ function MessageComposerImpl({ emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); - // Set after `useLinkEditor` exists below; the editor's link-click handler - // delegates through this ref to break the hook ordering cycle (the editor - // needs `onEditLink`, but the link editor needs the editor's `richText`). const onEditLinkRef = React.useRef< ((info: LinkSelectionInfo) => void) | null >(null); @@ -256,8 +265,6 @@ function MessageComposerImpl({ customEmoji, onSubmit: () => submitMessageRef.current(), onEditLastOwnMessage: () => { - // Never re-enter edit from an empty edit (e.g. image-only edit whose - // text body is empty) — `editTarget` means we're already editing. if (editTargetRef.current) return false; const handler = onEditLastOwnMessageRef.current; return handler ? handler() : false; @@ -272,7 +279,6 @@ function MessageComposerImpl({ mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); - persistentMentionHydrationRef.current?.reconcile(text); if (text.trim().length > 0) { notifyTyping(); } @@ -288,19 +294,17 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); - const persistentMentionHydration = usePersistentAgentMentionHydration({ + const persistentAudience = usePersistentAgentAudience(audienceScope); + const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); + const addressPulse = useAddressMentionPulse(); + const openMentionOptionsRef = React.useRef<() => void>(() => {}); + const addInlineAgentMentionsToAudience = useAutoPinMentionedAgents({ audienceScope, - hydrationKey: effectiveDraftKey, - initialAgentPubkeys: audienceContext?.initialAgentPubkeys, - isEditing: editTarget != null, - mentions, - richText, + enabled: keepMentionedAgentsPinned, + getDisplayName: mentions.getMentionDisplayName, + onOpenOptions: () => openMentionOptionsRef.current(), + onPulse: addressPulse.pulseOne, }); - const persistentAudience = persistentMentionHydration.audience; - const persistentMentionHydrationRef = React.useRef( - persistentMentionHydration, - ); - persistentMentionHydrationRef.current = persistentMentionHydration; const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -310,6 +314,9 @@ function MessageComposerImpl({ drafts, emojiAutocomplete, mentions, + onAddressedAgentsSendStarted: addressPulse.pulseMany, + onAddressedAgentsSendFailed: addressPulse.shakeMany, + onInlineAgentMentionsSent: addInlineAgentMentionsToAudience, onPrepareSendChannel, onSendRef, richText, @@ -322,18 +329,6 @@ function MessageComposerImpl({ clearQueuedAttachments: media.clearQueuedAttachments, restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, - onSuccessfulExplicitAgentAudience: - persistentAudience.enabled && audienceContext && ownerPubkey - ? ({ channelId: successfulChannelId, ...promotion }) => { - const scope = getPersistentAgentAudienceScope({ - ownerPubkey, - channelId: successfulChannelId, - threadRootId: audienceThreadRootId, - }); - persistentAudience.promotePubkeys({ ...promotion, scope }); - } - : undefined, - resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, }); React.useEffect(() => { onDeferredEditPendingChange?.(isDeferredEditPending); @@ -349,9 +344,6 @@ function MessageComposerImpl({ queuedAttachments: [...media.queuedAttachmentsRef.current], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), }; - // Strip the trailing `![image|video](url)` lines that correspond to - // imeta attachments — the user manages those via the attachments row, - // not via raw markdown in the editor. const editableImeta = restoreImetaMediaDisplayLabels( editTarget.body, editTarget.imetaMedia ?? [], @@ -359,19 +351,12 @@ function MessageComposerImpl({ const editableBody = stripImetaMediaLines(editTarget.body, editableImeta); setComposerContent(editableBody); richText.setContent(editableBody); - // Seed pending imeta with removable originals before saving the edit. - // New attachments can then be added through the same row. mentions.restoreDraftMentionRefs(editTarget.mentionRefs ?? []); media.setPendingImeta(editableImeta); media.clearQueuedAttachments(); setSpoileredAttachmentUrls( findSpoileredImetaMediaUrls(editTarget.body, editableImeta), ); - // Defer focus to the next frame so it runs after any focus- - // restoration the trigger UI (e.g. the message-row context menu) - // fires on close. Without this, Radix-style focus-restoration races - // our call and leaves DOM focus on the message row — global keybinds - // like Delete then fire there instead of in the editor. `focusEnd` // also lands the caret at end of the loaded content. const rafId = requestAnimationFrame(() => richText.focusEnd()); return () => cancelAnimationFrame(rafId); @@ -399,9 +384,7 @@ function MessageComposerImpl({ if (!replyTarget || composerDisabled) return; richText.focusPreserve(); }, [composerDisabled, replyTarget, richText.focusPreserve]); - // ── Autofocus on mount / channel switch ───────────────────────────── useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); - // ── Mention / channel / emoji autocomplete insertion ──────────────── // Hooks return a plain-text edit descriptor; `replacePlainTextRange` // applies it as a single ProseMirror transaction (no markdown round-trip). const applyAutocompleteEdit = React.useCallback( @@ -415,17 +398,22 @@ function MessageComposerImpl({ }, [richText.replacePlainTextRange], ); - const applyMentionInsert = React.useCallback( - (suggestion: MentionSuggestion) => { - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - }, - [ - applyAutocompleteEdit, - mentions.insertMention, - richText.getPlainTextAndCursor, - ], - ); + const { + announcement: addressLockAnnouncement, + lockedAgents, + lockedAgentPubkeys, + removeAddressedAgent, + selectMentionSuggestion, + toggleAlwaysAddressAgent, + } = useAgentAddressLockPicker({ + applyAutocompleteEdit, + audience: persistentAudience, + audienceScope, + mentions, + onPulseAddressLock: addressPulse.pulseOne, + profiles, + richText, + }); const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -484,32 +472,26 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - const openMentionPicker = React.useCallback(() => { - if (!richText.editor) return; - const { text, cursor } = richText.getPlainTextAndCursor(); - // Check if there's already an @-query in progress - const beforeCursor = text.slice(0, cursor); - if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) { - mentions.updateMentionQuery(text, cursor); - richText.focus(); - return; - } - // Insert @ at cursor - const previousChar = text.slice(0, cursor).slice(-1); - const prefix = - cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@"; - richText.editor.chain().focus().insertContent(prefix).run(); - setIsEmojiPickerOpen(false); - // Trigger mention detection after inserting @ - const { text: updatedText, cursor: updatedCursor } = - richText.getPlainTextAndCursor(); - mentions.updateMentionQuery(updatedText, updatedCursor); - }, [ - richText.editor, - richText.getPlainTextAndCursor, - richText.focus, - mentions.updateMentionQuery, - ]); + const openMentionPicker = useComposerMentionPicker({ + mentions, + richText, + setIsEmojiPickerOpen, + }); + const openMentionSettings = React.useCallback( + () => openMentionPicker(false), + [openMentionPicker], + ); + const openMentionOptions = React.useCallback(() => { + openMentionSettings(); + setMentionOptionsOpenRequest((request) => request + 1); + }, [openMentionSettings]); + openMentionOptionsRef.current = openMentionOptions; + const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({ + enabled: Boolean(audienceScope && editTarget == null), + mentions, + onOpenPicker: openMentionPicker, + onToggle: toggleAlwaysAddressAgent, + }); const submitMessage = React.useCallback(async () => { const trimmed = syncComposerContentFromEditor().trim(); // Edit mode @@ -580,7 +562,6 @@ function MessageComposerImpl({ } isSubmitLockedRef.current = true; onPreparingMentionSendChange?.(true); - persistentMentionHydration.beginSubmit(); try { const preparedLinkPreviews = getReadyLinkPreviewTags().some( (tag) => tag[1] === "none", @@ -588,6 +569,10 @@ function MessageComposerImpl({ ? null : prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates()); await mentionSendFlow.sendMessageWithMentionFlow({ + addressedAgentPubkeys: persistentAudience.pubkeys, + audienceRevision: audienceScope + ? getPersistentAgentAudienceRevision(audienceScope) + : 0, capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, @@ -601,12 +586,9 @@ function MessageComposerImpl({ recoveryDraftKey: effectiveDraftKey, spoileredAttachmentUrls, trimmed, - audienceGeneration: persistentAudience.generation, - audienceRevision: audienceScope ? persistentAudience.revision : null, }); } finally { isSubmitLockedRef.current = false; - persistentMentionHydration.endSubmit(); onPreparingMentionSendChange?.(false); } }, [ @@ -634,9 +616,7 @@ function MessageComposerImpl({ onCaptureSendContext, onPreparingMentionSendChange, audienceScope, - persistentMentionHydration, - persistentAudience.generation, - persistentAudience.revision, + persistentAudience.pubkeys, isEditSubmissionLocked, effectiveDraftKey, mentions.getDraftMentionRefs, @@ -677,6 +657,7 @@ function MessageComposerImpl({ // handles autocomplete arrow/enter keys and Escape for edit mode. const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { + if (handleAlwaysAddressShortcut(event)) return; // Let autocomplete handle keys first const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); if (emojiResult.handled) { @@ -695,7 +676,7 @@ function MessageComposerImpl({ const { handled, suggestion } = mentions.handleMentionKeyDown(event); if (handled) { if (suggestion) { - applyMentionInsert(suggestion); + selectMentionSuggestion(suggestion); } return; } @@ -719,12 +700,13 @@ function MessageComposerImpl({ } }, [ + handleAlwaysAddressShortcut, emojiAutocomplete.handleEmojiKeyDown, applyEmojiInsert, channelLinks.handleChannelKeyDown, applyChannelInsert, mentions.handleMentionKeyDown, - applyMentionInsert, + selectMentionSuggestion, linkEditor.isCardOpen, linkEditor.focusCardFirstControl, isDeferredEditPending, @@ -808,11 +790,9 @@ function MessageComposerImpl({ media.pendingImeta.length === 0 && media.queuedAttachments.length === 0); const handleCaptureSelection = React.useCallback(() => {}, []); - const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); }, [media.handlePaperclip]); - const handleRemoveAttachment = React.useCallback( (url: string) => { setSpoileredAttachmentUrls((current) => { @@ -825,14 +805,12 @@ function MessageComposerImpl({ }, [media.removeAttachment], ); - const { handleAttachmentEditSave, handleAttachmentRevert } = useAttachmentEditing({ revertAttachment: media.revertAttachment, setSpoileredAttachmentUrls, uploadEditedAttachment: media.uploadEditedAttachment, }); - const handleToggleAttachmentSpoiler = React.useCallback((url: string) => { setSpoileredAttachmentUrls((current) => { const next = new Set(current); @@ -844,7 +822,6 @@ function MessageComposerImpl({ return next; }); }, []); - return ( <>
@@ -937,31 +928,40 @@ function MessageComposerImpl({
) : null} - {composerLinkPreviews} + + {addressLockAnnouncement} + {(media.pendingImeta.length > 0 || media.queuedAttachments.length > 0 || media.isUploading) && ( -
- +
+ {media.pendingImeta.length > 0 || + media.queuedAttachments.length > 0 || + media.isUploading ? ( + + ) : null}
)} - {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
-
- - {linkEditor.card} {linkEditor.dialog} ); } - export const MessageComposer = React.memo(MessageComposerImpl); diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index a24be0aeab0..517b9afb00e 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -24,9 +24,7 @@ export type MessageComposerEditTarget = { export type MessageComposerProps = { audienceContext?: { - type: "thread"; - threadRootId: string; - initialAgentPubkeys?: readonly string[]; + type: "channel" | "thread"; } | null; channelId?: string | null; channelName: string; diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index efb0d3b3b99..d4e2284b85f 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -1,10 +1,15 @@ import * as React from "react"; import type { Editor } from "@tiptap/react"; import { AnimatePresence, motion } from "motion/react"; -import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react"; +import { ALargeSmall, Paperclip, X } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { + type ComposerAddressAgent, + ComposerMentionButton, + ComposerSendButton, +} from "./ComposerAddressControls"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; import { FormattingToolbar } from "./FormattingToolbar"; import { SelectionFormattingTray } from "./SelectionFormattingTray"; @@ -15,9 +20,12 @@ const presenceSpring = { stiffness: 400, damping: 28, } as const; +const NO_ADDRESSED_AGENTS: readonly ComposerAddressAgent[] = []; +const ignoreAddressRemoval = () => {}; export const MessageComposerToolbar = React.memo( function MessageComposerToolbar({ + addressedAgents = NO_ADDRESSED_AGENTS, composerDisabled, editor, extraActions, @@ -33,8 +41,12 @@ export const MessageComposerToolbar = React.memo( onLinkButton, onOpenMentionPicker, onPaperclip, + onRemoveAddressedAgent = ignoreAddressRemoval, + pulseVersionByPubkey, sendDisabled, + shakeVersionByPubkey, }: { + addressedAgents?: readonly ComposerAddressAgent[]; composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; @@ -50,7 +62,10 @@ export const MessageComposerToolbar = React.memo( onLinkButton: () => void; onOpenMentionPicker: () => void; onPaperclip: () => void; + onRemoveAddressedAgent?: (pubkey: string) => void; + pulseVersionByPubkey?: Readonly>; sendDisabled: boolean; + shakeVersionByPubkey?: Readonly>; }) { return (
@@ -158,24 +173,16 @@ export const MessageComposerToolbar = React.memo( exit={{ opacity: 0, x: -12 }} transition={presenceSpring} > - {/* disableHoverableContent keeps tooltips from lingering over the editor. */} - - - - - Mention someone - + +
); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index fd5be7d9a86..536631b02d3 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -58,6 +58,8 @@ import { MessageTimestamp } from "./MessageTimestamp"; import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs"; +import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -277,6 +279,18 @@ export const MessageRow = React.memo( return Object.keys(values).length > 0 ? values : undefined; }, [isKnownAgentPubkey, mentionPubkeysByName]); + const addressedAgentPubkeys = React.useMemo(() => { + return getAgentAddressMentionPubkeys(message.tags).filter( + isKnownAgentPubkey, + ); + }, [isKnownAgentPubkey, message.tags]); + const agentAddressPrefix = + addressedAgentPubkeys.length > 0 ? ( + + ) : undefined; const imetaByUrl = React.useMemo( () => (message.tags ? parseImetaTags(message.tags) : undefined), @@ -427,6 +441,7 @@ export const MessageRow = React.memo( messageId={message.id} linkPreviewsSuppressed={linkPreviewsSuppressed} linkPreviewTags={message.tags} + leadingInlineContent={agentAddressPrefix} onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone} customEmoji={customEmoji} imetaByUrl={imetaByUrl} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 517065cc03d..d2650c84ac4 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -1,11 +1,7 @@ import * as React from "react"; import { ArrowDown } from "lucide-react"; -import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleTranscriptIntro } from "@/features/huddle/components/HuddleTranscriptIntro"; -import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; -import { normalizePubkey } from "@/shared/lib/pubkey"; -import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { buildThreadSummaryFromVisibleEntries, hasNestedThreadBranches, @@ -534,29 +530,6 @@ export function MessageThreadPanel({ "padding", settleAtBottomAfterLayout, ); - const knownAgentPubkeys = useKnownAgentPubkeys(); - const initialAgentPubkeys = React.useMemo(() => { - if ( - !threadHead || - !currentPubkey || - normalizePubkey(threadHead.signerPubkey ?? threadHead.pubkey ?? "") !== - normalizePubkey(currentPubkey) - ) { - return []; - } - const { mentionPubkeysByName } = resolveMentionProps( - threadHead.tags, - profiles, - ); - if (!mentionPubkeysByName) return []; - - return orderMentionPubkeysByText( - threadHead.body, - mentionPubkeysByName, - (pubkey) => - knownAgentPubkeys.has(pubkey) || profiles?.[pubkey]?.isAgent === true, - ); - }, [currentPubkey, knownAgentPubkeys, profiles, threadHead]); const stableSendToChannel = useStableSendToChannel( channelId, threadHead, @@ -893,11 +866,7 @@ export function MessageThreadPanel({ > Promise; profiles?: UserProfileLookup; + renderAfterMessage?: (message: TimelineMessage) => React.ReactNode; testId?: string; }; @@ -36,6 +37,7 @@ export function MessageThreadTranscript({ messages, onToggleReaction, profiles, + renderAfterMessage, testId = "message-thread-transcript", }: MessageThreadTranscriptProps) { const renderItems = React.useMemo(() => { @@ -59,16 +61,18 @@ export function MessageThreadTranscript({ data-testid={testId} > {renderItems.map(({ isContinuation, message }) => ( - + + + {renderAfterMessage?.(message)} + ))} ); diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs new file mode 100644 index 00000000000..e98763a4a51 --- /dev/null +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("agent picker preference skips people", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useMentionSelection } = await import( + "@/features/messages/lib/useMentionSelection" + ); + const view = renderHook( + ({ suggestions }) => useMentionSelection(suggestions), + { initialProps: { suggestions: [] } }, + ); + const suggestions = [ + { displayName: "Alice", pubkey: "person" }, + { displayName: "Agent Ada", isAgent: true, pubkey: "agent-a" }, + { displayName: "Bob", pubkey: "person-b" }, + { displayName: "Agent Bea", isAgent: true, pubkey: "agent-b" }, + ]; + + act(() => view.result.current.prepareSelectionPreference("first-agent")); + view.rerender({ suggestions }); + assert.equal(view.result.current.mentionSelectedIndex, 1); +}); + +test("primary+Shift+Enter opens the picker or toggles in place", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + const opened = []; + const toggled = []; + const suggestion = { + displayName: "Agent Ada", + isAgent: true, + pubkey: "agent-a", + }; + const createEvent = () => ({ + altKey: false, + ctrlKey: !isMacPlatform(), + key: "Enter", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }); + const view = renderHook( + ({ isMentionOpen }) => + useAlwaysAddressShortcut({ + enabled: true, + mentions: { + isMentionOpen, + mentionSelectedIndex: 0, + suggestions: [suggestion], + }, + onOpenPicker: (insertTrigger) => opened.push(insertTrigger), + onToggle: (value) => toggled.push(value), + }), + { initialProps: { isMentionOpen: false } }, + ); + + act(() => assert.equal(view.result.current(createEvent()), true)); + assert.deepEqual(opened, [false]); + assert.deepEqual(toggled, []); + + view.rerender({ isMentionOpen: true }); + act(() => assert.equal(view.result.current(createEvent()), true)); + assert.deepEqual(toggled, [suggestion]); + + act(() => assert.equal(view.result.current(createEvent()), true)); + assert.deepEqual(toggled, [suggestion, suggestion]); +}); diff --git a/desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs b/desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs index d6200f253e1..26f666491c8 100644 --- a/desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs +++ b/desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs @@ -16,16 +16,12 @@ test("supported conversation hosts opt into explicit audience contexts", async ( ], ); - assert.doesNotMatch(channelPane, /audienceContext=/); + assert.match(channelPane, /audienceContext=\{\{ type: "channel" \}\}/); assert.doesNotMatch(newMessage, /audienceContext=/); - assert.match( - threadPanel, - /type: "thread"[\s\S]*threadRootId: threadHead\.id/, - ); - assert.match( - inboxDetail, - /type: "thread"[\s\S]*threadRootId: item\.conversationId/, - ); + assert.match(threadPanel, /audienceContext=\{\{ type: "thread" \}\}/); + assert.match(inboxDetail, /type: "thread"/); + assert.doesNotMatch(threadPanel, /audienceContext=\{[\s\S]*threadRootId/); + assert.doesNotMatch(inboxDetail, /audienceContext=\{[\s\S]*threadRootId/); }); test("video review remains explicitly outside persistent audience routing", async () => { @@ -43,5 +39,5 @@ test("composer never derives audience context from draft keys", async () => { const composer = await source("./MessageComposer.tsx"); assert.doesNotMatch(composer, /draftKey\?\.startsWith\("thread:"\)/); - assert.match(composer, /audienceContext\?\.threadRootId/); + assert.match(composer, /audienceContext && channelId && ownerPubkey/); }); diff --git a/desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs b/desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs new file mode 100644 index 00000000000..e4eea41ef54 --- /dev/null +++ b/desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("pulse versions restart per addressed agent", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAddressMentionPulse } = await import( + "./useAddressMentionPulse.ts" + ); + const { result } = renderHook(() => useAddressMentionPulse()); + + act(() => result.current.pulseMany(["AGENT-A", "agent-a", "agent-b"])); + assert.deepEqual(result.current.pulseVersionByPubkey, { + "agent-a": 1, + "agent-b": 1, + }); + + act(() => result.current.pulseOne("agent-a")); + assert.deepEqual(result.current.pulseVersionByPubkey, { + "agent-a": 2, + "agent-b": 1, + }); + + act(() => result.current.shakeMany(["AGENT-A", "agent-a", "agent-b"])); + assert.deepEqual(result.current.shakeVersionByPubkey, { + "agent-a": 1, + "agent-b": 1, + }); +}); diff --git a/desktop/src/features/messages/ui/useAddressMentionPulse.ts b/desktop/src/features/messages/ui/useAddressMentionPulse.ts new file mode 100644 index 00000000000..55c157b979b --- /dev/null +++ b/desktop/src/features/messages/ui/useAddressMentionPulse.ts @@ -0,0 +1,44 @@ +import * as React from "react"; + +export function useAddressMentionPulse() { + const [pulseVersionByPubkey, setPulseVersionByPubkey] = React.useState< + Record + >({}); + const [shakeVersionByPubkey, setShakeVersionByPubkey] = React.useState< + Record + >({}); + const pulseMany = React.useCallback((pubkeys: readonly string[]) => { + setPulseVersionByPubkey((current) => { + const next = { ...current }; + for (const pubkey of new Set( + pubkeys.map((value) => value.toLowerCase()), + )) { + next[pubkey] = (next[pubkey] ?? 0) + 1; + } + return next; + }); + }, []); + const pulseOne = React.useCallback( + (pubkey: string) => pulseMany([pubkey]), + [pulseMany], + ); + const shakeMany = React.useCallback((pubkeys: readonly string[]) => { + setShakeVersionByPubkey((current) => { + const next = { ...current }; + for (const pubkey of new Set( + pubkeys.map((value) => value.toLowerCase()), + )) { + next[pubkey] = (next[pubkey] ?? 0) + 1; + } + return next; + }); + }, []); + + return { + pulseMany, + pulseOne, + pulseVersionByPubkey, + shakeMany, + shakeVersionByPubkey, + }; +} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs new file mode 100644 index 00000000000..4acd89cf0a5 --- /dev/null +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -0,0 +1,406 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("always addressing an agent keeps autocomplete open, adds the lock, and pulses", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + let cancelCount = 0; + const text = "Ask @Agent Ada later @"; + const mentions = { + cancelMentionAutocomplete: () => { + cancelCount += 1; + }, + getDraftMentionRefs: () => [ + { + displayName: "Agent Ada", + pubkey: "agent-pubkey", + isAgent: true, + }, + ], + getMentionDisplayName: () => "Agent Ada", + mentionStartIndex: text.lastIndexOf("@"), + }; + const audience = { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }; + const richText = { + getPlainTextAndCursor: () => ({ text, cursor: text.length }), + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + act(() => { + result.current.toggleAlwaysAddressAgent({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); + + assert.deepEqual(appliedEdits, []); + assert.equal(cancelCount, 0); + assert.deepEqual(addedPubkeys, ["agent-pubkey"]); + assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); + assert.equal( + result.current.announcement, + "Automatically mentioning Agent Ada", + ); +}); + +test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const removedPubkeys = []; + const pulsedPubkeys = []; + let cancelCount = 0; + const text = "Ask @Agent Ada later @"; + const mentions = { + cancelMentionAutocomplete: () => { + cancelCount += 1; + }, + getDraftMentionRefs: () => [ + { + displayName: "Agent Ada", + pubkey: "agent-pubkey", + isAgent: true, + }, + ], + getMentionDisplayName: () => "Agent Ada", + mentionStartIndex: text.lastIndexOf("@"), + }; + const audience = { + pubkeys: ["agent-pubkey"], + addPubkey: () => { + throw new Error("an addressed agent must not be added again"); + }, + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }; + const richText = { + getPlainTextAndCursor: () => ({ text, cursor: text.length }), + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + act(() => { + result.current.toggleAlwaysAddressAgent({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); + + assert.deepEqual(appliedEdits, []); + assert.equal(cancelCount, 0); + assert.deepEqual(removedPubkeys, ["agent-pubkey"]); + assert.deepEqual(pulsedPubkeys, []); + assert.equal( + result.current.announcement, + "Stopped automatically mentioning Agent Ada", + ); +}); + +test("selecting an already addressed agent from the explicit picker pulses its badge", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const mentions = { + cancelMentionAutocomplete: () => {}, + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + insertMention: () => { + throw new Error("an already addressed agent must not be inserted"); + }, + mentionStartIndex: 5, + }; + const audience = { + pubkeys: ["agent-pubkey"], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }; + const richText = { + getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + act(() => { + result.current.selectMentionSuggestion({ + pubkey: "AGENT-PUBKEY", + displayName: "Agent Ada", + isAgent: true, + }); + }); + + assert.deepEqual(appliedEdits, []); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); +}); + +test("selecting an agent from a typed query leaves the inline mention for send", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const mentions = { + cancelMentionAutocomplete: () => {}, + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => true, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 6, + insertText: "@Agent Ada ", + }), + mentionStartIndex: 5, + }; + const audience = { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }; + const richText = { + // Selection intent comes from the mention picker, even if focus movement + // makes the editor text/cursor insufficient to re-detect the typed query. + getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + act(() => { + result.current.selectMentionSuggestion({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 6, + insertText: "@Agent Ada ", + }, + ]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); + assert.equal(result.current.announcement, ""); +}); + +test("selecting an agent from the explicit picker auto-addresses it", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const mentions = { + cancelMentionAutocomplete: () => {}, + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + insertMention: () => { + throw new Error("explicit picker selections must become addressing"); + }, + mentionStartIndex: 5, + }; + const audience = { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }; + const richText = { + getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + act(() => { + result.current.selectMentionSuggestion({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); + + assert.deepEqual(appliedEdits, []); + assert.deepEqual(addedPubkeys, ["agent-pubkey"]); + assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); + assert.equal( + result.current.announcement, + "Automatically mentioning Agent Ada", + ); +}); + +test("selecting an explicitly unpinned agent inserts a mention until send", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const removedPubkeys = []; + const pulsedPubkeys = []; + const mentions = { + cancelMentionAutocomplete: () => {}, + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + insertMention: () => ({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + }), + mentionStartIndex: 0, + }; + const richText = { + getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + }; + const { result, rerender } = renderHook( + ({ pubkeys }) => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys, + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions, + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + { initialProps: { pubkeys: ["agent-pubkey"] } }, + ); + + act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); + rerender({ pubkeys: [] }); + act(() => { + result.current.selectMentionSuggestion({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); + + assert.deepEqual(removedPubkeys, ["agent-pubkey"]); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + }, + ]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); +}); + +test("an addressed agent keeps its resolved name while mention state clears during send", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + let displayName = "Agent Ada"; + const mentions = { + getMentionDisplayName: () => displayName, + }; + const audience = { + pubkeys: ["agent-pubkey"], + }; + const { result, rerender } = renderHook( + ({ profiles }) => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience, + audienceScope: "channel-scope", + mentions, + onPulseAddressLock: () => {}, + profiles, + richText: {}, + }), + { initialProps: { profiles: {} } }, + ); + + assert.equal(result.current.lockedAgents[0].displayName, "Agent Ada"); + + displayName = null; + rerender({ profiles: {} }); + + assert.equal(result.current.lockedAgents[0].displayName, "Agent Ada"); +}); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts new file mode 100644 index 00000000000..8d5a87b9b8f --- /dev/null +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -0,0 +1,248 @@ +import * as React from "react"; + +import { getMentionOffsets } from "@/features/messages/lib/hasMention"; +import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { + AutocompleteEdit, + UseRichTextEditorResult, +} from "@/features/messages/lib/useRichTextEditor"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import type { ComposerAddressAgent } from "./ComposerAddressControls"; +import type { MentionSuggestion } from "./MentionAutocomplete"; + +function buildMentionRemovalEdits( + text: string, + displayNames: readonly string[], + queryStart: number, + cursor: number, +): AutocompleteEdit[] { + const ranges = displayNames.flatMap((displayName) => + getMentionOffsets(text, displayName).map((start) => { + let end = start + `@${displayName}`.length; + if (text[end] === " ") end += 1; + return { start, end }; + }), + ); + ranges.push({ + start: Math.max(0, Math.min(queryStart, text.length)), + end: Math.max(0, Math.min(cursor, text.length)), + }); + + const merged = ranges + .filter(({ start, end }) => start < end) + .sort((left, right) => left.start - right.start) + .reduce>((result, range) => { + const previous = result.at(-1); + if (previous && range.start <= previous.end) { + previous.end = Math.max(previous.end, range.end); + } else { + result.push({ ...range }); + } + return result; + }, []); + + return merged.reverse().map(({ start, end }) => ({ + replaceFromOffset: start, + replaceToOffset: end, + insertText: "", + })); +} + +export function useAgentAddressLockPicker({ + applyAutocompleteEdit, + audience, + audienceScope, + mentions, + onPulseAddressLock, + profiles, + richText, +}: { + applyAutocompleteEdit: (edit: AutocompleteEdit) => void; + audience: ReturnType; + audienceScope: string | null; + mentions: UseMentionsResult; + onPulseAddressLock: (pubkey: string) => void; + profiles?: UserProfileLookup; + richText: UseRichTextEditorResult; +}) { + const lockedAgentPubkeys = React.useMemo( + () => new Set(audience.pubkeys), + [audience.pubkeys], + ); + const unpinnedAgentPubkeysRef = React.useRef(new Set()); + const unpinnedAudienceScopeRef = React.useRef(audienceScope); + if (unpinnedAudienceScopeRef.current !== audienceScope) { + unpinnedAudienceScopeRef.current = audienceScope; + unpinnedAgentPubkeysRef.current.clear(); + } + const lockedAgentNamesRef = React.useRef(new Map()); + const [announcement, setAnnouncement] = React.useState(""); + const lockedAgents = React.useMemo( + () => + audience.pubkeys.map((pubkey) => { + const normalized = normalizePubkey(pubkey); + const profile = profiles?.[normalized]; + const resolvedDisplayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + profile?.nip05Handle?.trim() || + mentions.getMentionDisplayName(normalized)?.trim(); + if (resolvedDisplayName) { + lockedAgentNamesRef.current.set(normalized, resolvedDisplayName); + } + return { + pubkey: normalized, + displayName: + resolvedDisplayName ?? + lockedAgentNamesRef.current.get(normalized) ?? + truncatePubkey(normalized), + avatarUrl: profile?.avatarUrl ?? null, + }; + }), + [audience.pubkeys, mentions.getMentionDisplayName, profiles], + ); + const consumeAddressSuggestion = React.useCallback( + ( + suggestion: MentionSuggestion, + { removeInlineMentions }: { removeInlineMentions: boolean }, + ): string | null => { + const pubkey = normalizePubkey(suggestion.pubkey ?? ""); + if (!audienceScope || !pubkey || !suggestion.isAgent) return null; + + const { text, cursor } = richText.getPlainTextAndCursor(); + const matchingDisplayNames = removeInlineMentions + ? mentions + .getDraftMentionRefs(text) + .filter((ref) => normalizePubkey(ref.pubkey) === pubkey) + .map((ref) => ref.displayName) + : []; + mentions.cancelMentionAutocomplete(); + for (const edit of buildMentionRemovalEdits( + text, + matchingDisplayNames, + mentions.mentionStartIndex, + cursor, + )) { + applyAutocompleteEdit(edit); + } + return pubkey; + }, + [ + applyAutocompleteEdit, + audienceScope, + mentions.cancelMentionAutocomplete, + mentions.getDraftMentionRefs, + mentions.mentionStartIndex, + richText.getPlainTextAndCursor, + ], + ); + const removeAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + unpinnedAgentPubkeysRef.current.add(normalized); + audience.removePubkey(normalized); + }, + [audience.removePubkey, audienceScope], + ); + const toggleAlwaysAddressAgent = React.useCallback( + (suggestion: MentionSuggestion) => { + const pubkey = normalizePubkey(suggestion.pubkey ?? ""); + if (!audienceScope || !pubkey || !suggestion.isAgent) return; + + if (lockedAgentPubkeys.has(pubkey)) { + removeAddressedAgent(pubkey); + setAnnouncement( + `Stopped automatically mentioning ${suggestion.displayName}`, + ); + } else { + unpinnedAgentPubkeysRef.current.delete(pubkey); + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + } + + if (mentions.isMentionOpen) { + const { text, cursor } = richText.getPlainTextAndCursor(); + const activeMention = detectPrefixQuery("@", text, cursor, [ + suggestion.displayName.toLowerCase(), + ]); + const queryStart = Math.max( + 0, + Math.min( + activeMention?.startIndex ?? mentions.mentionStartIndex, + text.length, + ), + ); + applyAutocompleteEdit({ + replaceFromOffset: queryStart, + replaceToOffset: Math.max(queryStart, Math.min(cursor, text.length)), + insertText: "", + }); + mentions.openMentionPicker(queryStart, "preserve"); + } + }, + [ + applyAutocompleteEdit, + audience.addPubkey, + audienceScope, + lockedAgentPubkeys, + mentions.isMentionOpen, + mentions.mentionStartIndex, + mentions.openMentionPicker, + onPulseAddressLock, + removeAddressedAgent, + richText.getPlainTextAndCursor, + ], + ); + + const selectMentionSuggestion = React.useCallback( + (suggestion: MentionSuggestion) => { + const pubkey = normalizePubkey(suggestion.pubkey ?? ""); + if (suggestion.isAgent && pubkey && audienceScope) { + const { cursor } = richText.getPlainTextAndCursor(); + const wasUnpinned = + !lockedAgentPubkeys.has(pubkey) && + unpinnedAgentPubkeysRef.current.has(pubkey); + if (mentions.isInlineMentionSelection() || wasUnpinned) { + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + return; + } + + consumeAddressSuggestion(suggestion, { removeInlineMentions: false }); + if (!lockedAgentPubkeys.has(pubkey)) { + audience.addPubkey(pubkey); + setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + } + onPulseAddressLock(pubkey); + return; + } + + const { cursor } = richText.getPlainTextAndCursor(); + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + }, + [ + applyAutocompleteEdit, + audience.addPubkey, + audienceScope, + consumeAddressSuggestion, + lockedAgentPubkeys, + mentions.isInlineMentionSelection, + mentions.insertMention, + onPulseAddressLock, + richText.getPlainTextAndCursor, + ], + ); + + return { + announcement, + lockedAgents, + lockedAgentPubkeys, + removeAddressedAgent, + selectMentionSuggestion, + toggleAlwaysAddressAgent, + }; +} diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts new file mode 100644 index 00000000000..8fd253df222 --- /dev/null +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -0,0 +1,52 @@ +import * as React from "react"; + +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; +import type { MentionSuggestion } from "./MentionAutocomplete"; + +export function useAlwaysAddressShortcut({ + enabled, + mentions, + onOpenPicker, + onToggle, +}: { + enabled: boolean; + mentions: UseMentionsResult; + onOpenPicker: (insertTrigger?: boolean) => void; + onToggle: (suggestion: MentionSuggestion) => void; +}) { + const { isMentionOpen, mentionSelectedIndex, suggestions } = mentions; + return React.useCallback( + (event: React.KeyboardEvent): boolean => { + if ( + !enabled || + event.key !== "Enter" || + !hasPrimaryShortcutModifier(event) || + event.altKey || + !event.shiftKey + ) { + return false; + } + + event.preventDefault(); + if (event.repeat) return true; + if (!isMentionOpen) { + onOpenPicker(false); + return true; + } + + const suggestion = suggestions[mentionSelectedIndex]; + if (!suggestion?.isAgent || !suggestion.pubkey) return true; + onToggle(suggestion); + return true; + }, + [ + enabled, + isMentionOpen, + mentionSelectedIndex, + onOpenPicker, + onToggle, + suggestions, + ], + ); +} diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts new file mode 100644 index 00000000000..44b87c9f1d0 --- /dev/null +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -0,0 +1,74 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + promotePersistentAgentAudienceIfUnchanged, + removePersistentAgentAudienceMembersIfUnchanged, +} from "@/features/messages/lib/persistentAgentAudience"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type Options = { + audienceScope: string | null; + enabled: boolean; + getDisplayName: (pubkey: string) => string | null | undefined; + onOpenOptions: () => void; + onPulse: (pubkey: string) => void; +}; + +export function useAutoPinMentionedAgents({ + audienceScope, + enabled, + getDisplayName, + onOpenOptions, + onPulse, +}: Options) { + return React.useCallback( + ({ + expectedRevision, + pubkeys, + }: { + expectedRevision: number; + pubkeys: readonly string[]; + }) => { + if (!audienceScope || !enabled) return; + const normalizedPubkeys = [ + ...new Set(pubkeys.map(normalizePubkey)), + ].filter(Boolean); + const promotion = promotePersistentAgentAudienceIfUnchanged({ + expectedRevision, + pubkeys: normalizedPubkeys, + scope: audienceScope, + }); + if (promotion === null) return; + const { promotedPubkeys, revision } = promotion; + for (const pubkey of promotedPubkeys) onPulse(pubkey); + + const displayName = + promotedPubkeys.length === 1 + ? getDisplayName(promotedPubkeys[0])?.trim() + : null; + const title = displayName + ? `${displayName} will be mentioned automatically` + : promotedPubkeys.length === 1 + ? "Agent will be mentioned automatically" + : `${promotedPubkeys.length} agents will be mentioned automatically`; + toast.success(title, { + action: { + label: "Undo", + onClick: () => { + if ( + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: revision, + pubkeys: promotedPubkeys, + scope: audienceScope, + }) + ) { + onOpenOptions(); + } + }, + }, + }); + }, + [audienceScope, enabled, getDisplayName, onOpenOptions, onPulse], + ); +} diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs index 049c57f70c4..9236ee3a6fe 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs @@ -70,18 +70,18 @@ test("composer input versions retain only active hrefs while re-entry advances", nextHrefVersion: 0, }; - input = updateComposerLinkPreviewInput(input, `see ${HREF}`); + input = updateComposerLinkPreviewInput(input, `see ${HREF}`, null); const firstVersion = input.hrefVersions.get(HREF); assert.equal(input.hrefVersions.size, 1); - input = updateComposerLinkPreviewInput(input, `see ${secondHref}`); + input = updateComposerLinkPreviewInput(input, `see ${secondHref}`, null); assert.deepEqual( [...input.hrefVersions.keys()], [secondHref], "departed href history is pruned instead of retained for the composer lifetime", ); - input = updateComposerLinkPreviewInput(input, `see ${HREF}`); + input = updateComposerLinkPreviewInput(input, `see ${HREF}`, null); assert.deepEqual([...input.hrefVersions.keys()], [HREF]); assert.ok( input.hrefVersions.get(HREF) > firstVersion, @@ -159,6 +159,7 @@ test("composer forces a refetch and drops the stale tag on a fast clear+re-paste nextHrefVersion: 0, }, `see ${HREF}`, + null, ); const { result, rerender, unmount } = renderHook( ({ content, hrefVersions }) => @@ -183,10 +184,11 @@ test("composer forces a refetch and drops the stale tag on a fast clear+re-paste // href set equals the previous commit, but the update-boundary version has // advanced because the URL left and re-entered between those updates. await act(async () => { - previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null); previewInput = updateComposerLinkPreviewInput( previewInput, `see ${HREF}`, + null, ); rerender(previewInput); }); @@ -318,6 +320,7 @@ test("a removed blocked re-entry can later use metadata that resolved while abse nextHrefVersion: 0, }, `see ${HREF}`, + null, ); const { result, rerender, unmount } = renderHook( ({ content, hrefVersions }) => @@ -330,10 +333,11 @@ test("a removed blocked re-entry can later use metadata that resolved while abse // Re-enter the cached negative and wait until its forced refetch is in flight. await act(async () => { - previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null); previewInput = updateComposerLinkPreviewInput( previewInput, `see ${HREF}`, + null, ); rerender(previewInput); }); @@ -345,7 +349,7 @@ test("a removed blocked re-entry can later use metadata that resolved while abse // Remove the blocked href, then let its refetch populate healthy metadata // while no candidate is active. await act(async () => { - previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null); rerender(previewInput); }); await settle(); @@ -359,6 +363,7 @@ test("a removed blocked re-entry can later use metadata that resolved while abse previewInput = updateComposerLinkPreviewInput( previewInput, `see ${HREF}`, + null, ); rerender(previewInput); }); @@ -486,6 +491,7 @@ test("a stale in-flight upload cannot publish after the URL re-enters and a fres nextHrefVersion: 0, }, `see ${HREF}`, + null, ); const { result, rerender, unmount } = renderHook( ({ content, hrefVersions }) => @@ -510,10 +516,11 @@ test("a stale in-flight upload cannot publish after the URL re-enters and a fres // 2. Fast gesture: clear then re-paste the SAME URL inside the debounce. await act(async () => { - previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null); previewInput = updateComposerLinkPreviewInput( previewInput, `see ${HREF}`, + null, ); rerender(previewInput); }); @@ -750,3 +757,105 @@ test("an abandoned concurrent render cannot invalidate the committed snapshot ta ipcHandlers.clear(); } }); + +// ── Composer clone-URL classification ──────────────────────────────────────── +// +// A same-relay `/git//` clone URL is a Buzz repository entity: the +// renderer normalizes it onto `buzz://repo` and shows it as an inline chip, not +// a standalone card. The composer must reach the same verdict from the same +// active relay origin — without it the URL is classified as an external +// generic-link, enters snapshot fetching, and shows a card the sent message +// then contradicts. + +const CLONE_OWNER = "a".repeat(64); +const RELAY_ORIGIN = "https://relay.example.com"; +const CLONE_HREF = `${RELAY_ORIGIN}/git/${CLONE_OWNER}/relay-tools.git`; + +test("composer input classifies a same-relay clone URL as a Buzz entity", async () => { + const { updateComposerLinkPreviewInput } = await import( + "./useComposerLinkPreviews.tsx" + ); + const empty = { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }; + + assert.deepEqual( + [ + ...updateComposerLinkPreviewInput( + empty, + `clone ${CLONE_HREF}`, + RELAY_ORIGIN, + ).hrefs, + ], + [], + "a same-relay clone URL is a chip-only entity, never a preview candidate", + ); + // A different origin sharing the path shape stays an ordinary external link. + assert.deepEqual( + [ + ...updateComposerLinkPreviewInput( + empty, + `clone ${CLONE_HREF}`, + "https://evil.example.com", + ).hrefs, + ], + [CLONE_HREF], + ); +}); + +test("composer never fetches a snapshot for a same-relay clone URL", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { getCachedRelayOrigin } = await import("@/shared/lib/mediaUrl.ts"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { useComposerLinkPreviews } = await import( + "./useComposerLinkPreviews.tsx" + ); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + ipcHandlers.set("get_relay_http_url", () => Promise.resolve(RELAY_ORIGIN)); + let fetchCalls = 0; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + return Promise.resolve(metadata()); + }); + + try { + // The origin resolves asynchronously; the classification under test only + // exists once it is known, so wait for the shared cache to publish it. + const deadline = Date.now() + 5000; + while (getCachedRelayOrigin() !== RELAY_ORIGIN && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(getCachedRelayOrigin(), RELAY_ORIGIN); + + const { result, unmount } = renderHook(() => + useComposerLinkPreviews(`clone ${CLONE_HREF}`), + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + assert.equal( + fetchCalls, + 0, + "a Buzz repository entity must not enter external snapshot fetching", + ); + assert.equal(result.current.previewList, null); + assert.deepEqual(result.current.getReadyTags(), []); + assert.deepEqual(result.current.getLiveCandidates(), []); + assert.equal(result.current.hasPendingSnapshots, false); + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 3c845d057f7..9795401f78b 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -12,6 +12,7 @@ import { beginRelayOriginFetch, getCachedRelayOrigin, } from "@/shared/lib/mediaUrl"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { isBuzzEntityPreview, type ResolvedLinkPreview, @@ -199,9 +200,10 @@ export interface ComposerLinkPreviewInput { export function updateComposerLinkPreviewInput( current: ComposerLinkPreviewInput, content: string, + relayOrigin: string | null, ): ComposerLinkPreviewInput { const nextHrefs = new Set( - extractSupportedLinkPreviews(content) + extractSupportedLinkPreviews(content, relayOrigin) .filter((preview) => preview.href.startsWith("buzz://") ? true @@ -235,11 +237,28 @@ export function useComposerLinkPreviewInput() { hrefVersions: new Map(), nextHrefVersion: 0, })); + // Read the origin through the store subscription so a paste that lands + // before the async lookup resolves is reclassified — an href set frozen at + // first-render time would keep a same-relay clone URL versioned as an + // external candidate for the life of the composer. + const relayOrigin = useRelayOrigin(); const update = React.useCallback( (content: string) => - setInput((current) => updateComposerLinkPreviewInput(current, content)), - [], + setInput((current) => + updateComposerLinkPreviewInput(current, content, relayOrigin), + ), + [relayOrigin], ); + // Re-classify already-entered content when the origin resolves or changes. + // An empty draft has nothing to reclassify; returning `current` lets React + // bail out of the mount-time pass instead of re-rendering the composer. + React.useEffect(() => { + setInput((current) => + current.content + ? updateComposerLinkPreviewInput(current, current.content, relayOrigin) + : current, + ); + }, [relayOrigin]); return [input, update] as const; } @@ -273,16 +292,17 @@ export function useComposerLinkPreviews( ); return () => window.clearTimeout(timer); }, [content]); + const relayOrigin = useRelayOrigin(); const extractCandidates = React.useCallback( (source: string) => enabled - ? extractSupportedLinkPreviews(source).filter((preview) => + ? extractSupportedLinkPreviews(source, relayOrigin).filter((preview) => preview.href.startsWith("buzz://") ? true : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), ) : [], - [enabled], + [enabled, relayOrigin], ); const candidates = React.useMemo( () => extractCandidates(debounced), diff --git a/desktop/src/features/messages/ui/useComposerMentionPicker.ts b/desktop/src/features/messages/ui/useComposerMentionPicker.ts new file mode 100644 index 00000000000..1a13aa98586 --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerMentionPicker.ts @@ -0,0 +1,63 @@ +import * as React from "react"; + +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; + +export function useComposerMentionPicker({ + mentions, + richText, + setIsEmojiPickerOpen, +}: { + mentions: UseMentionsResult; + richText: UseRichTextEditorResult; + setIsEmojiPickerOpen: (open: boolean) => void; +}) { + const { + cancelMentionAutocomplete, + isMentionOpen, + openMentionPicker, + updateMentionQuery, + } = mentions; + const { editor, focus, getPlainTextAndCursor } = richText; + return React.useCallback( + (insertTrigger = true) => { + if (!editor) return; + const { text, cursor } = getPlainTextAndCursor(); + if (!insertTrigger) { + if (isMentionOpen) { + cancelMentionAutocomplete(); + setIsEmojiPickerOpen(false); + focus(); + return; + } + openMentionPicker(cursor, "first-agent"); + setIsEmojiPickerOpen(false); + focus(); + return; + } + const beforeCursor = text.slice(0, cursor); + if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) { + updateMentionQuery(text, cursor); + focus(); + return; + } + const previousChar = text.slice(0, cursor).slice(-1); + const prefix = + cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@"; + editor.chain().focus().insertContent(prefix).run(); + setIsEmojiPickerOpen(false); + const updated = getPlainTextAndCursor(); + updateMentionQuery(updated.text, updated.cursor); + }, + [ + cancelMentionAutocomplete, + editor, + focus, + getPlainTextAndCursor, + isMentionOpen, + openMentionPicker, + setIsEmojiPickerOpen, + updateMentionQuery, + ], + ); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs new file mode 100644 index 00000000000..f45f63be27a --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeMentionRecipients } from "./useMentionSendFlow.helpers.ts"; + +test("address-locked agents join explicit mentions without duplicating recipients", () => { + const explicit = ["A".repeat(64), "b".repeat(64)]; + const locked = ["a".repeat(64), "C".repeat(64)]; + + assert.deepEqual(mergeMentionRecipients(explicit, locked), [ + "a".repeat(64), + "b".repeat(64), + "c".repeat(64), + ]); +}); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 4bd87c15d64..8176bb13bbd 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -12,6 +12,9 @@ import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; export { MENTION_REFERENCE_TAG }; export type PendingNonMemberMentionSend = { + addressedAgentPubkeys: string[]; + audienceRevision: number; + inlineAgentMentionPubkeys: string[]; capturedChannelId: string | null; capturedThreadContext: { parentEventId: string | null; @@ -31,12 +34,11 @@ export type PendingNonMemberMentionSend = { sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; savedMentionRefs: DraftMentionRef[]; - audienceGeneration: number; - audienceRevision: number | null; - explicitAgentPubkeys: string[]; }; export type SendMessageWithMentionFlowInput = { + addressedAgentPubkeys?: readonly string[]; + audienceRevision?: number; capturedChannelId: string | null; capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; @@ -47,8 +49,6 @@ export type SendMessageWithMentionFlowInput = { recoveryDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; - audienceGeneration?: number; - audienceRevision?: number | null; }; export async function resolvePreviewTags( @@ -89,6 +89,16 @@ export function uniqueNormalizedPubkeys(pubkeys: Iterable) { return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); } +export function mergeMentionRecipients( + explicitMentionPubkeys: Iterable, + addressedAgentPubkeys: Iterable, +) { + return uniqueNormalizedPubkeys([ + ...explicitMentionPubkeys, + ...addressedAgentPubkeys, + ]); +} + export function isManagedAgentRunning(agent: ManagedAgent) { return agent.status === "running" || agent.status === "deployed"; } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index e322f91987d..601c3b41135 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -10,13 +10,10 @@ import { useStartManagedAgentMutation, } from "@/features/agents/hooks"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; -import { - useAddChannelMembersMutation, - useCanAddChannelMembers, -} from "@/features/channels/hooks"; +import { useAddChannelMembersMutation } from "@/features/channels/hooks"; +import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError"; -import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, @@ -41,6 +38,7 @@ import { getErrorMessage, isManagedAgentRunning, isProviderBackedAgent, + mergeMentionRecipients, MENTION_REFERENCE_TAG, mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, @@ -48,6 +46,7 @@ import { resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; +import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; type UseMentionSendFlowOptions = { channelId: string | null; channelLinks: Pick; @@ -58,6 +57,12 @@ type UseMentionSendFlowOptions = { emojiAutocomplete: Pick; mentions: UseMentionsResult; onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; + onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; + onInlineAgentMentionsSent?: (promotion: { + expectedRevision: number; + pubkeys: readonly string[]; + }) => void; onSendRef: React.MutableRefObject< ( content: string, @@ -71,10 +76,7 @@ type UseMentionSendFlowOptions = { forceRest?: boolean, ) => Promise >; - richText: Pick< - UseRichTextEditorResult, - "clearContent" | "setContent" | "restorePlainTextAndFocusEnd" - >; + richText: Pick; setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; @@ -84,13 +86,6 @@ type UseMentionSendFlowOptions = { setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; - onSuccessfulExplicitAgentAudience?: (audience: { - channelId: string; - expectedGeneration: number; - expectedRevision: number | null; - explicitAgentPubkeys: string[]; - }) => void; - resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; export function useMentionSendFlow({ channelId, @@ -102,6 +97,9 @@ export function useMentionSendFlow({ emojiAutocomplete, mentions, onPrepareSendChannel, + onAddressedAgentsSendStarted, + onAddressedAgentsSendFailed, + onInlineAgentMentionsSent, onSendRef, richText, setContent, @@ -111,8 +109,6 @@ export function useMentionSendFlow({ clearQueuedAttachments, restoreQueuedAttachments, setSpoileredAttachmentUrls, - onSuccessfulExplicitAgentAudience, - resolvePostSendContent, }: UseMentionSendFlowOptions) { const [pendingNonMemberSend, setPendingNonMemberSend] = React.useState(null); @@ -319,51 +315,39 @@ export function useMentionSendFlow({ provisionPersonaAgentMutation, ], ); - - const clearComposer = React.useCallback( - (postSendContent = "") => { - setPendingNonMemberSend(null); - setNonMemberPromptError(null); - setContent(postSendContent); - contentRef.current = postSendContent; - if (postSendContent) { - richText.restorePlainTextAndFocusEnd(postSendContent); - mentions.cancelMentionAutocomplete(); - } else richText.clearContent(); - setPendingImeta([]); - clearQueuedAttachments(); - setSpoileredAttachmentUrls?.(new Set()); - if (!postSendContent) mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - setIsEmojiPickerOpen(false); - }, - [ - channelLinks.clearChannels, - contentRef, - emojiAutocomplete.clearEmojis, - mentions.cancelMentionAutocomplete, - mentions.clearMentions, - richText.clearContent, - richText.restorePlainTextAndFocusEnd, - setContent, - setIsEmojiPickerOpen, - setPendingImeta, - clearQueuedAttachments, - setSpoileredAttachmentUrls, - ], - ); - + const clearComposer = React.useCallback(() => { + setPendingNonMemberSend(null); + setNonMemberPromptError(null); + setContent(""); + contentRef.current = ""; + richText.clearContent(); + setPendingImeta([]); + clearQueuedAttachments(); + setSpoileredAttachmentUrls?.(new Set()); + mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + setIsEmojiPickerOpen(false); + }, [ + channelLinks.clearChannels, + contentRef, + emojiAutocomplete.clearEmojis, + mentions.clearMentions, + richText.clearContent, + setContent, + setIsEmojiPickerOpen, + setPendingImeta, + clearQueuedAttachments, + setSpoileredAttachmentUrls, + ]); React.useEffect(() => { if (previousChannelIdRef.current === channelId) { return; } - previousChannelIdRef.current = channelId; setPendingNonMemberSend(null); setNonMemberPromptError(null); }, [channelId]); - const completeSend = React.useCallback( async ( draft: PendingNonMemberMentionSend, @@ -373,11 +357,9 @@ export function useMentionSendFlow({ if (isCompleteSendPendingRef.current) { return; } - const sendSignal = draft.preparedLinkPreviews?.signal; const isSendCancelled = () => sendSignal?.aborted === true; if (isSendCancelled()) return draft.preparedLinkPreviews?.release(); - isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); const preparedUpload = @@ -399,12 +381,84 @@ export function useMentionSendFlow({ draft.queuedAttachments, ); }; + const persistCanceledDraft = () => { + if (isSendCancelled() || !draft.recoveryDraftKey) return; + const existing = drafts.loadDraft(draft.recoveryDraftKey); + if ( + existing && + (existing.content !== draft.savedContent || + existing.channelId !== + (draft.capturedChannelId ?? draft.recoveryDraftKey) || + JSON.stringify(existing.pendingImeta) !== + JSON.stringify(draft.savedImeta) || + JSON.stringify(existing.spoileredAttachmentUrls) !== + JSON.stringify([...draft.savedSpoileredAttachmentUrls])) + ) { + return; + } + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, + ); + }; + let composerCleared = false; + const restoreComposerAfterFailure = () => { + if (!composerCleared) return; + composerCleared = false; + persistCanceledDraft(); + const canAnimateCurrentComposer = + isMountedRef.current && + (draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null); + if ( + canAnimateCurrentComposer && + draft.addressedAgentPubkeys.length > 0 + ) { + onAddressedAgentsSendFailed?.(draft.addressedAgentPubkeys); + } + const canRestoreCurrentComposer = + canAnimateCurrentComposer && + contentRef.current.trim().length === 0 && + !hasUnsavedMedia(); + if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + } + if (!canRestoreCurrentComposer) { + return; + } + setContent(draft.savedContent); + contentRef.current = draft.savedContent; + richText.setContent(draft.savedContent); + setPendingImeta(draft.savedImeta); + restoreQueuedAttachments(draft.queuedAttachments); + mentions.restoreDraftMentionRefs(draft.savedMentionRefs); + setSpoileredAttachmentUrls?.( + new Set(draft.savedSpoileredAttachmentUrls), + ); + }; + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + if (draft.addressedAgentPubkeys.length > 0) { + onAddressedAgentsSendStarted?.(draft.addressedAgentPubkeys); + } + clearComposer(); + composerCleared = true; + } let uploadStarted = false; try { const admittedMentionPubkeys = uniqueNormalizedPubkeys( await mentions.revalidateMentionPubkeys(mentionPubkeys), ); - if (isSendCancelled()) return; + if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) return persistPreflightDraft(); const admittedMentionPubkeySet = new Set(admittedMentionPubkeys); const readyAgentPubkeys = new Set( @@ -413,7 +467,7 @@ export function useMentionSendFlow({ ), ); const managedAgentsByPubkey = await getManagedAgentsByPubkey(); - if (isSendCancelled()) return; + if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -436,16 +490,15 @@ export function useMentionSendFlow({ let sendChannelId = draft.capturedChannelId; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); - if (isSendCancelled()) return; + if (isSendCancelled()) return restoreComposerAfterFailure(); if (!sendChannelId) { - return; + return restoreComposerAfterFailure(); } if (!isMountedRef.current) { persistPreflightDraft(); return; } } - const agentReadiness = await ensureManagedAgentMentionsReady( managedMentionPubkeys.filter( (pubkey) => !readyAgentPubkeys.has(normalizePubkey(pubkey)), @@ -454,7 +507,7 @@ export function useMentionSendFlow({ onPrepareSendChannel ? preparedAgentPubkeys : [], [...managedAgentsByPubkey.values()], ); - if (isSendCancelled()) return; + if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -468,7 +521,7 @@ export function useMentionSendFlow({ )}`; setNonMemberPromptError(message); toast.error(message); - return; + return restoreComposerAfterFailure(); } if (preparedAgentPubkeys.length > 0 && sendChannelId) { try { @@ -476,76 +529,19 @@ export function useMentionSendFlow({ channelId: sendChannelId, agentPubkeys: preparedAgentPubkeys, }); - if (isSendCancelled()) return; + if (isSendCancelled()) return restoreComposerAfterFailure(); } catch (error) { - if (isSendCancelled()) return; + if (isSendCancelled()) return restoreComposerAfterFailure(); const message = `Could not add mentioned agent to the Huddle: ${getErrorMessage( error, "Huddle enrollment failed.", )}`; setNonMemberPromptError(message); toast.error(message); - return; + return restoreComposerAfterFailure(); } } - const effectiveExplicitAgentPubkeys = - filterEffectiveExplicitAgentPubkeys( - draft.explicitAgentPubkeys, - mentionPubkeys, - ); const send = onSendRef.current; - const persistCanceledDraft = () => { - if (isSendCancelled() || !draft.recoveryDraftKey) return; - const existing = drafts.loadDraft(draft.recoveryDraftKey); - if ( - existing && - (existing.content !== draft.savedContent || - existing.channelId !== - (draft.capturedChannelId ?? draft.recoveryDraftKey) || - JSON.stringify(existing.pendingImeta) !== - JSON.stringify(draft.savedImeta) || - JSON.stringify(existing.spoileredAttachmentUrls) !== - JSON.stringify([...draft.savedSpoileredAttachmentUrls])) - ) { - return; - } - drafts.persistDraft( - draft.recoveryDraftKey, - draft.savedContent, - draft.capturedChannelId ?? draft.recoveryDraftKey, - draft.savedImeta, - [...draft.savedSpoileredAttachmentUrls], - draft.savedMentionRefs, - ); - }; - const restoreComposerAfterFailure = () => { - if (isSendCancelled()) return; - persistCanceledDraft(); - const canRestoreCurrentComposer = - isMountedRef.current && - (draft.capturedChannelId === channelIdRef.current || - channelIdRef.current === null) && - contentRef.current.trim().length === 0 && - !hasUnsavedMedia(); - if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { - saveQueuedAttachmentsForDraft( - draft.recoveryDraftKey, - draft.queuedAttachments, - ); - } - if (!canRestoreCurrentComposer) { - return; - } - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); - setPendingImeta(draft.savedImeta); - restoreQueuedAttachments(draft.queuedAttachments); - mentions.restoreDraftMentionRefs(draft.savedMentionRefs); - setSpoileredAttachmentUrls?.( - new Set(draft.savedSpoileredAttachmentUrls), - ); - }; const finishSend = async ( uploaded: ImetaMedia[], signal?: AbortSignal, @@ -572,28 +568,31 @@ export function useMentionSendFlow({ const revalidatedMentionPubkeys = await mentions.revalidateMentionPubkeys(mentionPubkeys); if (signal?.aborted || isSendCancelled()) return; - const revalidatedExplicitAgentPubkeys = - filterEffectiveExplicitAgentPubkeys( - draft.explicitAgentPubkeys, + const finalTagsWithAgentAddress = [ + ...finalOutgoingTags, + ...buildAgentAddressMentionTags( + draft.addressedAgentPubkeys, revalidatedMentionPubkeys, - ); + ), + ]; await send( finalContent, revalidatedMentionPubkeys, - finalOutgoingTags, + finalTagsWithAgentAddress, sendChannelId, draft.capturedThreadContext, draft.preparedLinkPreviews != null, ); if (signal?.aborted || isSendCancelled()) return; - if (revalidatedExplicitAgentPubkeys.length > 0) { - onSuccessfulExplicitAgentAudience?.({ - channelId: sendChannelId ?? draft.capturedChannelId ?? "", - expectedGeneration: draft.audienceGeneration, - expectedRevision: draft.audienceRevision, - explicitAgentPubkeys: revalidatedExplicitAgentPubkeys, - }); - } + const sentMentionPubkeys = new Set( + revalidatedMentionPubkeys.map(normalizePubkey), + ); + onInlineAgentMentionsSent?.({ + expectedRevision: draft.audienceRevision, + pubkeys: draft.inlineAgentMentionPubkeys.filter((pubkey) => + sentMentionPubkeys.has(normalizePubkey(pubkey)), + ), + }); if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, @@ -624,18 +623,9 @@ export function useMentionSendFlow({ }, }); if (!uploadStarted) { - return; + return restoreComposerAfterFailure(); } } - if ( - draft.capturedChannelId === channelIdRef.current || - channelIdRef.current === null - ) { - clearComposer( - resolvePostSendContent?.(effectiveExplicitAgentPubkeys), - ); - } - if (!preparedUpload) { try { await finishSend([]); @@ -643,6 +633,9 @@ export function useMentionSendFlow({ restoreComposerAfterFailure(); } } + } catch (error) { + restoreComposerAfterFailure(); + throw error; } finally { if (draft.preparedLinkPreviews) { activePreparedLinkPreviews.delete(draft.preparedLinkPreviews); @@ -663,10 +656,11 @@ export function useMentionSendFlow({ getManagedAgentsByPubkey, mentions.isAgentPubkey, mentions.revalidateMentionPubkeys, + onAddressedAgentsSendStarted, + onAddressedAgentsSendFailed, + onInlineAgentMentionsSent, onPrepareSendChannel, onSendRef, - onSuccessfulExplicitAgentAudience, - resolvePostSendContent, richText.setContent, setContent, setPendingImeta, @@ -679,6 +673,8 @@ export function useMentionSendFlow({ ); const sendMessageWithMentionFlow = React.useCallback( async ({ + addressedAgentPubkeys = [], + audienceRevision = 0, capturedChannelId, capturedThreadContext = null, pendingImeta, @@ -689,13 +685,10 @@ export function useMentionSendFlow({ recoveryDraftKey, spoileredAttachmentUrls = new Set(), trimmed, - audienceGeneration = 0, - audienceRevision = null, }: SendMessageWithMentionFlowInput) => { if (isMentionSendPendingRef.current) { return; } - isMentionSendPendingRef.current = true; setIsMentionSendPending(true); const isSendCancelled = () => @@ -711,7 +704,11 @@ export function useMentionSendFlow({ isThreadReply: capturedThreadContext != null, channelType, extractMentionPersonas: mentions.extractMentionPersonas, - extractMentionPubkeys: mentions.extractMentionPubkeys, + extractMentionPubkeys: (text) => + mergeMentionRecipients( + mentions.extractMentionPubkeys(text), + addressedAgentPubkeys, + ), isAgentPubkey: mentions.isAgentPubkey, hasResolvedMembers: mentions.hasResolvedMembers, memberPubkeys: mentions.memberPubkeys, @@ -721,7 +718,6 @@ export function useMentionSendFlow({ toast.error(dmThreadAgentMentionErrorMessage); return; } - let effectiveChannelId = capturedChannelId; if (!effectiveChannelId && onPrepareSendChannel) { effectiveChannelId = await onPrepareSendChannel(); @@ -730,7 +726,6 @@ export function useMentionSendFlow({ return; } } - const personaMentionResult = await createMentionedPersonaAgents( trimmed, effectiveChannelId ?? "", @@ -747,7 +742,6 @@ export function useMentionSendFlow({ toast.error(message); return; } - const createdPersonaAgentPubkeys = personaMentionResult.pubkeys; const createdPersonaAgentPubkeySet = new Set( createdPersonaAgentPubkeys.map(normalizePubkey), @@ -756,12 +750,10 @@ export function useMentionSendFlow({ ...mentions.extractMentionPubkeys(trimmed), ...createdPersonaAgentPubkeys, ]); - const explicitAgentPubkeys = explicitMentionPubkeys.filter( - (pubkey) => - mentions.isAgentPubkey(pubkey) || - createdPersonaAgentPubkeySet.has(pubkey), + const pubkeys = mergeMentionRecipients( + explicitMentionPubkeys, + addressedAgentPubkeys, ); - const pubkeys = explicitMentionPubkeys; const outgoingTags = [ ...buildCustomEmojiTags(trimmed, customEmoji), ...linkPreviewTags, @@ -779,7 +771,6 @@ export function useMentionSendFlow({ !mentions.isManagedAgentPubkey(pubkey) && !createdPersonaAgentPubkeySet.has(normalizePubkey(pubkey)), ); - if (promptNonMemberPubkeys.length > 0) { try { const managedAgentsByPubkey = await getManagedAgentsByPubkey(); @@ -789,8 +780,15 @@ export function useMentionSendFlow({ ); } catch {} } - + const savedMentionRefs = mentions.getDraftMentionRefs(trimmed); const pendingDraft: PendingNonMemberMentionSend = { + addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys), + audienceRevision, + inlineAgentMentionPubkeys: uniqueNormalizedPubkeys( + savedMentionRefs + .filter((ref) => ref.isAgent) + .map((ref) => ref.pubkey), + ), capturedChannelId: effectiveChannelId, capturedThreadContext, trimmed, @@ -809,18 +807,13 @@ export function useMentionSendFlow({ savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, recoveryDraftKey, - savedMentionRefs: mentions.getDraftMentionRefs(trimmed), - audienceGeneration, - audienceRevision, - explicitAgentPubkeys, + savedMentionRefs, }; - if (promptNonMemberPubkeys.length > 0) { setNonMemberPromptError(null); setPendingNonMemberSend(pendingDraft); return; } - sendPromoted = true; await completeSend(pendingDraft, pubkeys); } finally { @@ -853,16 +846,13 @@ export function useMentionSendFlow({ ); const pendingNonMemberNames = React.useMemo(() => { if (!pendingNonMemberSend) return []; - return pendingNonMemberSend.nonMemberPubkeys.map( (pubkey) => mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), ); }, [mentions.getMentionDisplayName, pendingNonMemberSend]); - const handleSendWithoutInviting = React.useCallback(() => { if (!pendingNonMemberSend) return; - const nonMemberPubkeys = new Set( pendingNonMemberSend.nonMemberPubkeys.map((pubkey) => normalizePubkey(pubkey), @@ -911,14 +901,12 @@ export function useMentionSendFlow({ if (managedAgentsByPubkey.has(pubkey)) { continue; } - if (mentions.isAgentPubkey(pubkey)) { relayAgentPubkeys.push(pubkey); } else { peoplePubkeys.push(pubkey); } } - const errors: string[] = []; if (peoplePubkeys.length > 0) { const result = await addMembersMutation.mutateAsync({ @@ -928,7 +916,6 @@ export function useMentionSendFlow({ }); errors.push(...result.errors.map((error) => error.error)); } - if (relayAgentPubkeys.length > 0) { const result = await addMembersMutation.mutateAsync({ channelId: pendingNonMemberSend.capturedChannelId ?? undefined, @@ -937,12 +924,10 @@ export function useMentionSendFlow({ }); errors.push(...result.errors.map((error) => error.error)); } - if (errors.length > 0) { setNonMemberPromptError(errors.join("; ")); return; } - await completeSend( { ...pendingNonMemberSend, @@ -966,7 +951,6 @@ export function useMentionSendFlow({ mentions.revalidateMentionPubkeys, pendingNonMemberSend, ]); - const dismissNonMemberPrompt = React.useCallback(() => { setPendingNonMemberSend(null); setNonMemberPromptError(null); diff --git a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts deleted file mode 100644 index d2e0d4390e1..00000000000 --- a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts +++ /dev/null @@ -1,175 +0,0 @@ -import * as React from "react"; - -import { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; - -export function usePersistentAgentMentionHydration({ - audienceScope, - hydrationKey, - initialAgentPubkeys, - isEditing, - mentions, - richText, -}: { - audienceScope: string | null; - hydrationKey: string | null | undefined; - initialAgentPubkeys?: readonly string[]; - isEditing: boolean; - mentions: UseMentionsResult; - richText: UseRichTextEditorResult; -}) { - const audience = usePersistentAgentAudience(audienceScope); - const audienceRef = React.useRef(audience); - audienceRef.current = audience; - const scopeRef = React.useRef(audienceScope); - scopeRef.current = audienceScope; - const isEditingRef = React.useRef(isEditing); - isEditingRef.current = isEditing; - React.useEffect(() => { - if (!audienceScope || !initialAgentPubkeys) return; - audience.initialize(initialAgentPubkeys); - }, [audience.initialize, audienceScope, initialAgentPubkeys]); - const isRestoringRef = React.useRef(false); - const isSubmittingRef = React.useRef(false); - const cancelHydrationAutocompleteRef = React.useRef(false); - const hydratedRef = React.useRef(false); - - const hydrate = React.useCallback(() => { - const capturedScope = audienceScope; - if ( - !audience.enabled || - !capturedScope || - isEditingRef.current || - audience.pubkeys.length === 0 - ) { - hydratedRef.current = true; - return; - } - isRestoringRef.current = true; - const current = richText.getPlainTextAndCursor().text; - const targets = audience.pubkeys - .map((pubkey) => ({ - pubkey, - displayName: mentions.getMentionDisplayName(pubkey), - })) - .filter((target): target is { pubkey: string; displayName: string } => - Boolean(target.displayName), - ); - for (const target of targets) - mentions.registerMentionPubkey(target.displayName, target.pubkey, { - isAgent: true, - }); - if (scopeRef.current !== capturedScope) { - isRestoringRef.current = false; - return; - } - const present = new Set(mentions.extractMentionPubkeys(current)); - let prefixLength = 0; - for (const target of targets.filter( - (candidate) => !present.has(candidate.pubkey), - )) { - if (scopeRef.current !== capturedScope) break; - const edit = mentions.insertResolvedMention({ - ...target, - isAgent: true, - replaceFromOffset: prefixLength, - replaceToOffset: prefixLength, - }); - cancelHydrationAutocompleteRef.current = true; - richText.replacePlainTextRange( - edit.replaceFromOffset, - edit.replaceToOffset, - edit.insertText, - ); - prefixLength += edit.insertText.length; - } - hydratedRef.current = scopeRef.current === capturedScope; - isRestoringRef.current = false; - if (cancelHydrationAutocompleteRef.current) { - cancelHydrationAutocompleteRef.current = false; - // Hydration is a programmatic transition, not an authored query. Cancel - // only when its editor updates actually scheduled autocomplete work. - mentions.cancelMentionAutocomplete(); - } - }, [audience.enabled, audience.pubkeys, audienceScope, mentions, richText]); - - const reconcile = React.useCallback( - (text: string) => { - if ( - !hydratedRef.current || - isRestoringRef.current || - isSubmittingRef.current || - isEditingRef.current - ) - return; - const present = new Set(mentions.extractMentionPubkeys(text)); - for (const pubkey of audienceRef.current.pubkeys) { - if (!present.has(pubkey)) audienceRef.current.removePubkey(pubkey); - } - }, - [mentions.extractMentionPubkeys], - ); - - const hydrateRef = React.useRef(hydrate); - hydrateRef.current = hydrate; - const scheduleHydration = React.useCallback( - (cancelAutocomplete = false) => - requestAnimationFrame(() => { - hydrateRef.current(); - if (cancelAutocomplete) mentions.cancelMentionAutocomplete(); - }), - [mentions.cancelMentionAutocomplete], - ); - React.useEffect(() => { - void hydrationKey; - hydratedRef.current = false; - const frame = scheduleHydration(); - return () => cancelAnimationFrame(frame); - }, [hydrationKey, scheduleHydration]); - - const resolvePostSendContent = React.useCallback( - (explicitAgentPubkeys: string[]) => { - if (!audience.enabled || !audienceScope || isEditingRef.current) - return ""; - const orderedPubkeys = [ - ...new Set([...explicitAgentPubkeys, ...audience.pubkeys]), - ]; - const targets = orderedPubkeys - .map((pubkey) => ({ - pubkey, - displayName: mentions.getMentionDisplayName(pubkey), - })) - .filter((target): target is { pubkey: string; displayName: string } => - Boolean(target.displayName), - ); - mentions.clearMentions(); - for (const target of targets) { - mentions.registerMentionPubkey(target.displayName, target.pubkey, { - isAgent: true, - }); - } - isRestoringRef.current = true; - hydratedRef.current = true; - return ( - targets.map((target) => `@${target.displayName}`).join(" ") + - (targets.length > 0 ? " " : "") - ); - }, - [audience.enabled, audience.pubkeys, audienceScope, mentions], - ); - - return { - audience, - beginSubmit: () => { - isSubmittingRef.current = true; - }, - endSubmit: () => { - isSubmittingRef.current = false; - scheduleHydration(true); - }, - reconcile, - resolvePostSendContent, - scheduleHydration, - }; -} diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 45716de2d94..0844c24de51 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -210,6 +210,7 @@ export async function listenForDesktopNotificationActions( let pluginListener: { unregister: () => Promise } | null = null; let nativeUnlisten: (() => void) | null = null; + let redrainUnlisten: (() => void) | null = null; if (isTauri()) { const usesMacActivationQueue = isMacPlatform(); @@ -279,6 +280,29 @@ export async function listenForDesktopNotificationActions( ); } } + + if (usesMacActivationQueue) { + // Belt and suspenders for block/buzz#3509: the Rust delegate queues the + // target before emitting, so a lost emit strands the activation with + // nothing re-draining it. macOS always foregrounds the app on a + // notification click, and WebKit delivers the resulting focus and + // visibility transitions independently of the Tauri event channel — use + // them to re-drain so a queued target is never stranded. + const redrain = () => { + void dispatchNativeActivations().catch((error) => { + console.error( + "Failed to drain macOS notification activations on focus", + error, + ); + }); + }; + window.addEventListener("focus", redrain); + document.addEventListener("visibilitychange", redrain); + redrainUnlisten = () => { + window.removeEventListener("focus", redrain); + document.removeEventListener("visibilitychange", redrain); + }; + } } return () => { @@ -288,6 +312,7 @@ export async function listenForDesktopNotificationActions( ); void pluginListener?.unregister(); nativeUnlisten?.(); + redrainUnlisten?.(); }; } @@ -335,6 +360,33 @@ export async function requestDockBounce(): Promise { } } +/** + * How long the window-reveal invoke chain may run before callers proceed + * without it. macOS already foregrounds the app when a notification is + * clicked, so a reveal that never settles must not gate click-through + * routing (block/buzz#3509). + */ +const REVEAL_WINDOW_TIMEOUT_MS = 1_500; + +function resolveWithinTimeout( + operation: Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, timeoutMs); + operation.then( + () => { + clearTimeout(timer); + resolve(); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + export async function revealDesktopAppWindow(): Promise { if (!isTauri()) { if (typeof window !== "undefined") { @@ -345,9 +397,19 @@ export async function revealDesktopAppWindow(): Promise { try { const currentWindow = getCurrentWindow(); - await currentWindow.unminimize(); - await currentWindow.show(); - await currentWindow.setFocus(); + // The reveal crosses the IPC boundary three times, and the try/catch + // only covers rejections — an invoke that never settles (seen while + // macOS is simultaneously foregrounding the app from a notification + // click) would strand callers that await this helper before navigating. + // Resolve after a timeout so navigation always proceeds. + await resolveWithinTimeout( + (async () => { + await currentWindow.unminimize(); + await currentWindow.show(); + await currentWindow.setFocus(); + })(), + REVEAL_WINDOW_TIMEOUT_MS, + ); } catch { // Best effort only. } diff --git a/desktop/src/features/notifications/lib/desktopActivations.test.mjs b/desktop/src/features/notifications/lib/desktopActivations.test.mjs new file mode 100644 index 00000000000..880018c415d --- /dev/null +++ b/desktop/src/features/notifications/lib/desktopActivations.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// revealDesktopAppWindow and listenForDesktopNotificationActions cross the +// Tauri IPC boundary through window.__TAURI_INTERNALS__ — stub it before the +// module (and @tauri-apps/api) load. block/buzz#3509: a macOS notification +// click must always route, even when a window invoke hangs or the Tauri +// activation emit is lost. + +let pendingActivations = []; +let hangWindowInvokes = false; + +const tauriInternals = { + invoke(command) { + if (command === "take_pending_activations") { + const drained = pendingActivations; + pendingActivations = []; + return Promise.resolve(drained); + } + if (hangWindowInvokes && command.startsWith("plugin:window|")) { + return new Promise(() => {}); + } + if (command === "plugin:event|listen") { + return Promise.resolve(1); + } + return Promise.resolve(undefined); + }, + transformCallback() { + return 0; + }, + metadata: { currentWindow: { label: "main" } }, +}; + +const testWindow = new EventTarget(); +testWindow.__TAURI_INTERNALS__ = tauriInternals; +testWindow.__TAURI_EVENT_PLUGIN_INTERNALS__ = { + unregisterListener() {}, +}; +// The module under test only checks that a Notification API exists and reads +// its static permission; a plain function stub keeps biome happy. +function StubNotification() {} +StubNotification.permission = "granted"; +testWindow.Notification = StubNotification; +globalThis.window = testWindow; +globalThis.document = new EventTarget(); +globalThis.isTauri = true; +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform: "MacIntel", userAgent: "buzz-test" }, +}); + +const { listenForDesktopNotificationActions, revealDesktopAppWindow } = + await import("./desktop.ts"); + +function flushPendingWork() { + return new Promise((resolve) => setImmediate(resolve)); +} + +test("reveal resolves via timeout when a window invoke hangs", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + hangWindowInvokes = true; + t.after(() => { + hangWindowInvokes = false; + }); + + let settled = false; + const reveal = revealDesktopAppWindow().then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + assert.equal(settled, false); + + t.mock.timers.tick(1_500); + await reveal; + assert.equal(settled, true); +}); + +test("reveal resolves without the timer when the invoke chain settles", async (t) => { + // Mocked timers never fire on their own here, so this await only returns + // if the helper resolves through the settled invoke chain. + t.mock.timers.enable({ apis: ["setTimeout"] }); + + await revealDesktopAppWindow(); +}); + +test("window focus re-drains activations stranded by a lost emit", async () => { + const received = []; + const dispose = await listenForDesktopNotificationActions((target) => { + received.push(target); + }); + + // The Tauri emit was lost, but the Rust queue still holds the clicked + // target. macOS foregrounds the app anyway; WebKit fires window focus. + pendingActivations = [ + { channelId: "channel-1", eventId: "event-1", kind: 9 }, + ]; + window.dispatchEvent(new Event("focus")); + await flushPendingWork(); + + assert.deepEqual(received, [ + { + channelId: "channel-1", + channelName: null, + content: undefined, + createdAt: null, + eventId: "event-1", + kind: 9, + pubkey: undefined, + threadRootId: null, + }, + ]); + + dispose(); + pendingActivations = [ + { channelId: "channel-2", eventId: "event-2", kind: 9 }, + ]; + window.dispatchEvent(new Event("focus")); + await flushPendingWork(); + assert.equal(received.length, 1, "disposed listener must not re-drain"); + // Leave the queue empty so the next test's mount-time drain starts clean. + pendingActivations = []; +}); + +test("visibilitychange re-drains activations stranded by a lost emit", async () => { + const received = []; + const dispose = await listenForDesktopNotificationActions((target) => { + received.push(target); + }); + + pendingActivations = [ + { channelId: "channel-3", eventId: "event-3", kind: 9 }, + ]; + document.dispatchEvent(new Event("visibilitychange")); + await flushPendingWork(); + + assert.equal(received.length, 1); + assert.equal(received[0].channelId, "channel-3"); + dispose(); +}); diff --git a/desktop/src/features/notifications/lib/feed.ts b/desktop/src/features/notifications/lib/feed.ts index f8413c4313c..4c87cb99d4f 100644 --- a/desktop/src/features/notifications/lib/feed.ts +++ b/desktop/src/features/notifications/lib/feed.ts @@ -1,8 +1,5 @@ import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; -import { - formatNotificationTitle, - truncateNotificationBody, -} from "@/features/notifications/lib/notificationFormat"; +import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat"; export type NotificationChannel = Pick; @@ -31,44 +28,28 @@ export function enrichFeedItemChannel( }; } -export function notificationTitle(item: FeedItem, senderName?: string) { - const channelLabel = - item.channelType !== "dm" && item.channelName.trim() - ? `#${item.channelName.trim()}` - : null; - - if (item.channelType === "dm") { - return senderName || "Direct message"; - } - - if (item.category === "mention") { - return formatNotificationTitle({ - prefix: senderName ? `${senderName} mentioned you` : "@Mention", - channelLabel, - }); - } - - if (item.kind === 46010) { - return formatNotificationTitle({ - prefix: senderName - ? `${senderName} requested approval` - : "Approval Requested", - channelLabel, - }); - } +function feedNotificationSource(item: FeedItem) { + if (item.channelType === "dm") return "dm" as const; + if (item.category === "mention") return "mention" as const; + if (item.kind === 46010) return "approval" as const; + return "needs_action" as const; +} - return formatNotificationTitle({ - prefix: senderName ? senderName : "Needs Action", - channelLabel, +export function formatFeedNotification(item: FeedItem, senderName?: string) { + return formatMessageNotification({ + source: feedNotificationSource(item), + senderName, + channelName: item.channelType !== "dm" ? item.channelName : null, + content: item.content, }); } +export function notificationTitle(item: FeedItem, senderName?: string) { + return formatFeedNotification(item, senderName).title; +} + export function notificationBody(item: FeedItem) { - const fallback = - item.kind === 46010 - ? "A workflow is waiting for your approval." - : "Something in Buzz needs your attention."; - return truncateNotificationBody(item.content, fallback); + return formatFeedNotification(item).body; } export function collectHomeAlertItems(feed: HomeFeedResponse) { diff --git a/desktop/src/features/notifications/lib/notificationFormat.test.mjs b/desktop/src/features/notifications/lib/notificationFormat.test.mjs new file mode 100644 index 00000000000..162cb6a439e --- /dev/null +++ b/desktop/src/features/notifications/lib/notificationFormat.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatMessageNotification } from "./notificationFormat.ts"; +import { senderNameFromSummary } from "./senderName.ts"; + +test("DM title is the sender name when resolved", () => { + const { title, body } = formatMessageNotification({ + source: "dm", + senderName: "Taylor", + channelName: "taylor-wes", + content: "hey there", + }); + + assert.equal(title, "Taylor"); + assert.equal(body, "hey there"); +}); + +test("DM title falls back to the channel name, then generic copy", () => { + assert.equal( + formatMessageNotification({ + source: "dm", + senderName: null, + channelName: "taylor-wes", + content: "hi", + }).title, + "taylor-wes", + ); + assert.equal( + formatMessageNotification({ + source: "dm", + senderName: " ", + channelName: "", + content: "hi", + }).title, + "Direct message", + ); +}); + +test("DM body falls back when the message is blank", () => { + assert.equal( + formatMessageNotification({ + source: "dm", + senderName: "Taylor", + channelName: null, + content: " ", + }).body, + "New message", + ); +}); + +test("thread reply leads with the sender when resolved", () => { + assert.equal( + formatMessageNotification({ + source: "thread_reply", + senderName: "Taylor", + channelName: "ship-room", + content: "done!", + }).title, + "Taylor replied in #ship-room", + ); +}); + +test("thread reply preserves legacy copy when the sender is unknown", () => { + assert.equal( + formatMessageNotification({ + source: "thread_reply", + senderName: null, + channelName: "ship-room", + content: "done!", + }).title, + "Reply in #ship-room", + ); + assert.deepEqual( + formatMessageNotification({ + source: "thread_reply", + senderName: null, + channelName: null, + content: "", + }), + { title: "Reply", body: "New reply" }, + ); +}); + +test("mention titles match the home-feed conventions", () => { + assert.equal( + formatMessageNotification({ + source: "mention", + senderName: "Taylor", + channelName: "ship-room", + content: "@wes look", + }).title, + "Taylor mentioned you in #ship-room", + ); + assert.equal( + formatMessageNotification({ + source: "mention", + senderName: null, + channelName: "ship-room", + content: "@wes look", + }).title, + "@Mention in #ship-room", + ); +}); + +test("approval and needs-action titles match the home-feed conventions", () => { + assert.deepEqual( + formatMessageNotification({ + source: "approval", + senderName: "Taylor", + channelName: "ops", + content: "", + }), + { + title: "Taylor requested approval in #ops", + body: "A workflow is waiting for your approval.", + }, + ); + assert.deepEqual( + formatMessageNotification({ + source: "needs_action", + senderName: null, + channelName: null, + content: "", + }), + { + title: "Needs Action", + body: "Something in Buzz needs your attention.", + }, + ); +}); + +test("senderNameFromSummary prefers displayName, then NIP-05, never a pubkey", () => { + assert.equal( + senderNameFromSummary({ + displayName: "Taylor", + avatarUrl: null, + nip05Handle: "taylor@buzz.example", + ownerPubkey: null, + }), + "Taylor", + ); + assert.equal( + senderNameFromSummary({ + displayName: " ", + avatarUrl: null, + nip05Handle: "taylor@buzz.example", + ownerPubkey: null, + }), + "taylor@buzz.example", + ); + assert.equal( + senderNameFromSummary({ + displayName: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }), + null, + ); + assert.equal(senderNameFromSummary(null), null); + assert.equal(senderNameFromSummary(undefined), null); +}); diff --git a/desktop/src/features/notifications/lib/notificationFormat.ts b/desktop/src/features/notifications/lib/notificationFormat.ts index c25270123ae..04c951c33fc 100644 --- a/desktop/src/features/notifications/lib/notificationFormat.ts +++ b/desktop/src/features/notifications/lib/notificationFormat.ts @@ -47,3 +47,66 @@ export function formatNotificationTitle(opts: { ? `${opts.prefix} in ${opts.channelLabel}` : opts.prefix; } + +export type MessageNotificationSource = + | "mention" + | "approval" + | "needs_action" + | "dm" + | "thread_reply"; + +const MESSAGE_BODY_FALLBACKS: Record = { + mention: "Something in Buzz needs your attention.", + approval: "A workflow is waiting for your approval.", + needs_action: "Something in Buzz needs your attention.", + dm: "New message", + thread_reply: "New reply", +}; + +/** + * Canonical copy for every message-shaped desktop notification (home-feed + * mentions and needs-action items, live DMs, live thread replies). All paths + * format through here so sender attribution and fallbacks stay consistent: + * the sender leads the title whenever their profile has resolved, and each + * source degrades to neutral copy — never a raw pubkey — when it has not. + * + * `senderName` must already be a real human label (see + * `senderNameFromSummary`); `channelName` is the raw channel name without + * a `#` prefix. + */ +export function formatMessageNotification(opts: { + source: MessageNotificationSource; + senderName?: string | null; + channelName?: string | null; + content: string; +}): { title: string; body: string } { + const { source, content } = opts; + const senderName = opts.senderName?.trim() || null; + const channelName = opts.channelName?.trim() || null; + const body = truncateNotificationBody( + content, + MESSAGE_BODY_FALLBACKS[source], + ); + + if (source === "dm") { + return { title: senderName ?? channelName ?? "Direct message", body }; + } + + const channelLabel = channelName ? `#${channelName}` : null; + const prefix = + source === "mention" + ? senderName + ? `${senderName} mentioned you` + : "@Mention" + : source === "approval" + ? senderName + ? `${senderName} requested approval` + : "Approval Requested" + : source === "thread_reply" + ? senderName + ? `${senderName} replied` + : "Reply" + : (senderName ?? "Needs Action"); + + return { title: formatNotificationTitle({ prefix, channelLabel }), body }; +} diff --git a/desktop/src/features/notifications/lib/senderName.ts b/desktop/src/features/notifications/lib/senderName.ts new file mode 100644 index 00000000000..1872cf540b6 --- /dev/null +++ b/desktop/src/features/notifications/lib/senderName.ts @@ -0,0 +1,24 @@ +import type { UserProfileSummary } from "@/shared/api/types"; + +/** + * Resolve a sender's human display label from a profile summary, mirroring + * the sidebar's precedence (`resolveUserLabel`: displayName, then NIP-05 + * handle) — but returning `null` instead of a truncated pubkey when no real + * name is known. Notification titles fall back to neutral copy ("Reply in + * #channel", "Direct message"), never a hex fragment. + */ +export function senderNameFromSummary( + summary: UserProfileSummary | null | undefined, +): string | null { + const displayName = summary?.displayName?.trim(); + if (displayName) { + return displayName; + } + + const nip05Handle = summary?.nip05Handle?.trim(); + if (nip05Handle) { + return nip05Handle; + } + + return null; +} diff --git a/desktop/src/features/notifications/lib/target.test.mjs b/desktop/src/features/notifications/lib/target.test.mjs new file mode 100644 index 00000000000..b9966381bdb --- /dev/null +++ b/desktop/src/features/notifications/lib/target.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildEventNotificationTarget, + buildFeedItemNotificationTarget, +} from "./target.ts"; + +test("builds a complete click-through target from a live relay event", () => { + const target = buildEventNotificationTarget( + { + content: "hello", + created_at: 123, + id: "event-id", + kind: 9, + pubkey: "sender", + tags: [ + ["h", "channel-id"], + ["e", "root-id", "", "root"], + ["e", "parent-id", "", "reply"], + ], + }, + { id: "channel-id", name: "ship-room" }, + ); + + assert.deepEqual(target, { + channelId: "channel-id", + channelName: "ship-room", + content: "hello", + createdAt: 123, + eventId: "event-id", + kind: 9, + pubkey: "sender", + threadRootId: "root-id", + }); +}); + +test("null channel name and top-level events produce null fields", () => { + const target = buildEventNotificationTarget( + { + content: "hello", + created_at: 123, + id: "event-id", + kind: 9, + pubkey: "sender", + tags: [["h", "channel-id"]], + }, + { id: "channel-id", name: " " }, + ); + + assert.equal(target.channelName, null); + assert.equal(target.threadRootId, null); +}); + +test("builds a complete click-through target from a feed item", () => { + const target = buildFeedItemNotificationTarget({ + id: "feed-event", + kind: 9, + pubkey: "sender", + content: "ping", + createdAt: 456, + channelId: "channel-id", + channelName: "ship-room", + tags: [ + ["e", "root-id", "", "root"], + ["e", "parent-id", "", "reply"], + ], + category: "mention", + }); + + assert.deepEqual(target, { + channelId: "channel-id", + channelName: "ship-room", + content: "ping", + createdAt: 456, + eventId: "feed-event", + kind: 9, + pubkey: "sender", + threadRootId: "root-id", + }); +}); diff --git a/desktop/src/features/notifications/lib/target.ts b/desktop/src/features/notifications/lib/target.ts new file mode 100644 index 00000000000..be4459b9c18 --- /dev/null +++ b/desktop/src/features/notifications/lib/target.ts @@ -0,0 +1,44 @@ +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { FeedItem, RelayEvent } from "@/shared/api/types"; +import type { DesktopNotificationTarget } from "./desktop"; + +/** + * Build the click-through navigation target for a live relay event (DM or + * thread-reply). Every notification path constructs its target here so the + * payload the OS hands back on activation always carries the full routing + * anchor (eventId + threadRootId), not a hand-rolled subset. + */ +export function buildEventNotificationTarget( + event: Pick< + RelayEvent, + "content" | "created_at" | "id" | "kind" | "pubkey" | "tags" + >, + channel: { id: string; name?: string | null }, +): DesktopNotificationTarget { + return { + channelId: channel.id, + channelName: channel.name?.trim() || null, + content: event.content, + createdAt: event.created_at, + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + threadRootId: getThreadReference(event.tags).rootId ?? null, + }; +} + +/** Build the click-through navigation target for a home-feed item. */ +export function buildFeedItemNotificationTarget( + item: FeedItem, +): DesktopNotificationTarget { + return { + channelId: item.channelId, + channelName: item.channelName, + content: item.content, + createdAt: item.createdAt, + eventId: item.id, + kind: item.kind, + pubkey: item.pubkey, + threadRootId: getThreadReference(item.tags).rootId ?? null, + }; +} diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index b58e260ec2e..4a0865437ed 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -5,15 +5,14 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { getThreadReference } from "@/features/messages/lib/threading"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { collectHomeAlertItems, eligibleFeedNotificationItems, + formatFeedNotification, type NotificationChannel, - notificationBody, - notificationTitle, } from "./lib/feed"; +import { buildFeedItemNotificationTarget } from "./lib/target"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, @@ -112,20 +111,11 @@ export function useFeedDesktopNotifications( const deliverFeedNotification = React.useEffectEvent( async (item: FeedItem, senderName?: string) => { - const threadRootId = getThreadReference(item.tags).rootId ?? null; + const { title, body } = formatFeedNotification(item, senderName); const didSend = await sendDesktopNotification({ - body: notificationBody(item), - target: { - channelId: item.channelId, - channelName: item.channelName, - content: item.content, - createdAt: item.createdAt, - eventId: item.id, - kind: item.kind, - pubkey: item.pubkey, - threadRootId, - }, - title: notificationTitle(item, senderName), + body, + target: buildFeedItemNotificationTarget(item), + title, }); if ( diff --git a/desktop/src/features/notifications/useNotificationSenderName.ts b/desktop/src/features/notifications/useNotificationSenderName.ts new file mode 100644 index 00000000000..9f51e44b492 --- /dev/null +++ b/desktop/src/features/notifications/useNotificationSenderName.ts @@ -0,0 +1,76 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { + usersBatchEntryKey, + type UsersBatchEntry, +} from "@/features/profile/hooks"; +import { + readCachedUserLabels, + writeCachedUserLabels, +} from "@/features/profile/lib/userLabelStorage"; +import { getUsersBatch } from "@/shared/api/tauriProfiles"; +import { senderNameFromSummary } from "./lib/senderName"; + +/** + * Synchronous sender-name lookup for live desktop notifications (DMs and + * thread replies). Toasts must never wait on the network, so resolution is + * cache-only: the per-pubkey `users-batch-entry` React Query cache first + * (kept warm by the sidebar and home feed), then the persisted label cache + * (survives restart). A cold miss returns `null` — the caller falls back to + * neutral copy — and kicks off a background fetch so the sender's next + * message resolves. + */ +export function useNotificationSenderName(): ( + pubkey: string | undefined, +) => string | null { + const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const relayUrl = activeCommunity?.relayUrl ?? ""; + + return React.useCallback( + (pubkey) => { + const normalized = pubkey?.trim().toLowerCase() ?? ""; + if (!normalized) { + return null; + } + + const entry = queryClient.getQueryData( + usersBatchEntryKey(normalized), + ); + if (entry) { + // A stale name still beats no name for a toast; relay-confirmed + // misses (summary: null) correctly resolve to the fallback copy. + return senderNameFromSummary(entry.summary); + } + + const cached = relayUrl + ? readCachedUserLabels(relayUrl, [normalized]) + : undefined; + const cachedSummary = cached?.profiles[normalized]; + if (cachedSummary) { + return senderNameFromSummary(cachedSummary); + } + + void getUsersBatch([normalized]) + .then((fresh) => { + queryClient.setQueryData( + usersBatchEntryKey(normalized), + { + summary: fresh.profiles[normalized] ?? null, + fetchedAt: Date.now(), + }, + ); + if (relayUrl) { + writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing); + } + }) + .catch(() => { + // Warm-up is best effort; the toast already shipped with fallback copy. + }); + return null; + }, + [queryClient, relayUrl], + ); +} diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 7a456fb2591..f174d504080 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -286,12 +286,15 @@ export function useUserProfileQuery(pubkey?: string) { // `summary: null` records a relay-confirmed miss so unknown pubkeys aren't // re-requested every page. Entries older than the hook's 60s staleTime are // treated as unresolved and refetched. -type UsersBatchEntry = { +export type UsersBatchEntry = { summary: UserProfileSummary | null; fetchedAt: number; }; -const usersBatchEntryKey = (pubkey: string) => ["users-batch-entry", pubkey]; +export const usersBatchEntryKey = (pubkey: string) => [ + "users-batch-entry", + pubkey, +]; /** * Drop the per-pubkey delta-fetch entries so the next `useUsersBatchQuery` diff --git a/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts index e52864727f6..c708bffe823 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts @@ -1,9 +1,11 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { deleteManagedAgentWithRules, type ManagedAgentActionResult, } from "@/features/agents/lib/managedAgentControlActions"; +import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; import { removeChannelMember } from "@/shared/api/tauri"; import type { AgentPersona, @@ -46,6 +48,7 @@ export function useProfileAgentDeletion({ presenceLookup, relayAgents, }: UseProfileAgentDeletionInput) { + const queryClient = useQueryClient(); const removeAgentFromAllChannels = React.useCallback( async (agentPubkey: string) => { const normalizedPubkey = agentPubkey.toLowerCase(); @@ -67,8 +70,12 @@ export function useProfileAgentDeletion({ removeChannelMember(channelId, agentPubkey), ), ); + // Direct writes bypass the member mutations' invalidation; without + // this, the deleted agent stays in cached rosters for the freshness + // window. + await invalidateChannelMembersRosters(queryClient, channelIds); }, - [channels, relayAgents], + [channels, queryClient, relayAgents], ); const deleteManagedAgentRecord = React.useCallback( diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 257256c5f63..f82bac1c336 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -134,24 +134,138 @@ export function UserProfilePopover({ const hoverTimerRef = React.useRef | null>( null, ); - const profileQuery = useUserProfileQuery(open ? pubkey : undefined); - const usersBatchQuery = useUsersBatchQuery(open ? [pubkey] : [], { - enabled: open, - }); - const relayAgentsQuery = useRelayAgentsQuery({ - enabled: open, - }); - const managedAgentsQuery = useManagedAgentsQuery({ - enabled: open, - }); - const presenceQuery = usePresenceQuery(open ? [pubkey] : [], { - enabled: open, - }); - const userStatusQuery = useUserStatusQuery(open ? [pubkey] : []); - - const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); const { openProfilePanel } = useProfilePanel(); const canOpenProfilePanel = enableProfilePanel && Boolean(openProfilePanel); + + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + const handleTriggerMouseEnter = React.useCallback(() => { + if (!enableHoverPopover) { + return; + } + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer, enableHoverPopover]); + + const handleMouseLeave = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + + const handleContentMouseEnter = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + const handleTriggerClick = React.useCallback( + (event: React.MouseEvent) => { + clearHoverTimer(); + if (canOpenProfilePanel && openProfilePanel) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + openProfilePanel(pubkey); + } + }, + [canOpenProfilePanel, clearHoverTimer, openProfilePanel, pubkey], + ); + + React.useEffect(() => { + return clearHoverTimer; + }, [clearHoverTimer]); + + const TriggerElement = triggerElement; + return ( + + + { + if ( + (e.key === "Enter" || e.key === " ") && + canOpenProfilePanel && + openProfilePanel + ) { + e.preventDefault(); + e.stopPropagation(); + clearHoverTimer(); + setOpen(false); + openProfilePanel(pubkey); + } + }} + onMouseEnter={handleTriggerMouseEnter} + onMouseLeave={handleMouseLeave} + className={cn( + "inline-flex", + canOpenProfilePanel && "cursor-pointer [&_*]:cursor-pointer", + )} + > + {children} + + + {open ? ( + + ) : null} + + ); +} + +/** + * Everything behind the popover surface: seven query subscriptions, agent + * classification, and the interaction actions. Mounted only while the + * popover is open — the trigger shell above stays cheap enough for grids + * that render hundreds of instances (~40ms per card when this was eager). + */ +function UserProfilePopoverBody({ + botIdenticonValue, + canOpenProfilePanel, + onBeforeAction, + onContentMouseEnter, + onMouseLeave, + onTriggerClick, + pubkey, + role, + setOpen, +}: { + botIdenticonValue?: string; + canOpenProfilePanel: boolean; + onBeforeAction: () => void; + onContentMouseEnter: () => void; + onMouseLeave: () => void; + onTriggerClick: (event: React.MouseEvent) => void; + pubkey: string; + role?: string; + setOpen: (open: boolean) => void; +}) { + const profileQuery = useUserProfileQuery(pubkey); + const usersBatchQuery = useUsersBatchQuery([pubkey]); + const relayAgentsQuery = useRelayAgentsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const presenceQuery = usePresenceQuery([pubkey]); + const userStatusQuery = useUserStatusQuery([pubkey]); + + const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey); const managedAgent = managedAgentsQuery.data?.find( (a) => a.pubkey === pubkey, @@ -160,7 +274,7 @@ export function UserProfilePopover({ const ownerPubkey = profile?.ownerPubkey ?? null; const ownerProfileQuery = useUsersBatchQuery( ownerPubkey ? [ownerPubkey] : [], - { enabled: open && Boolean(ownerPubkey) }, + { enabled: Boolean(ownerPubkey) }, ); const normalizedPubkey = normalizePubkey(pubkey); const isAgentByOaOwner = Boolean( @@ -173,7 +287,6 @@ export function UserProfilePopover({ isAgentByProfileOwner || isAgentByOaOwner; const isAgentClassificationPending = - open && role !== "bot" && (profileQuery.isPending || relayAgentsQuery.isPending || @@ -234,48 +347,10 @@ export function UserProfilePopover({ return map; }, [channelsQuery.data]); - const clearHoverTimer = React.useCallback(() => { - if (hoverTimerRef.current !== null) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - }, []); - - const handleTriggerMouseEnter = React.useCallback(() => { - if (!enableHoverPopover) { - return; - } - clearHoverTimer(); - hoverTimerRef.current = setTimeout(() => { - setOpen(true); - }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); - }, [clearHoverTimer, enableHoverPopover]); - - const handleMouseLeave = React.useCallback(() => { - clearHoverTimer(); - hoverTimerRef.current = setTimeout(() => { - setOpen(false); - }, HOVER_CLOSE_DELAY_MS); - }, [clearHoverTimer]); - - const handleContentMouseEnter = React.useCallback(() => { - clearHoverTimer(); - }, [clearHoverTimer]); - - const handleTriggerClick = React.useCallback( - (event: React.MouseEvent) => { - clearHoverTimer(); - if (canOpenProfilePanel && openProfilePanel) { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - openProfilePanel(pubkey); - } - }, - [canOpenProfilePanel, clearHoverTimer, openProfilePanel, pubkey], + const closeProfileActions = React.useCallback( + () => setOpen(false), + [setOpen], ); - - const closeProfileActions = React.useCallback(() => setOpen(false), []); const { handleHuddle, handleMessage, @@ -290,19 +365,14 @@ export function UserProfilePopover({ wave: showHumanProfileActions, }, effectivePubkey: pubkey, - enabled: open, + enabled: true, isBot: isBotProfile, isSelf, - onBeforeAction: clearHoverTimer, + onBeforeAction: onBeforeAction, onClose: closeProfileActions, viewerIsOwner, }); - React.useEffect(() => { - return clearHoverTimer; - }, [clearHoverTimer]); - - const TriggerElement = triggerElement; const profileHeaderContent = ( <> - - { - if ( - (e.key === "Enter" || e.key === " ") && - canOpenProfilePanel && - openProfilePanel - ) { - e.preventDefault(); - e.stopPropagation(); - clearHoverTimer(); - setOpen(false); - openProfilePanel(pubkey); - } - }} - onMouseEnter={handleTriggerMouseEnter} - onMouseLeave={handleMouseLeave} - className={cn( - "inline-flex", - canOpenProfilePanel && "cursor-pointer [&_*]:cursor-pointer", - )} - > - {children} - - - event.preventDefault()} - side="top" - sideOffset={8} - > -
- {canOpenProfilePanel ? ( - - ) : ( -
- {profileHeaderContent} -
- )} + event.preventDefault()} + side="top" + sideOffset={8} + > +
+ {canOpenProfilePanel ? ( + + ) : ( +
+ {profileHeaderContent} +
+ )} - {isBotProfile && (managedAgent || relayAgent) ? ( -
- {managedAgent?.agentCommand ? ( - {runtimeLabel(managedAgent.agentCommand)} - ) : relayAgent?.agentType ? ( - {runtimeLabel(relayAgent.agentType)} - ) : null} - {managedAgent?.model ? ( - - {resolveModelLabel( - managedAgent.model, - null, - managedAgent.provider, - )} - - ) : null} - {managedAgent?.acpCommand ? ( - ACP: {managedAgent.acpCommand} - ) : null} -
- ) : null} + {isBotProfile && (managedAgent || relayAgent) ? ( +
+ {managedAgent?.agentCommand ? ( + {runtimeLabel(managedAgent.agentCommand)} + ) : relayAgent?.agentType ? ( + {runtimeLabel(relayAgent.agentType)} + ) : null} + {managedAgent?.model ? ( + + {resolveModelLabel( + managedAgent.model, + null, + managedAgent.provider, + )} + + ) : null} + {managedAgent?.acpCommand ? ( + ACP: {managedAgent.acpCommand} + ) : null} +
+ ) : null} - {activeTurns.length > 0 ? ( -
- {activeTurns.map(({ channelId, anchorAt }) => ( - - ))} -
- ) : null} + {activeTurns.length > 0 ? ( +
+ {activeTurns.map(({ channelId, anchorAt }) => ( + + ))} +
+ ) : null} - {canViewActivity ? ( - - ) : null} + {canViewActivity ? ( + + ) : null} - {hasUserStatus || showAnyProfileActions ? ( - <> - - - + {hasUserStatus || showAnyProfileActions ? ( + <> + + ); } diff --git a/desktop/src/features/projects/assignmentOperationFetch.test.mjs b/desktop/src/features/projects/assignmentOperationFetch.test.mjs index 3882d1807e0..c669ee5a261 100644 --- a/desktop/src/features/projects/assignmentOperationFetch.test.mjs +++ b/desktop/src/features/projects/assignmentOperationFetch.test.mjs @@ -285,3 +285,38 @@ test("a failed assignment query surfaces as a failed section instead of silent l assert.ok(result.issues.failedSections.includes("assignments")); }); + +test("fetchAssignmentOperationEvents stops paginating once its signal aborts", async () => { + // A permanently-full page would paginate forever without the cursor; abort + // after the first page and require the loop to stop with AbortError. + const controller = new AbortController(); + let fetches = 0; + const fullPage = (until) => + Array.from({ length: 500 }, (_, index) => ({ + id: `${until ?? "head"}-${index}`.padEnd(64, "0"), + kind: 1, + pubkey: "a".repeat(64), + created_at: 1_000_000 - fetches * 1_000 - index, + content: JSON.stringify({ type: "assign" }), + tags: [], + })); + const fetchEvents = async (filter) => { + fetches += 1; + // Bound the fake: without the abort support the loop would paginate + // forever (each page has a fresh cursor) and OOM the test run. + if (fetches > 3) throw new Error("kept paginating after abort"); + const page = fullPage(filter.until); + controller.abort(); + return page; + }; + + await assert.rejects( + fetchAssignmentOperationEvents( + ["issue".padEnd(64, "1")], + fetchEvents, + controller.signal, + ), + (error) => error.name === "AbortError", + ); + assert.equal(fetches, 1); +}); diff --git a/desktop/src/features/projects/assignmentOperationFetch.ts b/desktop/src/features/projects/assignmentOperationFetch.ts index fbdc90b2186..fa4e2585149 100644 --- a/desktop/src/features/projects/assignmentOperationFetch.ts +++ b/desktop/src/features/projects/assignmentOperationFetch.ts @@ -68,6 +68,7 @@ export async function fetchAssignmentOperationEvents( fetchEvents: ( filter: FetchEventsInput, ) => Promise = relayClient.fetchEvents.bind(relayClient), + signal?: AbortSignal, ): Promise { if (issueIds.length === 0) return []; const chunks: string[][] = []; @@ -75,7 +76,9 @@ export async function fetchAssignmentOperationEvents( chunks.push(issueIds.slice(i, i + ISSUE_ID_CHUNK_SIZE)); } const pages = await Promise.all( - chunks.map((chunk) => fetchIssueCommentsExhaustively(chunk, fetchEvents)), + chunks.map((chunk) => + fetchIssueCommentsExhaustively(chunk, fetchEvents, signal), + ), ); const seen = new Map(); for (const page of pages) { @@ -91,11 +94,15 @@ export async function fetchAssignmentOperationEvents( async function fetchIssueCommentsExhaustively( issueIds: string[], fetchEvents: (filter: FetchEventsInput) => Promise, + signal?: AbortSignal, ): Promise { const seen = new Map(); let limit = ASSIGNMENT_PAGE_LIMIT; let until: number | undefined; for (;;) { + // Leaving the Projects surface cancels its queries; stop queuing pages + // behind the next surface's fetches. + signal?.throwIfAborted(); const page = await fetchEvents({ kinds: [KIND_TEXT_NOTE], "#e": issueIds, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 17ab58d0621..ebfc15a083e 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -168,13 +168,18 @@ export function eventToProject( } export async function fetchProjects( - fetchExhaustively: FetchProjectEventsExhaustively = fetchProjectEventsExhaustively, + fetchExhaustively?: FetchProjectEventsExhaustively, + signal?: AbortSignal, ): Promise { // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which // is the pure, Tauri-free core of this operation. That helper's javadoc // explains the fail-closed tombstone contract and the NIP-OA owner-deletion // relay-side-suppression decision. - return buildProjectsFromFetcher(fetchExhaustively, { + const fetcher: FetchProjectEventsExhaustively = + fetchExhaustively ?? + ((kinds, extraFilter) => + fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal)); + return buildProjectsFromFetcher(fetcher, { relayOrigin: getCachedRelayOrigin(), hiddenAddresses: new Set(readHiddenProjectCards()), }); @@ -637,22 +642,45 @@ async function deleteProject(project: Project): Promise { export const projectsQueryKey = ["projects"] as const; -export function useProjectsQuery() { +/** + * Freshness windows for the Projects surface. Every local write path + * invalidates its keys explicitly (issue/PR mutations, project creation, + * repo sync), so short windows bought nothing for own-actions and charged a + * full relay fan-out — project enumeration is an exhaustive paginated scan, + * work items are five 2,000-event queries — on nearly every Projects + * re-entry. Remote actors' changes surface within the window. gcTime keeps + * the enumeration cached across visits so re-entering Projects paints from + * cache instead of blocking on the scan. + */ +export const PROJECTS_STALE_TIME_MS = 5 * 60_000; +// Window-guarded like react-query's own server default (Infinity): an +// explicit finite gcTime schedules a real, non-unref'd timeout per cache +// entry, which keeps node test processes alive for the full 30 minutes. +export const PROJECTS_GC_TIME_MS = + typeof window === "undefined" ? Number.POSITIVE_INFINITY : 30 * 60_000; +export const PROJECT_WORK_ITEMS_STALE_TIME_MS = 2 * 60_000; +export const PROJECT_ACTIVITY_STALE_TIME_MS = 2 * 60_000; +export const PROJECT_LOCAL_REPOS_STALE_TIME_MS = 2 * 60_000; + +export function useProjectsQuery(enabled = true) { return useQuery({ queryKey: projectsQueryKey, - queryFn: () => fetchProjects(), - staleTime: 60_000, + queryFn: ({ signal }) => fetchProjects(undefined, signal), + staleTime: PROJECTS_STALE_TIME_MS, + gcTime: PROJECTS_GC_TIME_MS, + enabled, }); } export function useProjectQuery(projectId: string) { return useQuery({ queryKey: projectsQueryKey, - queryFn: () => fetchProjects(), + queryFn: ({ signal }) => fetchProjects(undefined, signal), select: (projects) => projects.find((project) => projectMatchesRouteId(project, projectId)) ?? null, - staleTime: 60_000, + staleTime: PROJECTS_STALE_TIME_MS, + gcTime: PROJECTS_GC_TIME_MS, }); } @@ -793,7 +821,8 @@ export function useProjectLocalRepositoriesQuery(reposDir?: string | null) { return useQuery({ queryKey: ["projects", "local-repositories", reposDir ?? "default"], queryFn: () => listProjectLocalRepositories({ reposDir }), - staleTime: 10_000, + // Filesystem scan; repo sync/clone flows invalidate this key on change. + staleTime: PROJECT_LOCAL_REPOS_STALE_TIME_MS, retry: 1, }); } @@ -828,9 +857,22 @@ export function useProjectPullRequestsQuery( export function useProjectsWorkItemsQuery(projects: Project[]) { return useQuery({ enabled: projects.length > 0, - queryKey: ["projects", "work-items", projects.map((project) => project.id)], - queryFn: () => fetchProjectsWorkItems(projects), - staleTime: 30_000, + queryKey: [ + "projects", + "work-items", + projects.map((project) => project.id), + // Repo attach/detach changes the fan-out inputs without changing + // project ids; keying on addresses too prevents a pre-attach result + // from serving as fresh for the whole staleTime window. + projects + .flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ) + .sort(), + ], + queryFn: ({ signal }) => + fetchProjectsWorkItems(projects, undefined, signal), + staleTime: PROJECT_WORK_ITEMS_STALE_TIME_MS, }); } @@ -935,7 +977,7 @@ export function useProjectActivitySummariesQuery(projects: Project[]) { enabled: repoAddresses.length > 0, queryKey: ["projects", "activity-summaries", repoAddresses], queryFn: () => fetchProjectActivitySummaries(projects), - staleTime: 30_000, + staleTime: PROJECT_ACTIVITY_STALE_TIME_MS, }); } diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs index 7febdf8b678..36066132c42 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs @@ -6,6 +6,7 @@ import { buildProjectSelectionAgentContext, buildProjectsOverviewAgentContext, projectDetailAgentContextBlock, + splitProjectDetailAgentContext, stripProjectDetailAgentContext, untrustedPromptValue, withProjectSelectionAgentContext, @@ -215,8 +216,44 @@ test("selected project context enforces a final serialization budget", () => { }); test("strips hidden page context from the displayed user message", () => { - const content = `Explain this file${projectDetailAgentContextBlock( + const payload = projectDetailAgentContextBlock( buildProjectDetailAgentContext(base), - )}`; + ); + const content = `Explain this file${payload}`; assert.equal(stripProjectDetailAgentContext(content), "Explain this file"); + assert.deepEqual(splitProjectDetailAgentContext(content), { + context: payload.trim(), + message: "Explain this file", + }); +}); + +test("leaves ordinary messages unchanged without inventing context", () => { + assert.deepEqual(splitProjectDetailAgentContext("A normal message"), { + context: null, + message: "A normal message", + }); +}); + +test("splits only the final appended context marker", () => { + const userMessage = + "Discuss this literal example:\n---\nCurrent Buzz project page:\nnot appended"; + const payload = projectDetailAgentContextBlock( + buildProjectDetailAgentContext(base), + ); + assert.deepEqual(splitProjectDetailAgentContext(`${userMessage}${payload}`), { + context: payload.trim(), + message: userMessage, + }); +}); + +test("splits workspace repository context for the shared conversation view", () => { + const payload = + '\n---\nWorkspace repositories:\n- "Buzz" (address: "owner:buzz")'; + assert.deepEqual( + splitProjectDetailAgentContext(`Compare the repos${payload}`), + { + context: payload.trim(), + message: "Compare the repos", + }, + ); }); diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.ts b/desktop/src/features/projects/lib/projectDetailAgentContext.ts index 0194bad3e18..97142157007 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.ts +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.ts @@ -5,6 +5,12 @@ import { } from "./projectSelection.ts"; const PROJECT_PAGE_CONTEXT_MARKER = "Current Buzz project page:"; +/** Marker for the repository set appended by the full Projects agent page. */ +export const PROJECT_WORKSPACE_CONTEXT_MARKER = "Workspace repositories:"; +const PROJECT_AGENT_CONTEXT_MARKERS = [ + PROJECT_PAGE_CONTEXT_MARKER, + PROJECT_WORKSPACE_CONTEXT_MARKER, +]; const MAX_OVERVIEW_CONTEXT_ITEMS = 200; const MAX_OVERVIEW_CONTEXT_FIELD_LENGTH = 180; const MAX_SELECTION_CONTEXT_ITEMS = 100; @@ -323,8 +329,24 @@ function overviewContextField(value: string | null | undefined) { return normalizedPromptValue(value, MAX_OVERVIEW_CONTEXT_FIELD_LENGTH); } +export function splitProjectDetailAgentContext(content: string): { + context: string | null; + message: string; +} { + const markerIndex = Math.max( + ...PROJECT_AGENT_CONTEXT_MARKERS.map((marker) => + content.lastIndexOf(`---\n${marker}`), + ), + ); + if (markerIndex === -1) { + return { context: null, message: content }; + } + return { + context: content.slice(markerIndex).trim(), + message: content.slice(0, markerIndex).replace(/\n+$/, ""), + }; +} + export function stripProjectDetailAgentContext(content: string) { - const markerIndex = content.indexOf(`---\n${PROJECT_PAGE_CONTEXT_MARKER}`); - if (markerIndex === -1) return content; - return content.slice(0, markerIndex).replace(/\n+$/, ""); + return splitProjectDetailAgentContext(content).message; } diff --git a/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs b/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs new file mode 100644 index 00000000000..2f89decab42 --- /dev/null +++ b/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + currentPullRequestForSelection, + projectReviewFilesChangedBody, + retainLatestByKey, + reviewDiffWorkspaceBranch, + shouldReplaceRetainedPullRequest, +} from "./projectReviewDisplay.ts"; + +test("retainLatestByKey keeps the previous value when shouldReplace is false", () => { + const cache = { current: { key: "pr-1", value: { files: [1] } } }; + + const retained = retainLatestByKey( + cache, + "pr-1", + { files: [] }, + (next, previous) => + next.files.length > 0 ? true : previous.files.length === 0, + ); + + assert.deepEqual(retained, { files: [1] }); + assert.deepEqual(cache.current.value, { files: [1] }); +}); + +test("retainLatestByKey takes a new key immediately", () => { + const cache = { current: { key: "pr-1", value: { files: [1] } } }; + + const retained = retainLatestByKey( + cache, + "pr-2", + { files: [] }, + (next) => next.files.length > 0, + ); + + assert.deepEqual(retained, { files: [] }); +}); + +test("an explicit selected review does not fall back to another identity", () => { + const selected = { id: "pr-a" }; + const branchReview = { id: "pr-branch" }; + + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [selected, branchReview], + selectedPullRequestId: "pr-a", + }), + selected, + ); + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [branchReview], + selectedPullRequestId: "pr-a", + }), + null, + ); + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [branchReview], + selectedPullRequestId: null, + }), + branchReview, + ); +}); + +test("retained review identity stays aligned with the diff-query identity across fetch phases", () => { + const reviewA = { id: "pr-a" }; + const renderedCache = { current: { key: "repo:pr-a", value: reviewA } }; + const diffQueryCache = { current: { key: "repo:pr-a", value: reviewA } }; + + const renderedDuringFetch = retainLatestByKey( + renderedCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, true), + ); + const diffDuringFetch = retainLatestByKey( + diffQueryCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, true), + ); + assert.equal(renderedDuringFetch, reviewA); + assert.equal(diffDuringFetch, reviewA); + assert.equal(renderedDuringFetch.id, diffDuringFetch.id); + + const renderedAfterComplete = retainLatestByKey( + renderedCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, false), + ); + const diffAfterComplete = retainLatestByKey( + diffQueryCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, false), + ); + assert.equal(renderedAfterComplete, null); + assert.equal(diffAfterComplete, null); +}); + +test("review files stay mounted when a populated diff races an unavailable snapshot", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: true, + hasSelectedPullRequest: true, + repositoryUnavailable: true, + }), + "files", + ); +}); + +test("review files can show unavailable before a diff exists", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: false, + hasSelectedPullRequest: true, + repositoryUnavailable: true, + }), + "unavailable", + ); +}); + +test("review files render the panel for a selected review when the repo is available", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: false, + hasSelectedPullRequest: true, + repositoryUnavailable: false, + }), + "files", + ); +}); + +test("review diffs stay on the target branch, not the head or picker branch", () => { + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: { targetBranch: "main" }, + }), + "main", + ); + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: { targetBranch: null }, + }), + "main", + ); + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: null, + }), + "variation/bees", + ); +}); diff --git a/desktop/src/features/projects/lib/projectReviewDisplay.ts b/desktop/src/features/projects/lib/projectReviewDisplay.ts new file mode 100644 index 00000000000..d7ba721238c --- /dev/null +++ b/desktop/src/features/projects/lib/projectReviewDisplay.ts @@ -0,0 +1,101 @@ +type RetainLatestByKeyCache = { + current: { + key: string; + value: T; + }; +}; + +/** + * Keep the latest accepted value for a stable key across transient empties. + * A new key always takes `value` immediately (real navigation). + */ +export function retainLatestByKey( + cache: RetainLatestByKeyCache, + key: string, + value: T, + shouldReplace: (next: T, previous: T) => boolean, +): T { + if (cache.current.key !== key) { + cache.current = { key, value }; + return value; + } + if (shouldReplace(value, cache.current.value)) { + cache.current.value = value; + } + return cache.current.value; +} + +/** + * Keep a selected review through transient empty refetches. Once the fetch + * is idle, accept the completed result — including null — so the rendered + * identity can match the diff-query identity. + */ +export function shouldReplaceRetainedPullRequest( + next: unknown, + isFetching: boolean, +): boolean { + return Boolean(next) || !isFetching; +} + +/** + * Resolve the current review for a selection. An explicit ID that is missing + * from the list is `null` so a completed refetch can clear it; pass + * `fallback` only when no ID is selected (branch auto-select). + */ +export function currentPullRequestForSelection({ + fallback = null, + pullRequests, + selectedPullRequestId, +}: { + fallback?: T | null; + pullRequests: readonly T[] | undefined; + selectedPullRequestId: string | null; +}): T | null { + if (selectedPullRequestId) { + return ( + pullRequests?.find((item) => item.id === selectedPullRequestId) ?? null + ); + } + return fallback; +} + +/** + * Which body to render under a review's Files changed section. + * A populated diff must keep the files panel mounted even when the repository + * snapshot briefly looks unavailable — swapping in the unavailable placeholder + * is the files-section flicker. + */ +export function projectReviewFilesChangedBody({ + hasPopulatedDiff, + hasSelectedPullRequest, + repositoryUnavailable, +}: { + hasPopulatedDiff: boolean; + hasSelectedPullRequest: boolean; + repositoryUnavailable: boolean; +}): "files" | "unavailable" | null { + if (hasSelectedPullRequest && (hasPopulatedDiff || !repositoryUnavailable)) { + return "files"; + } + if (repositoryUnavailable) return "unavailable"; + return null; +} + +/** + * Workspace branch used to fetch a review diff. + * A selected review is `target...head`; do not key that query on the head + * branch or the workspace picker, or Files changed will swap with the + * default-branch snapshot. + */ +export function reviewDiffWorkspaceBranch({ + activeBranch, + defaultBranch, + pullRequest, +}: { + activeBranch: string | null | undefined; + defaultBranch: string | null | undefined; + pullRequest: { targetBranch: string | null } | null | undefined; +}): string | null | undefined { + if (!pullRequest) return activeBranch; + return pullRequest.targetBranch || defaultBranch || activeBranch; +} diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs index 240546b8a4b..49cec525162 100644 --- a/desktop/src/features/projects/projectEnumeration.test.mjs +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -245,3 +245,31 @@ test("buildProjectsFromFetcher still suppresses deleted heads via the scoped fet const projects = await buildProjectsFromFetcher(fetchExhaustively); assert.deepEqual(projects, [], "deleted repo must not surface as a project"); }); + +test("enumerateProjectEvents stops paginating once its signal aborts", async () => { + // 3 full pages of 2 → without an abort the enumeration would fetch all of + // them plus boundary drains. Abort after the first page: the loop must + // throw before requesting another page. + const events = [ + relayEvent("a", 1_000), + relayEvent("b", 900), + relayEvent("c", 800), + relayEvent("d", 700), + relayEvent("e", 600), + relayEvent("f", 500), + ]; + const controller = new AbortController(); + let pageFetches = 0; + const fetchPage = async (filter) => { + pageFetches += 1; + const page = await fetcherFor(events)(filter); + controller.abort(); + return page; + }; + + await assert.rejects( + enumerateProjectEvents(fetchPage, [30617], 2, undefined, controller.signal), + (error) => error.name === "AbortError", + ); + assert.equal(pageFetches, 1); +}); diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index ab1502c7577..ac494321042 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -44,6 +44,7 @@ export async function enumerateProjectEvents( kinds: number[], pageSize: number, extraFilter?: ProjectEventExtraFilter, + signal?: AbortSignal, ): Promise { if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { throw new Error( @@ -55,6 +56,10 @@ export async function enumerateProjectEvents( let until: number | undefined; for (;;) { + // Each relay REQ is bounded, but this loop is not: leaving the Projects + // surface cancels its queries, and the abort must stop the enumeration + // from queuing further pages behind the next surface's fetches. + signal?.throwIfAborted(); const page = await fetchPage({ ...extraFilter, kinds, @@ -65,6 +70,7 @@ export async function enumerateProjectEvents( if (page.length < pageSize) return [...eventsById.values()]; const oldest = Math.min(...page.map((event) => event.created_at)); + signal?.throwIfAborted(); const boundary = await fetchPage({ ...extraFilter, kinds, @@ -94,12 +100,14 @@ export function fetchProjectEventsExhaustively( kinds: number[], extraFilter?: ProjectEventExtraFilter, pageSize = PROJECT_ENUMERATION_PAGE_SIZE, + signal?: AbortSignal, ): Promise { return enumerateProjectEvents( (filter) => relayClient.fetchEvents(filter), kinds, pageSize, extraFilter, + signal, ); } diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index 80ab32c62a5..11acbc8b34b 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -82,6 +82,7 @@ export async function fetchProjectsWorkItems( fetchEvents: ( filter: FetchEventsInput, ) => Promise = relayClient.fetchEvents.bind(relayClient), + signal?: AbortSignal, ): Promise> { const repoAddresses = [ ...new Set( @@ -129,10 +130,17 @@ export async function fetchProjectsWorkItems( .filter((event) => event.kind === KIND_GIT_ISSUE) .map((event) => event.id), fetchEvents, + signal, ), ), ]); + // The five eager queries above are single bounded REQs the relay client + // cannot abort mid-flight; only the assignment pagination is abort-aware. + // What cancellation CAN save here is the reduce work below and caching a + // result for a surface the user already left. + signal?.throwIfAborted(); + if (rootResult.status === "rejected") { throw rootResult.reason instanceof Error ? rootResult.reason diff --git a/desktop/src/features/projects/repositoryActivityHooks.ts b/desktop/src/features/projects/repositoryActivityHooks.ts index 10733116a88..39a9cdb98a4 100644 --- a/desktop/src/features/projects/repositoryActivityHooks.ts +++ b/desktop/src/features/projects/repositoryActivityHooks.ts @@ -3,6 +3,7 @@ import * as React from "react"; import { fetchRepositoryActivitySummaries, + PROJECT_ACTIVITY_STALE_TIME_MS, type Project, } from "@/features/projects/hooks"; @@ -27,6 +28,6 @@ export function useRepositoryActivitySummariesQuery(projects: Project[]) { enabled: repoAddresses.length > 0, queryKey: ["projects", "activity-summaries", "repositories", repoAddresses], queryFn: () => fetchRepositoryActivitySummaries(repositories), - staleTime: 30_000, + staleTime: PROJECT_ACTIVITY_STALE_TIME_MS, }); } diff --git a/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs b/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs index 749d5eb5011..6229c775389 100644 --- a/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs +++ b/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs @@ -33,7 +33,7 @@ afterEach(async () => { after(() => dom.window.close()); -async function renderPreview(payload) { +async function renderPreview(payload, options = {}) { const { createElement } = await import("react"); const { render } = await import("@testing-library/react"); const { AgentContextPayloadPreview } = await import( @@ -41,8 +41,9 @@ async function renderPreview(payload) { ); return render( createElement(AgentContextPayloadPreview, { + iconOnly: options.iconOnly, payload, - triggerLabel: "Context", + triggerLabel: options.triggerLabel ?? "Context", }), ); } @@ -83,6 +84,24 @@ test("discloses the exact appended payload before send, adversarial metadata inc assert.equal(screen.queryByTestId("agent-context-preview"), null); }); +test("supports a subtle icon-only disclosure without losing its accessible name", async () => { + const { fireEvent, screen } = await import("@testing-library/react"); + await renderPreview("Exact context", { + iconOnly: true, + triggerLabel: "Preview message context", + }); + + const trigger = screen.getByRole("button", { + name: "Preview message context", + }); + assert.equal(trigger.textContent?.trim(), ""); + fireEvent.click(trigger); + assert.equal( + screen.getByTestId("agent-context-preview-payload").textContent, + "Exact context", + ); +}); + test("renders nothing when there is no payload to append", async () => { const { screen } = await import("@testing-library/react"); await renderPreview(""); diff --git a/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx b/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx index d6db47b8686..550cadd86aa 100644 --- a/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx +++ b/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx @@ -1,6 +1,7 @@ import { Info } from "lucide-react"; import * as React from "react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; /** @@ -13,9 +14,11 @@ import { Button } from "@/shared/ui/button"; * inspects here is byte-identical to what gets signed under their key. */ export function AgentContextPayloadPreview({ + iconOnly = false, payload, triggerLabel, }: { + iconOnly?: boolean; payload: string; triggerLabel: string; }) { @@ -25,8 +28,14 @@ export function AgentContextPayloadPreview({ return (
{open ? (
{ + const byChannel = new Map(); + for (const hit of hits) { + if (hit.channelId && !byChannel.has(hit.channelId)) { + byChannel.set(hit.channelId, hit); + } + } + return byChannel; + }, [hits]); +} + +/** + * Shared row-click behavior: land on the latest matching message — in the + * side conversation panel when one is mounted — instead of jumping straight + * to the channel. Forum content opens in place (the panel renders chat + * threads only), and channels with no quotable hit fall back to plain + * channel navigation. + */ +function openDiscussionHit({ + channelId, + goChannel, + latestHit, + openSearchHit, + panel, +}: { + channelId: string; + goChannel: (channelId: string) => unknown; + latestHit: SearchHit | undefined; + openSearchHit: (hit: SearchHit) => unknown; + panel: { openConversation: (hit: SearchHit) => void } | null; +}) { + if (!latestHit) { + void goChannel(channelId); + return; + } + const opensForum = + latestHit.kind === KIND_FORUM_POST || latestHit.kind === KIND_FORUM_COMMENT; + if (!panel || opensForum) { + void openSearchHit(latestHit); + return; + } + panel.openConversation(latestHit); +} + /** Channel display name, preferring the hit's name, then bounded metadata, * then a short id so inaccessible/renamed channels still render something. */ function useChannelNameLookup(channelIds: readonly string[]) { @@ -141,19 +188,10 @@ export function DiscussedInChannels({ { enabled: visible.length > 0 }, ); const profiles = profilesQuery.data?.profiles; - // Hits are sorted newest first, so the first hit per channel is the one a - // click should land on (and the one worth quoting). The origin channel has - // no such hit: the `h` tag proves only the channel, so its row navigates - // to the channel without claiming any particular message. - const latestHitByChannel = React.useMemo(() => { - const byChannel = new Map(); - for (const hit of hits) { - if (hit.channelId && !byChannel.has(hit.channelId)) { - byChannel.set(hit.channelId, hit); - } - } - return byChannel; - }, [hits]); + // The origin channel has no quotable hit: the `h` tag proves only the + // channel, so its row navigates to the channel without claiming any + // particular message. + const latestHitByChannel = useLatestHitByChannel(hits); if (channels.length === 0) return null; const hiddenCount = channels.length - visible.length; @@ -173,21 +211,14 @@ export function DiscussedInChannels({ {visible.map((channel) => { const latestHit = latestHitByChannel.get(channel.id); const name = channelName(channel.id, channel.name); - const opensForum = - latestHit != null && - (latestHit.kind === KIND_FORUM_POST || - latestHit.kind === KIND_FORUM_COMMENT); - const openConversation = () => { - if (!latestHit) { - void goChannel(channel.id); - return; - } - if (!projectConversationPanel || opensForum) { - void openSearchHit(latestHit); - return; - } - projectConversationPanel.openConversation(latestHit); - }; + const openConversation = () => + openDiscussionHit({ + channelId: channel.id, + goChannel, + latestHit, + openSearchHit, + panel: projectConversationPanel, + }); return (
@@ -336,7 +367,9 @@ function DiscussionNameList({ /** * Full-width channel list for the workspace "Channels" tab: every channel * where the repository (or its PRs/issues) is linked in chat, with the - * people who discussed it there. + * people who discussed it there. Clicking a row opens the latest matching + * conversation in the side panel (whose header still jumps to the channel) + * rather than leaving the project view. */ export function DiscussionChannelsPanel({ query, @@ -345,13 +378,17 @@ export function DiscussionChannelsPanel({ query: string; repositoryName: string; }) { - const { channels, isLoading, isTruncated } = useDiscussionChannels(query); - const { goChannel } = useAppNavigation(); + const { channels, hits, isLoading, isTruncated } = + useDiscussionChannels(query); + const { goChannel, openSearchHit } = useAppNavigation(); + const projectConversationPanel = useProjectConversationPanel(); + const latestHitByChannel = useLatestHitByChannel(hits); const channelIds = React.useMemo( () => channels.map((channel) => channel.id), [channels], ); const channelName = useChannelNameLookup(channelIds); + const profilesQuery = useUsersBatchQuery( channels.flatMap((channel) => channel.participants), { enabled: channels.length > 0 }, @@ -363,7 +400,10 @@ export function DiscussionChannelsPanel({ } if (channels.length === 0) { return ( -

+

No channels reference this repository yet. Paste its link (or a review or task link) in a channel and it will show up here.

@@ -379,10 +419,11 @@ export function DiscussionChannelsPanel({ ); return ( -
+
    {channels.map((channel) => { const name = channelName(channel.id, channel.name); + const latestHit = latestHitByChannel.get(channel.id); return (
  • } - onClick={() => void goChannel(channel.id)} + onClick={() => + openDiscussionHit({ + channelId: channel.id, + goChannel, + latestHit, + openSearchHit, + panel: projectConversationPanel, + }) + } people={channel.participants} peopleTestId="project-channel-participants" profiles={profiles} @@ -411,7 +460,11 @@ export function DiscussionChannelsPanel({ }} testId="project-channel-row" title={`#${name}`} - titleAttr={`Open #${name}`} + titleAttr={ + latestHit + ? `Open the latest conversation in #${name}` + : `Open #${name}` + } />
  • ); diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 4da5123814a..74130447270 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -267,7 +267,9 @@ export function IssueAssigneesRow({ {canSelfAssign && viewer ? ( + {open ? ( +
    + {payload} +
    + ) : null} +
+ ); + }, +); diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index b8c3ffd273d..e9672d643bd 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -20,10 +20,8 @@ import type { ProjectActivitySummary, } from "@/features/projects/hooks"; import { - formatExactTimestamp, getProjectUpdatedAt, listRowDescription, - relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; @@ -55,39 +53,6 @@ import { ProjectEntityListRow } from "./ProjectEntityListRow"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; -function ProjectUpdatedLabel({ - profiles, - project, - summary, -}: { - profiles?: UserProfileLookup; - project: Project; - summary: ProjectActivitySummary | undefined; -}) { - const updatedAt = getProjectUpdatedAt(project, summary); - const latestCommit = summary?.latestCommit; - const authorLabel = latestCommit?.author - ? resolveUserLabel({ profiles, pubkey: latestCommit.author }) - : null; - - return ( - - - - {relativeTime(updatedAt)} - - - - {latestCommit - ? `${latestCommit.title || latestCommit.commit.slice(0, 7)}${ - authorLabel ? ` · ${authorLabel}` : "" - } · ${formatExactTimestamp(latestCommit.createdAt)}` - : `Created ${formatExactTimestamp(project.createdAt)}`} - - - ); -} - export function ProjectPeopleStack({ pubkeys, profiles, @@ -489,7 +454,9 @@ type ProjectItemProps = { onOpenTerminal: (project: Project) => Promise | void; }; -export function ProjectGridCard({ +// Memoized: these cards render in unbounded grids/lists; identity-stable +// props from the caller keep re-renders scoped to genuinely changed cards. +export const ProjectGridCard = React.memo(function ProjectGridCard({ project, people, profiles, @@ -504,7 +471,7 @@ export function ProjectGridCard({ }: ProjectItemProps) { return ( @@ -540,12 +507,7 @@ export function ProjectGridCard({
-
- +
); -} +}); -export function ProjectListRow({ +export const ProjectListRow = React.memo(function ProjectListRow({ project, people, profiles, @@ -613,6 +575,7 @@ export function ProjectListRow({ {repositoryCount} } + affiliationClassName="w-auto" affiliationTestId="projects-row-context" affiliationTitle={`${repositoryCount} ${ repositoryCount === 1 ? "repository" : "repositories" @@ -656,7 +619,7 @@ export function ProjectListRow({ } /> ); -} +}); /** Compact, borderless repository row for the overview side rail. */ export function ProjectRailRow({ diff --git a/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx b/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx index 0930146e2db..faadaf5d2c4 100644 --- a/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx @@ -15,12 +15,16 @@ export function CopyTextButton({ text: string; }) { const [copied, setCopied] = React.useState(false); - const handleCopy = React.useCallback(() => { - void writeTextToClipboard(text).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2_000); - }); - }, [text]); + const handleCopy = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + void writeTextToClipboard(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2_000); + }); + }, + [text], + ); return ( - {open ?
{children}
: null} + {open ? ( +
{children}
+ ) : null} ); } diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx index 10cd1f87a41..d0769e31e50 100644 --- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx +++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx @@ -131,6 +131,7 @@ export function ProjectEntitySelectControl({ export function ProjectEntityListRow({ affiliation, + affiliationClassName, affiliationTestId, affiliationTitle, beforeDate, @@ -158,6 +159,7 @@ export function ProjectEntityListRow({ trailing, }: { affiliation?: React.ReactNode; + affiliationClassName?: string; affiliationTestId?: string; affiliationTitle?: string; beforeDate?: React.ReactNode; @@ -292,7 +294,10 @@ export function ProjectEntityListRow({ ) : null} {affiliation ? ( {count != null ? ( - + {count} {countSuffix} diff --git a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx index 105d20f10ee..72884a33ed3 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx @@ -755,8 +755,10 @@ export function ProjectPullRequestFilesChangedPanel({ } export function ProjectDiffFilesPanel({ + className, error, diff, + fileTreeClassName, isLoading, embedded = false, focusedAnchor, @@ -764,6 +766,10 @@ export function ProjectDiffFilesPanel({ inlineComments, subjectLabel, }: { + /** Extra classes for the file-tree/diff grid container. */ + className?: string; + /** Overrides the file tree's default `max-h-96` cap, e.g. for full-height layouts. */ + fileTreeClassName?: string; error: unknown; diff: ProjectRepoDiff | null | undefined; isLoading: boolean; @@ -814,11 +820,11 @@ export function ProjectDiffFilesPanel({ } }, [filteredFiles, selectedPath]); - if (isLoading) { + if (isLoading && !diff) { return ; } - if (error) { + if (error && !diff) { const message = errorMessage(error); return (
@@ -876,7 +883,12 @@ export function ProjectDiffFilesPanel({ />
-