diff --git a/.env.example b/.env.example index 02e907b8cfd..6d127382479 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,21 @@ REDIS_URL=redis://localhost:6379 # READ_DATABASE_URL is set, reader (default 50). # BUZZ_DB_POOL_SIZE=50 +# Writer-session Postgres timeouts for buzz-db-backed pools and the relay audit +# pool, all in milliseconds; 0 disables. The separately deployed push gateway +# owns its own database and session policy and does not consume these knobs. +# lock_timeout: fail a statement that waits this long on any lock instead of +# parking behind a wedged holder (default 5000). +# BUZZ_DB_LOCK_TIMEOUT_MS=5000 +# idle_in_transaction_session_timeout: reap sessions idle inside an open +# transaction — bounds how long a wedged client can hold locks (default 60000). +# BUZZ_DB_IDLE_TXN_TIMEOUT_MS=60000 +# statement_timeout: cap any single statement's runtime. Off by default — +# startup migrations/backfills legitimately run long statements. Warning: a +# pathologically low value (e.g. 1) also times out connection setup and can +# prevent any DB connection from establishing. +# BUZZ_DB_STATEMENT_TIMEOUT_MS=0 + # ----------------------------------------------------------------------------- # Typesense (search) # ----------------------------------------------------------------------------- @@ -259,6 +274,10 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # app launch while keeping the current identity and relay data. # VITE_BUZZ_FORCE_FRESH_ONBOARDING=true +# Protected internal builds only: selects the module graph that contains the +# default-off Bestie experiment. Official OSS builds must leave this unset. +# VITE_BUZZ_BESTIE=1 + # ── Subscription & filtering ───────────────────────────────────────────────── # Subscribe mode: "mentions" (default), "all", or "config" (rule-based). # BUZZ_ACP_SUBSCRIBE=mentions @@ -282,6 +301,14 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Set to true to process the agent's own messages (default: ignore self). # BUZZ_ACP_NO_IGNORE_SELF=false +# ── Session scoping ────────────────────────────────────────────────────────── +# How ACP provider sessions are scoped in channels: "channel" (default) or +# "thread". "channel" keeps one provider session per channel (legacy). "thread" +# gives each canonical channel thread its own isolated provider session; direct +# messages stay conversation-scoped either way. Ships as "channel" so thread +# scoping can be canaried and rolled back without code changes. +# BUZZ_ACP_SESSION_POLICY=channel + # ── Context ────────────────────────────────────────────────────────────────── # Max context messages fetched for thread replies and DMs (0–100). 0 = disabled. # BUZZ_ACP_CONTEXT_MESSAGE_LIMIT=12 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da00fc21019..c509848b66c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,7 +306,7 @@ jobs: name: Desktop runs-on: ubuntu-latest timeout-minutes: 5 - needs: [changes, desktop-core, desktop-smoke-e2e] + needs: [changes, desktop-core, desktop-smoke-e2e, desktop-windows-build] if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') permissions: contents: read @@ -323,6 +323,10 @@ jobs: echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" exit 1 fi + if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then + echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" + exit 1 + fi echo "Desktop jobs passed" desktop-e2e-relay: @@ -384,6 +388,7 @@ jobs: -p buzz-relay \ -p buzz-test-client \ --lib \ + --bin buzz-relay \ --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache @@ -688,6 +693,18 @@ jobs: VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" + - name: Workflow message provenance tests + # The relay's workflow_sink suite is not selected by the infra-free + # unit job. Run both its pure tests and ignored PostgreSQL tests here so + # authored-template provenance cannot regress behind a green CI build. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/workflow_sink/)' \ + --run-ignored all + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Replaceable persistence PostgreSQL tests # Transaction, concurrency, and mention-index coverage for the # replaceable-event store seam. These tests require real Postgres and @@ -713,6 +730,31 @@ jobs: env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Writer session timeout guardrails + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(session_timeouts_install_through_db_new_and_bound_lock_waits)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Audit writer session timeout guardrails + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Audit worker lock-timeout recovery + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(audit_worker_retries_lock_timeout_until_original_entry_is_appended_once)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Start relay run: | chmod +x ./target/ci/buzz-relay @@ -1107,6 +1149,36 @@ jobs: -p git-credential-nostr \ -p git-sign-nostr + desktop-windows-build: + name: Desktop Windows Build + runs-on: windows-latest + timeout-minutes: 20 + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + - name: Install desktop dependencies + shell: bash + run: pnpm install --frozen-lockfile + - name: Build both protected-feature selections + shell: pwsh + run: | + Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:VITE_BUZZ_BESTIE = "1" + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) runs-on: windows-latest diff --git a/.github/workflows/codex-security-review.yml b/.github/workflows/codex-security-review.yml index df558a877d7..68ff0553c07 100644 --- a/.github/workflows/codex-security-review.yml +++ b/.github/workflows/codex-security-review.yml @@ -214,7 +214,7 @@ jobs: if: needs.prepare-review.outputs.authorized == 'true' runs-on: ubuntu-latest environment: codex-review - timeout-minutes: 30 + timeout-minutes: 40 concurrency: group: codex-security-review-${{ needs.prepare-review.outputs.pr_number }} cancel-in-progress: true @@ -228,7 +228,7 @@ jobs: REVIEW_REPOSITORY: review-target REVIEW_DIFF_FILE: .git/codex-review.diff outputs: - review_json: ${{ steps.run_codex.outputs.final-message }} + review_json: ${{ steps.salvage.outputs.review_json }} steps: - name: Checkout exact pull request head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -287,6 +287,12 @@ jobs: # action's local proxy rather than entering the Codex subprocess. - name: Review pull request id: run_codex + # Codex CLI ≥0.149.x can leave a PTY descendant holding inherited stdio + # after the turn completes, stalling the action indefinitely. The output + # file is written before the hang, so a timeout here wastes at most 30 + # minutes instead of the full 40, and the salvage step recovers the result. + timeout-minutes: 30 + continue-on-error: true uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 env: # Checkout and fetch are complete. Remove runner credentials from the @@ -306,6 +312,8 @@ jobs: safety-strategy: drop-sudo permission-profile: ':read-only' working-directory: ${{ github.workspace }}/${{ env.REVIEW_CONTEXT }} + # Written before the hang; salvaged below if the step times out. + output-file: ${{ runner.temp }}/codex-review.json output-schema: | { "type": "object", @@ -442,6 +450,51 @@ jobs: assumptions. Review only the authorized PR range and ground every finding in a changed hunk and a plausible failure or abuse path. + # Salvage the finished review whether the Codex step completed cleanly or + # timed out due to the PTY-shutdown hang. Prefer the action's final-message + # output (set on a clean exit); fall back to the output file written by the + # CLI before the hang. Fail the job only when neither source is available or + # the recovered JSON is not a valid review shape. + - name: Salvage review output + id: salvage + if: always() + env: + FINAL_MESSAGE: ${{ steps.run_codex.outputs.final-message }} + CODEX_OUTPUT_FILE: ${{ runner.temp }}/codex-review.json + run: | + json="" + + # Prefer the action output set on a clean exit. + if [ -n "$FINAL_MESSAGE" ]; then + json="$FINAL_MESSAGE" + echo "source=action-output" >> "$GITHUB_STEP_SUMMARY" + elif [ -s "$CODEX_OUTPUT_FILE" ]; then + json="$(cat "$CODEX_OUTPUT_FILE")" + echo "source=output-file" >> "$GITHUB_STEP_SUMMARY" + else + echo "No review output from action or output file." >&2 + exit 1 + fi + + # Minimal shape validation: non-empty JSON object with overall_risk. + if ! echo "$json" | python3 -c " + import sys, json + d = json.load(sys.stdin) + assert isinstance(d, dict), 'not an object' + assert 'overall_risk' in d, 'missing overall_risk' + "; then + echo "Review JSON failed shape validation." >&2 + exit 1 + fi + + # Write as a multiline output (GitHub-safe delimiter). + EOF=$(dd if=/dev/urandom bs=15 count=1 2>/dev/null | base64) + { + echo "review_json<<${EOF}" + echo "$json" + echo "${EOF}" + } >> "$GITHUB_OUTPUT" + post-review: name: Post Codex Security Review needs: [prepare-review, security-review] diff --git a/AGENTS.md b/AGENTS.md index 6c3d5dadb14..43358172fff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,91 @@ Additional rules: --- +## Review-Proven Rules + +These rules distill the recurring findings from the last 25 PRs' review +threads — 53% of substantive review findings were repeats of the clusters +below, and reviewed PRs averaged ~5 review rounds. A second, independent +mining pass over 71 agent-review rooms (303 findings, Aug 18–29) confirmed +the same clusters and measured how often authors actually fix each class +once flagged: test-seam binding and unbounded-resource findings were fixed +**100%** of the time, swallowed-error findings **90%**, stale-state races +**70%** — these are not style opinions, they are defects authors agree +with on sight. Apply the rules **before writing code**; each cites the +PRs where reviewers litigated it. + +1. **Every caught failure must leave a durable retry record or propagate.** + Never catch-log-and-return-success (opt-out revocation permanently + abandoned, PR #6269), never convert a terminal failure into an + authoritative success/empty result (cold-history `error` → `success` + with `[]`, PR #7013), and never delete the durable journal an operation + depends on before its retry has actually succeeded (PR #6269). If a + partial failure can orphan committed state (installations, endpoints), + schedule its cleanup/renewal durably (PRs #6269, #6996, #7013). + +2. **Fence async results by generation; clear derived metadata on every + removal path.** A completing in-flight probe or fetch must verify it is + still the newest before writing its result (stale login-shell probe + recached a false-negative PATH, PR #6904). Provenance/ownership metadata + attached to synthetic state must be updated or cleared on *all* paths + that remove or refresh that state — typed deletion, toolbar removal, + profile/name refresh; enumerate the paths and test each (PR #6956 burned + 4 rounds on this one class). Backfill and live subscriptions must + overlap — a gap between a finite history REQ and the live subscription + silently drops events (PR #3995); a retired chunk must not keep a stale + scope fence (PR #6996). (PRs #3995, #6904, #6956, #6996) + +3. **Regression tests must bind the production seam and be falsifiable.** + See "Review-Proven Test Standards" in [TESTING.md](TESTING.md) for the + full rule — in short: a guard whose removal doesn't fail any test + protects nothing; bind regression tests to the production code path, + not test-only helpers. (PRs #6807, #6980, #6996, #7013) + +4. **Bound every resource, loop, and process tree.** Cap captured + output (unbounded discovery temp files exhausted disk and overran the + deadline, PR #6904). Containment failures are errors, not warnings — a + tolerated Job Object creation failure or a `setsid` escape leaks whole + process trees (PR #6904). Retry/re-subscribe loops need backoff and a + terminal state: a persistent failure must not self-amplify into an + unbounded refresh loop (PR #6996), and check zero-delay edge cases + (`remainingMs()==0` selected the wrong fallback window, PR #6996). + (PRs #6904, #6996) + +5. **One user action = one atomic persist.** Implementing a single user + commit as N independent durable writes leaves torn state on partial + failure (theme "Set" as three independent notifier persists, PR #6944; + relay-commit vs. local-save recovery gap, PR #6269). Persist one + snapshot, or order the writes so every prefix is consistent and the + remainder is durably retried per rule 1. (PRs #6269, #6944) + +6. **A guard that hides the only recovery affordance is a functional + failure.** Before adding a visibility predicate or state fence, ask: + if the state it assumes goes wrong, does the user still have a way + back? A fence that permanently suppresses "jump to latest" after a + bounded correction fails strands the user silently — two reviewers + flagged this independently (PR #6807). + +7. **Audit assistive semantics on every new visual component.** The + agent-review lanes flagged accessibility defects on 44 findings across + the Aug 18–29 window — the second-largest cluster — and authors fixed + the concrete ones (duplicate VoiceOver stops on native controls, + actionable labels owned by two widgets at once, PR #6680; missing or + decorative-leaking semantics on new UI, PRs #6611, #6702, #6885, #6905, + #6908). New UI ships with: one owner per actionable label, no duplicate + screen-reader stops, and explicit semantics for every interactive + element. (PRs #6611, #6680, #6702, #6885, #6905, #6908, #6980) + +8. **Every input modality is a first-class seam.** Keyboard, pointer, and + hotkey paths must not silently diverge: `Shift+Space` treated as plain + `Space` because the guard omitted `shiftKey` (PR #6862), keyboard + ownership not released on blur, modifier keys dropped on the non-mouse + path (PRs #5958, #6793, #6860, #6908, #7006). When adding an input + handler, enumerate the modalities that can reach it and test the + non-primary ones — that's where the defects were. (PRs #5958, #5972, + #6793, #6860, #6862, #6908, #7006) + +--- + ## Key Patterns **Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 892082d96c6..138d192fa12 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -325,6 +325,15 @@ This prevents a race where a non-member receives live fan-out events from a priv After registering, the REQ handler queries Postgres for stored events matching the filters (up to 500 per filter, hard cap). These are sent as `["EVENT", sub_id, event]` frames before `["EOSE", sub_id]`. New events arriving after EOSE are delivered via the fan-out path. +**Client consumption invariant.** A client rebuilding channel state must +open its live subscription before (or overlapping) the finite history +REQ — a gap between the last backfill page and live delivery silently +drops events and rebuilds stale state (PR #3995). When the relay sends a +terminal CLOSED, the subscription is removed server-side; any client-side +ownership tied to it (chunk/scope fences) must be released in the same +step, or live delivery stops permanently while the client believes it is +subscribed (PR #6996). + --- ## 6. Crate Reference diff --git a/Justfile b/Justfile index 971a45288f6..9730b1270fa 100644 --- a/Justfile +++ b/Justfile @@ -99,6 +99,7 @@ check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clip security-review-check: node --check .github/scripts/codex-security-review.js node --test .github/scripts/codex-security-review.test.js + actionlint .github/workflows/codex-security-review.yml # Run the repository-wide differential file-size ratchet and its policy tests. # The ratchet inspects only files changed from the merge base, so this stays @@ -455,6 +456,10 @@ test-unit: # disabled_mode_still_requires_the_correct_host / _a_matching_origin. cargo nextest run -p buzz-relay --lib \ -E 'test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' + # ACP author-gate and queue tests protect the trust boundary between + # relay events and agent prompts. They are infra-free; ignored lifecycle + # tests remain excluded and run in their dedicated integration lanes. + cargo nextest run -p buzz-acp --lib else ./scripts/run-tests.sh unit fi diff --git a/TESTING.md b/TESTING.md index 0e64b740665..0e4aee87841 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,6 +16,21 @@ just test # unit + integration (starts Docker if needed) cargo test -p buzz-test-client -- --ignored ``` +### Review-Proven Test Standards + +Mined from the last 25 PRs' review threads (see Review-Proven Rules in +[AGENTS.md](AGENTS.md)); this is the test-quality rule reviewers litigated +most: + +**Regression tests must bind the production seam and be falsifiable.** +A guard whose removal doesn't fail any test protects nothing — mutations +survived the full mobile suite twice (PRs #6996, #7013). Don't bind a +regression test to a test-only helper instead of the production code +path (PR #7013). Give pure predicates a table test over the full input +combination space (PR #6807). Scope Playwright locators — unscoped +`getByText` in a required smoke test is a strict-mode flake (PR #6980). +(PRs #6807, #6980, #6996, #7013) + --- ## Live Local Relay diff --git a/benchmarks/buzz-dataset/README.md b/benchmarks/buzz-dataset/README.md index cfce3b257a2..6df3fbadcf3 100644 --- a/benchmarks/buzz-dataset/README.md +++ b/benchmarks/buzz-dataset/README.md @@ -16,6 +16,7 @@ willing to read. | [`interleaved-agent-reports`](interleaved-agent-reports) | Workflow | Retains and synthesizes every report in a batch of agent messages | | [`cross-thread-requests`](cross-thread-requests) | Workflow | Keeps simultaneous top-level requests isolated and replies to both exact threads | | [`ambiguous-user-mention`](ambiguous-user-mention) | Workflow | Resolves duplicate display names and notifies only the intended pubkey | +| [`memory-retrieval`](memory-retrieval) | Regression | Answers from harness-seeded cold memory without the value appearing in channel history | 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/memory-retrieval/README.md b/benchmarks/buzz-dataset/memory-retrieval/README.md new file mode 100644 index 00000000000..ef6e63950f2 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/README.md @@ -0,0 +1,18 @@ +# memory-retrieval + +Before the agent starts, the harness runs `buzz mem set` with the agent's own +Buzz credentials to seed five similar cold memories. One records the exact +total customer count for April 2024; the other four contain customer counts for +nearby months or related April metrics. The harness then delivers +`instruction.md`, which contains only the retrieval question and does not reveal +the answer or memory slug. No channel message contains the answer, so +conversation history cannot supply it. + +Full credit requires the exact customer count `352,345` in the threaded answer. +Equivalent comma-free formatting is accepted, but rounded or approximate counts +receive no credit. Credit is also voided if the answer mentions another number, +apart from the requested year `2024`. This includes every count drawn from the +distractor memories, so dumping several memories or selecting the wrong one does +not pass — the answer must resolve to the correct value alone. The verifier does +not inspect tool calls: seeding is deterministic harness setup, and retrieval is +graded only through the observable answer. diff --git a/benchmarks/buzz-dataset/memory-retrieval/environment/Dockerfile b/benchmarks/buzz-dataset/memory-retrieval/environment/Dockerfile new file mode 100644 index 00000000000..29f16f3c412 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/memory-retrieval/instruction.md b/benchmarks/buzz-dataset/memory-retrieval/instruction.md new file mode 100644 index 00000000000..0a7a96173d5 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/instruction.md @@ -0,0 +1 @@ +How many total customers did we have in April 2024? diff --git a/benchmarks/buzz-dataset/memory-retrieval/task.toml b/benchmarks/buzz-dataset/memory-retrieval/task.toml new file mode 100644 index 00000000000..a018303ce58 --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/task.toml @@ -0,0 +1,25 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/memory-retrieval" +description = "Answer a question using a harness-seeded cold-memory rule." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "agents", "memory", "retrieval"] + +[metadata] +evaluation_layer = "regression" +difficulty = "hard" +category = "collaboration" +tags = ["agents", "memory", "retrieval"] + +[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/memory-retrieval/tests/test.sh b/benchmarks/buzz-dataset/memory-retrieval/tests/test.sh new file mode 100755 index 00000000000..79434035e3c --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/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/memory-retrieval/tests/verify.py b/benchmarks/buzz-dataset/memory-retrieval/tests/verify.py new file mode 100755 index 00000000000..e3ffe9f5c6a --- /dev/null +++ b/benchmarks/buzz-dataset/memory-retrieval/tests/verify.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for pre-seeded cold-memory retrieval.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +EXPECTED_CUSTOMERS = 352_345 +ALLOWED_CONTEXT_NUMBERS = frozenset({2024}) +# Numbers that appear only in the distractor memories. Mentioning any of them +# means the answer pulled from the wrong memory (or dumped several), so it does +# not demonstrate that the correct value was selected. +DISTRACTOR_NUMBERS = frozenset( + { + 361_250, # total-customers-per-month: monthly average + 351_340, # customer-value-metric: last month's customers + 2_400, # customer-value-metric: revenue per customer + 325_401, # customers-metrics-spring-24: March total + 3_710, # customers-metrics-spring-24: April active customers named John + 21_604, # new-customers-april-2024: April new customers + } +) +NUMBER = re.compile(r"(? dict[str, float]: + return { + "reward": 0.0, + "answer_correct": 0.0, + "threaded_reply": 0.0, + "evidence_complete": 0.0, + } + + +def _numbers(content: str) -> list[float]: + values: list[float] = [] + for token in NUMBER.findall(content): + try: + values.append(float(token.replace(",", ""))) + except ValueError: + continue + return values + + +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"} + + identities = evidence.get("identities", {}) + agents = ( + [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + if isinstance(identities, dict) + else [] + ) + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + question_id = evidence.get("task_event_id") + trial = evidence.get("trial", {}) + question_channel = trial.get("channel_id") if isinstance(trial, dict) else None + + messages = [row for row in evidence.get("messages", []) if isinstance(row, dict)] + replies = [ + row + for row in messages + if agent_pubkey + and row.get("pubkey") == agent_pubkey + and row.get("channel_id") == question_channel + and row.get("reply_to_event_id") == question_id + ] + answer = replies[-1] if replies else None + content = str(answer.get("content", "")) if answer else "" + values = _numbers(content) + mentions_expected = any(value == EXPECTED_CUSTOMERS for value in values) + mentions_distractor = any(value in DISTRACTOR_NUMBERS for value in values) + noise_numbers = [ + value + for value in values + if value != EXPECTED_CUSTOMERS and value not in ALLOWED_CONTEXT_NUMBERS + ] + answer_correct = float(mentions_expected and not noise_numbers) + threaded_reply = float(answer is not None) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "memory-retrieval" + and evidence.get("truncated") is False + and len(agents) == 1 + and isinstance(question_id, str) + and isinstance(question_channel, str) + ) + + structural_score = float(threaded_reply == 1.0 and evidence_complete == 1.0) + metrics = { + "reward": answer_correct * structural_score, + "answer_correct": answer_correct, + "threaded_reply": threaded_reply, + "evidence_complete": evidence_complete, + } + return metrics, { + "question_event_id": question_id, + "question_channel_id": question_channel, + "answer_message_id": answer.get("id") if answer else None, + "answer_content": content, + "parsed_numbers": values, + "expected_customers": EXPECTED_CUSTOMERS, + "mentions_expected": mentions_expected, + "mentions_distractor": mentions_distractor, + "noise_numbers": noise_numbers, + "allowed_context_numbers": sorted(ALLOWED_CONTEXT_NUMBERS), + "distractor_numbers": sorted(DISTRACTOR_NUMBERS), + } + + +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 df859b41ab5..a0bd08a6ae0 100644 --- a/benchmarks/harbor-buzz-orchestra/README.md +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -69,8 +69,8 @@ directory of this harness, not a subdirectory of it — scores Buzz product 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: +isolation, ambiguous identities, and explicit cold-memory retrieval. Run one +task with the production base prompt from the checked-out source build: ```bash just benchmark \ 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 797e3a860c2..a29b86e7314 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 @@ -167,6 +167,8 @@ async def run( "--name", credential.agent_id, ) + await self._seed_memories(orchestrator, trial) + for credential in trial.credentials: agents.append( await self._launch_agent( environment=environment, @@ -786,6 +788,39 @@ async def _verify_m1_output( f"and its stripped text must equal 'Hello, world!' ({detail})" ) + async def _seed_memories( + self, credential: AgentCredential, trial: TrialHandle + ) -> None: + """Seed task-declared cold memory without exposing its value to the agent.""" + for seed in fixture_for(trial.task_name).memory_seeds: + try: + process = await asyncio.create_subprocess_exec( + self.buzz_cli_binary, + "mem", + "set", + seed.slug, + "-", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={ + **os.environ, + "BUZZ_RELAY_URL": self._user_relay_url(trial), + "BUZZ_PRIVATE_KEY": credential.nostr_secret_key, + "BUZZ_AUTH_TAG": credential.nostr_auth_tag, + }, + ) + _, stderr = await process.communicate(seed.value.encode()) + except OSError as error: + raise RuntimeLaunchError( + f"cannot seed cold memory {seed.slug!r}: {error}" + ) from None + if process.returncode != 0: + detail = stderr.decode(errors="replace").strip() + raise RuntimeLaunchError( + f"buzz mem set {seed.slug} - exited {process.returncode}: {detail}" + ) + async def _send( self, credential: AgentCredential, 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 451b97f9c12..87cffbbcf2c 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 @@ -32,6 +32,14 @@ class ScriptedMessage: mention_orchestrator: bool = True +@dataclass(frozen=True, slots=True) +class MemorySeed: + """A cold-memory value seeded under the orchestrator's identity.""" + + slug: str + value: str + + @dataclass(frozen=True, slots=True) class BuzzTaskFixture: """Relay state a task needs before the agent receives its prompt.""" @@ -40,6 +48,7 @@ class BuzzTaskFixture: scripted_messages: tuple[ScriptedMessage, ...] = () observe_channel_names: tuple[str, ...] = () user_display_name: str | None = None + memory_seeds: tuple[MemorySeed, ...] = () # Whether the task's verifier grades the exported relay snapshot. Only # these tasks fail when the export fails; a Terminal-Bench task is graded # by its own tests and must not be errored by a snapshot hiccup. @@ -59,6 +68,7 @@ class BuzzTaskFixture: INTERLEAVED_AGENT_REPORTS_TASK = "interleaved-agent-reports" CROSS_THREAD_REQUESTS_TASK = "cross-thread-requests" AMBIGUOUS_USER_MENTION_TASK = "ambiguous-user-mention" +MEMORY_RETRIEVAL_TASK = "memory-retrieval" _CREATE_CHANNEL_FIXTURE = BuzzTaskFixture( directory=tuple( @@ -161,6 +171,38 @@ class BuzzTaskFixture: requires_evidence=True, ) + +# Noisy memories test retrieval of one relevant value through `buzz mem ls/get`. +_MEMORY_RETRIEVAL_FIXTURE = BuzzTaskFixture( + user_display_name="Amelia Rose Bennett", + memory_seeds=( + MemorySeed( + slug="total-customers-per-month", + value="We average 361,250 customers per month.", + ), + MemorySeed( + slug="customer-value-metric", + value="Last month we had 351,340 customers with a $2400 revenue per customer", + ), + MemorySeed( + slug="customers-metrics-spring-24", + value=( + "In March, we had 325,401 total customers. In April, we had " + "3,710 active customers named John." + ), + ), + MemorySeed( + slug="new-customers-april-2024", + value="There are 21,604 new customers in April 2024.", + ), + MemorySeed( + slug="total-customers-metric", + value="In April 2024, we had 352,345 total customers.", + ), + ), + requires_evidence=True, +) + _FIXTURES = { CREATE_CHANNEL_TASK: _CREATE_CHANNEL_FIXTURE, USER_MENTION_TASK: _USER_MENTION_FIXTURE, @@ -173,6 +215,7 @@ class BuzzTaskFixture: INTERLEAVED_AGENT_REPORTS_TASK: _INTERLEAVED_AGENT_REPORTS_FIXTURE, CROSS_THREAD_REQUESTS_TASK: _CROSS_THREAD_REQUESTS_FIXTURE, AMBIGUOUS_USER_MENTION_TASK: _AMBIGUOUS_USER_MENTION_FIXTURE, + MEMORY_RETRIEVAL_TASK: _MEMORY_RETRIEVAL_FIXTURE, } diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md index f8d2a560bf2..a4a460d7087 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md @@ -9,6 +9,13 @@ endpoint string remains the join key. Every key in these files must be a manifest endpoint name; the loader treats all entries as endpoint configs (no comment keys). +## openai-live-wire-debug.json + +Diagnostic variant of `openai-live.json` for local runs. It enables +`acp::wire=debug`, so retained agent stdout logs include full ACP messages, +including tool-call arguments and results. These logs may contain prompt or +command content; keep them local. The verifier and reward do not read them. + ## m1-local.json M1 wiring proof: both placeholder endpoints resolve to one local llama-server diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live-wire-debug.json b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live-wire-debug.json new file mode 100644 index 00000000000..0403481648d --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live-wire-debug.json @@ -0,0 +1,9 @@ +{ + "gpt-5.6-luna": { + "provider": "openai", + "api_key_env": "OPENAI_COMPAT_API_KEY", + "env": { + "RUST_LOG": "acp::wire=debug" + } + } +} diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py index 0644df63b36..de91726e426 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py @@ -77,6 +77,7 @@ def test_buzz_task_metadata_defines_the_expected_layers(): "user-mention", "read-named-path-outside-workspace", "multiline-message", + "memory-retrieval", "narrative-agent-names", }, "workflow": { @@ -167,7 +168,7 @@ def test_explicit_attempts_override_keeps_one_mixed_buzz_job(): (run,) = benchmark.plan_benchmark_runs(args) assert run.attempts == 7 - assert len(run.include_task) == 9 + assert len(run.include_task) == 10 layered = benchmark.parse_args( [ diff --git a/benchmarks/harbor-buzz-orchestra/testbed/uv.lock b/benchmarks/harbor-buzz-orchestra/testbed/uv.lock index 814f4d3527e..543499b3159 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/uv.lock +++ b/benchmarks/harbor-buzz-orchestra/testbed/uv.lock @@ -717,7 +717,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, ] provides-extras = ["dev"] @@ -743,7 +743,7 @@ requires-dist = [ { name = "harbor-buzz-orchestra", editable = "../" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, ] provides-extras = ["dev"] @@ -2026,27 +2026,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } -sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } -wheels = [ - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca" }, +version = "0.16.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a" }, ] [[package]] diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index 182db9893f6..ecdc9e4cdec 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -1,5 +1,6 @@ """The container runtime must launch the production stack, unmodified.""" +import asyncio import hashlib import json import re @@ -343,6 +344,56 @@ async def test_launch_wires_the_desktop_environment(tmp_path, configured, expect ) +def test_memory_task_disables_auto_memory_injection(tmp_path): + manifest = write_manifest(tmp_path) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = replace(trial_handle((orch,)), task_name="memory-retrieval") + + env = runtime(tmp_path)._agent_env( + trial=trial, + credential=orch, + agent_class=manifest.roster[0], + endpoint=EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"), + remote_prompt="/prompt.md", + ) + + assert env["BUZZ_ACP_CHANNELS"] == "channel" + assert env["BUZZ_ACP_NO_MEMORY"] == "true" + + +@pytest.mark.asyncio +async def test_memory_seed_uses_agent_credentials_and_stdin(tmp_path, monkeypatch): + orch = credential("orch-1", "orchestrator", "orch-model") + trial = replace(trial_handle((orch,)), task_name="memory-retrieval") + captured = [] + + class Process: + def __init__(self, invocation): + self.invocation = invocation + + returncode = 0 + + async def communicate(self, value): + self.invocation["value"] = value + return b"", b"wrote memory" + + async def create_subprocess_exec(*args, **kwargs): + invocation = {"args": args, "env": kwargs["env"]} + captured.append(invocation) + return Process(invocation) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", create_subprocess_exec) + + await runtime(tmp_path)._seed_memories(orch, trial) + + seeds = fixture_for("memory-retrieval").memory_seeds + assert len(captured) == len(seeds) + for invocation, seed in zip(captured, seeds, strict=True): + assert invocation["args"][1:] == ("mem", "set", seed.slug, "-") + assert invocation["env"]["BUZZ_PRIVATE_KEY"] == orch.nostr_secret_key + assert invocation["value"] == seed.value.encode() + + def test_runtime_validates_construction_bounds(tmp_path): # 0 is legal and means unbounded (BUZZ_AGENT_MAX_ROUNDS=0); the trial # budget is the clock. Only negatives are rejected. 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 index 225cb1d1fa1..f39da50a3b7 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_expanded_buzz_native_verifiers.py @@ -6,6 +6,8 @@ from pathlib import Path from types import ModuleType +from harbor_buzz_orchestra.task_fixtures import fixture_for + DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" AGENT = "a" * 64 USER = "u" * 64 @@ -236,3 +238,102 @@ def test_ambiguous_user_mention_targets_only_profile_match(): metrics, _ = verifier.score_evidence(evidence) assert metrics["other_not_notified"] == 0.0 assert metrics["reward"] == 0.0 + + +def test_memory_retrieval_requires_correct_threaded_answer(): + verifier = _verifier("memory-retrieval") + evidence = _base("memory-retrieval", "Amelia Rose Bennett") + question_id = "memory-question" + evidence["task_event_id"] = question_id + answer = _message( + "answer", + "We had 352,345 total customers in April 2024.", + reply_to=question_id, + mentions=[USER], + ) + evidence["messages"] = [answer] + + for correct_answer in ( + "352,345", + "We had 352,345 total customers in April 2024.", + "April 2024 total customers: 352345", + ): + evidence["messages"][0]["content"] = correct_answer + metrics, _ = verifier.score_evidence(evidence) + assert all(value == 1.0 for value in metrics.values()) + + for answer_without_exact_total in ( + "361,250", + "351,340", + "$2,400 revenue per customer", + "325,401", + "3,710", + "21,604", + "352,344", + "352,346", + "352,000", + "About 352 thousand", + "Approximately 352.3 thousand", + ): + evidence["messages"][0]["content"] = answer_without_exact_total + metrics, _ = verifier.score_evidence(evidence) + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + for distractor in verifier.DISTRACTOR_NUMBERS: + evidence["messages"][0]["content"] = ( + f"We had 352,345 total customers. Another relevant count was {distractor:,}." + ) + metrics, details = verifier.score_evidence(evidence) + assert details["mentions_expected"] is True + assert details["mentions_distractor"] is True + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + for noise_count in (352_000, 999_999): + evidence["messages"][0]["content"] = ( + f"We had 352,345 total customers, approximately {noise_count:,}." + ) + metrics, details = verifier.score_evidence(evidence) + assert details["mentions_expected"] is True + assert details["mentions_distractor"] is False + assert details["noise_numbers"] == [float(noise_count)] + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + evidence["messages"][0]["content"] = "352,345" + evidence["messages"][0]["reply_to_event_id"] = "wrong-question" + metrics, _ = verifier.score_evidence(evidence) + assert metrics["answer_correct"] == 0.0 + assert metrics["threaded_reply"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_memory_retrieval_answer_exists_only_in_harness_seed(): + verifier = _verifier("memory-retrieval") + fixture = fixture_for("memory-retrieval") + instruction = (DATASET_ROOT / "memory-retrieval" / "instruction.md").read_text( + encoding="utf-8" + ) + + seeds = {seed.slug: seed.value for seed in fixture.memory_seeds} + assert set(seeds) == { + "total-customers-per-month", + "customer-value-metric", + "customers-metrics-spring-24", + "new-customers-april-2024", + "total-customers-metric", + } + assert "352,345" in seeds["total-customers-metric"] + assert sum("352,345" in value for value in seeds.values()) == 1 + seeded_distractors = frozenset( + number + for slug, value in seeds.items() + if slug != "total-customers-metric" + for number in verifier._numbers(value) + if number != 2024 + ) + assert verifier.EXPECTED_CUSTOMERS == 352_345 + assert verifier.DISTRACTOR_NUMBERS == seeded_distractors + assert "352,345" not in instruction + assert "352345" not in instruction.replace(",", "") diff --git a/benchmarks/harbor-buzz-orchestra/uv.lock b/benchmarks/harbor-buzz-orchestra/uv.lock index 05072d81a80..67b6390f365 100644 --- a/benchmarks/harbor-buzz-orchestra/uv.lock +++ b/benchmarks/harbor-buzz-orchestra/uv.lock @@ -696,7 +696,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, ] provides-extras = ["dev"] @@ -1934,27 +1934,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } -sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } -wheels = [ - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415" }, - { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca" }, +version = "0.16.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a" }, ] [[package]] diff --git a/bin/.actionlint-1.7.12.pkg b/bin/.actionlint-1.7.12.pkg new file mode 120000 index 00000000000..383f4511d44 --- /dev/null +++ b/bin/.actionlint-1.7.12.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/actionlint b/bin/actionlint new file mode 120000 index 00000000000..432f25e505e --- /dev/null +++ b/bin/actionlint @@ -0,0 +1 @@ +.actionlint-1.7.12.pkg \ No newline at end of file diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..cf36111a936 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -147,17 +147,31 @@ Controls which authors' events the harness forwards to the agent. Events from di | `anyone` | Forward all events (no author filtering). | | `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. | +Relay-signed workflow messages delegate to their recorded owner only when they +explicitly target this agent with authenticated workflow-mention provenance. +The owner tag means that owner scheduled the workflow; it does not claim that +the owner authored every word after template rendering. ACP verifies the +provenance against the relay's NIP-11 `self` key, then evaluates the owner under +the same author policy as ordinary messages. Legacy workflow messages and +workflow output without an explicit agent mention remain attributed to the relay +signer. `nobody` remains absolute. + The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode: | Command | Effect | |---------|--------| | `!shutdown` | Gracefully exits the harness. | -| `!cancel` | Cancels the current in-flight turn for that channel, if any. | -| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. | +| `!cancel` | Cancels the current in-flight turn for the command's resolved session scope, if any. | +| `!rotate` | Rotates the ACP session for the command's resolved session scope. If a turn is in flight, it is cancelled and that scoped session is invalidated when the task returns; otherwise the cached scoped session is invalidated immediately. The next queued/received event in that scope starts a fresh session. | + +Under the default `channel` policy, a session scope is the whole channel, so these commands retain their channel-wide behavior. Under the `thread` policy, post the command as a reply in the target thread so `!cancel` or `!rotate` affects only that thread. DMs remain one conversation scope. `!cancel` is a no-op when its scope is idle. -Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle. +Owner control commands must be kind:9 stream messages from the owner, must have body exactly `!cancel`, `!rotate`, or `!shutdown` after trimming, and must mention this agent with a separate `p` tag. They are consumed by the harness instead of being forwarded to the agent. An inline `@Name` changes the body and does not match. With the Buzz CLI, target a thread while preserving the exact command body by passing the mention separately: -Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent. +```bash +buzz messages send --channel --reply-to \ + --mention --content '!cancel' +``` > **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. Set `--respond-to anyone` to disable the gate entirely. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 4dc4720ed85..6cee0b603d6 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -1,11 +1,5 @@ You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session. -## Session Model - -You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state. - -When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. - ## Buzz CLI The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON. @@ -27,6 +21,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | +| `buzz mem` | `set`, `get`, `ls`, `patch`, `rm` | Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. @@ -118,10 +113,11 @@ Do not discover, fetch, load, read, or use relay-backed skills unless the author Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions. - **Keep `core` small.** A line earns a permanent slot only if it matters across most sessions or prevents a sharp repeat mistake. Treat the 65,535-byte hard limit as a wall to stay far from, not a budget to fill — aim to keep `core` under ~10 KB (roughly your healthy baseline). -- **Turn mistakes into durable lessons.** When a mistake exposes a repeatable mechanism, record the invariant in the same session. Keep only the load-bearing rule in `core`; put detailed evidence and procedures in cold memory. If the lesson improves a shared workflow, update the team's shared guidance so others do not have to re-earn it. -- **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/` slug you read on demand — not appended to `core`. -- **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later. +- **Turn mistakes into durable lessons.** When a mistake exposes a repeatable mechanism, record the invariant in the same session. Keep only the load-bearing rule in `core`; put detailed evidence and procedures in cold memory with `buzz mem set`. If the lesson improves a shared workflow, update the team's shared guidance so others do not have to re-earn it. +- **Durable detail goes to a cold `buzz mem set `, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in cold memory you read on demand with `buzz mem get `—not appended to `core`. +- **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `buzz mem` slug if you need it later. Always ask the owner before doing this. - **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it. +- **Cold memory search and hygiene.** Find cold memory with `buzz mem ls` and `buzz mem get`. If a user's prompt contradicts a memory, always ask the owner if they would remove it with `buzz mem rm` or update it with `buzz mem patch`. Never remove or patch a memory without owner approval. - Cite sources with paths, links, or command outputs. No unsupported claims. ## Engineering Discipline diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 2d7b2128320..3d4e67d0f55 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -350,6 +350,19 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)] pub dedup: DedupMode, + /// How ACP provider sessions are scoped in channels. + /// channel (default): one provider session per channel (legacy behavior). + /// thread: each canonical channel thread gets an isolated provider session; + /// direct messages stay conversation-scoped either way. Ships as `channel` + /// so thread scoping can be canaried and rolled back without code changes. + #[arg( + long, + env = "BUZZ_ACP_SESSION_POLICY", + default_value = "channel", + value_enum + )] + pub session_policy: crate::scope::SessionPolicy, + /// How to handle new @mentions while a turn is already in-flight. /// steer (default): cancel+re-prompt, framing the new mention as a message /// that arrived mid-task — the agent keeps working and weaves it in. @@ -536,6 +549,8 @@ pub struct Config { pub initial_message: Option, pub subscribe_mode: SubscribeMode, pub dedup_mode: DedupMode, + /// How ACP provider sessions are scoped in channels (channel vs thread). + pub session_policy: crate::scope::SessionPolicy, pub multiple_event_handling: MultipleEventHandling, pub ignore_self: bool, pub kinds_override: Option>, @@ -646,6 +661,35 @@ const SESSION_TITLE_SEPARATOR: &str = " · "; /// survives. Returns the bare agent name when there is no channel, the channel /// name is blank, or no room is left for it. pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> String { + compose_session_title_with_limit(agent, channel_name, SESSION_TITLE_MAX_CHARS) +} + +/// Append the canonical thread root's first eight characters to a session title. +/// Reserve suffix space before truncating names so thread identity always survives. +/// Conversation and heartbeat sessions preserve their existing title behavior. +pub(crate) fn compose_scoped_session_title( + agent: &str, + channel_name: Option<&str>, + thread_root: Option<&str>, +) -> String { + let Some(root) = thread_root.filter(|root| !root.is_empty()) else { + return compose_session_title(agent, channel_name); + }; + let short_root: String = root.chars().take(8).collect(); + let suffix = format!("{SESSION_TITLE_SEPARATOR}{short_root}"); + let budget = SESSION_TITLE_MAX_CHARS.saturating_sub(suffix.chars().count()); + let agent: String = agent.chars().take(budget).collect(); + format!( + "{}{suffix}", + compose_session_title_with_limit(agent.trim_end(), channel_name, budget) + ) +} + +fn compose_session_title_with_limit( + agent: &str, + channel_name: Option<&str>, + max_chars: usize, +) -> String { let Some(channel) = channel_name.and_then(sanitize_session_title) else { return agent.to_string(); }; @@ -653,7 +697,7 @@ pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> let reserved = agent.chars().count() + SESSION_TITLE_SEPARATOR.chars().count() + 1; let channel: String = channel .chars() - .take(SESSION_TITLE_MAX_CHARS.saturating_sub(reserved)) + .take(max_chars.saturating_sub(reserved)) .collect::() .trim_end() .to_string(); @@ -1113,6 +1157,7 @@ impl Config { initial_message: args.initial_message, subscribe_mode: args.subscribe, dedup_mode: args.dedup, + session_policy: args.session_policy, multiple_event_handling: args.multiple_event_handling, ignore_self: !args.no_ignore_self, kinds_override: args.kinds, @@ -1164,7 +1209,7 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} session_policy={} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1176,6 +1221,7 @@ impl Config { self.heartbeat_interval_secs, self.subscribe_mode, self.dedup_mode, + self.session_policy, self.multiple_event_handling, self.ignore_self, self.context_message_limit, @@ -1489,6 +1535,7 @@ mod tests { initial_message: None, subscribe_mode: mode, dedup_mode: DedupMode::Queue, + session_policy: crate::scope::SessionPolicy::Channel, multiple_event_handling: MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -2618,6 +2665,42 @@ channels = "ALL" assert!(result.is_empty()); } + // ── Session policy parsing + default ────────────────────────────────────── + + #[test] + fn test_session_policy_default_is_channel() { + // Ships dark: the default must be `channel` so thread scoping is opt-in + // and can be rolled back without code changes. + let args = CliArgs::parse_from(["buzz-acp", "--private-key", &"0".repeat(64)]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Channel); + } + + #[test] + fn test_session_policy_thread_flag_parses() { + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--session-policy", + "thread", + ]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread); + } + + #[test] + fn test_session_policy_env_var_parses() { + // The env fallback (`BUZZ_ACP_SESSION_POLICY`) must resolve to the same + // value as the flag; this is what the managed-agent runtime sets. + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--session-policy=thread", + ]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread); + assert_eq!(args.session_policy.to_string(), "thread"); + } + // ── Multiple-event-handling validation + default ────────────────────────── #[test] @@ -2991,6 +3074,36 @@ channels = "ALL" assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); } + #[test] + fn scoped_session_title_keeps_short_root_even_when_names_fill_the_cap() { + let root = "abcdef01".repeat(8); + assert_eq!( + compose_scoped_session_title("Fizz", Some("buzz-dev"), Some(&root)), + "Fizz · #buzz-dev · abcdef01" + ); + assert_eq!( + compose_scoped_session_title("Fizz", None, Some(&root)), + "Fizz · abcdef01" + ); + assert_eq!( + compose_scoped_session_title("Fizz", Some("buzz-dev"), Some("abc")), + "Fizz · #buzz-dev · abc" + ); + for (agent, channel) in [ + ("🐝".repeat(80), "work".into()), + ("Fizz".into(), "🐝".repeat(100)), + ] { + let title = compose_scoped_session_title(&agent, Some(&channel), Some(&root)); + assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS); + assert!(title.ends_with(" · abcdef01")); + } + assert_eq!( + compose_scoped_session_title("Fizz", Some("buzz-dev"), None), + "Fizz · #buzz-dev" + ); + assert_eq!(compose_scoped_session_title("Fizz", None, None), "Fizz"); + } + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH /// must set `hide_env_values = true` to prevent credential leakage in --help. #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 648bc866ddf..3f2eccfd257 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -11,6 +11,7 @@ mod prompt_framing; mod prompt_project; mod queue; mod relay; +mod scope; mod setup_mode; mod usage; @@ -316,44 +317,522 @@ async fn is_owner_or_sibling( is_sibling } -/// Inbound author gate decision: does this author's event fire a turn? +/// Return the workflow owner attributed by a relay-signed workflow message. /// -/// Coarse security policy applied before subscription rules. Both `OwnerOnly` -/// and `Allowlist` accept the owner and same-owner siblings; `Allowlist` -/// additionally accepts the explicit external pubkey list. +/// `buzz:workflow-owner` alone is not authority: any ordinary event author can +/// forge custom tags. Attribution is accepted only for a cryptographically +/// valid kind:9 event signed by the active relay's NIP-11 `self` key, with +/// exactly one canonical workflow marker and owner pubkey. The current agent +/// must also have exactly one canonical `buzz:workflow-mention` tag; legacy `p` +/// tags are deliberately ignored as author-gate authority because workflows +/// retain an owner `p` tag for mentions-feed compatibility. +fn verified_workflow_owner( + event: &nostr::Event, + relay_self: Option<&str>, + agent_pubkey_hex: &str, +) -> Option { + if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE { + return None; + } + + let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?; + if event.pubkey != relay_self || event.verify().is_err() { + return None; + } + + let markers: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow")) + .collect(); + if markers.as_slice() != [["buzz:workflow", "true"]] { + return None; + } + + let owners: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow-owner")) + .collect(); + let [owner_tag] = owners.as_slice() else { + return None; + }; + let [_, owner_value] = owner_tag else { + return None; + }; + let owner = nostr::PublicKey::from_hex(owner_value).ok()?.to_hex(); + if owner_value.as_str() != owner { + return None; + } + + let agent_pubkey = nostr::PublicKey::from_hex(agent_pubkey_hex).ok()?.to_hex(); + let workflow_mentions: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow-mention")) + .collect(); + let mut mentioned_pubkeys = HashSet::with_capacity(workflow_mentions.len()); + for mention_tag in workflow_mentions { + let [_, mention_value] = mention_tag else { + return None; + }; + let mention = nostr::PublicKey::from_hex(mention_value).ok()?.to_hex(); + if mention_value.as_str() != mention || !mentioned_pubkeys.insert(mention) { + return None; + } + } + if !mentioned_pubkeys.contains(&agent_pubkey) { + return None; + } + + Some(owner) +} + +/// Resolve the author principal used by the inbound author gate. +fn effective_prompt_author( + event: &nostr::Event, + relay_self: Option<&str>, + agent_pubkey_hex: &str, +) -> String { + verified_workflow_owner(event, relay_self, agent_pubkey_hex) + .unwrap_or_else(|| event.pubkey.to_hex()) +} + +/// Owns the verified relay signing identity for a listener's lifetime and +/// applies the inbound author gate to each event. /// -/// # DM hardening (`is_dm`) +/// The relay identity is deliberately *not* a per-event parameter, and this +/// type deliberately lives in its own module with private fields so the only +/// way to obtain one is [`InboundAuthorGate::connect`], which loads the +/// identity. +/// +/// Two earlier revisions of this code were mutable-with-impunity: the first +/// threaded a local `Option` into every gate call, and the second kept +/// a free `evaluate_inbound_author_gate(.., relay_self, ..)` alongside the +/// method. In both cases a listener could be rewired to pass `None` — silently +/// disabling every delegated workflow wake — while all 848 tests stayed green. +/// Encapsulation, not a test, is what closes that seam: `InboundAuthorGate { +/// relay_self: None, .. }` is now a privacy error outside this module, and +/// dropping the load inside it fails the construction regressions. +mod inbound_author_gate { + use super::{ + effective_prompt_author, is_dm_channel, is_owner_or_sibling, pool, refresh_relay_self, + relay, OwnerCache, RespondTo, + }; + use std::collections::HashSet; + + pub(crate) struct InboundAuthorGateDecision { + pub(crate) effective_author: String, + pub(crate) allowed: bool, + pub(crate) is_dm: bool, + } + + /// An event that passed the complete listener author boundary. + /// + /// The event is moved into the gate before policy evaluation and can only + /// be recovered through this private-field capability. Both production + /// loops therefore have to consume the gate's verdict before they can use + /// or publish the event; replacing the call with a raw signer or a local + /// `allowed = true` no longer type-checks. + pub(crate) struct AuthorizedListenerEvent { + buzz_event: relay::BuzzEvent, + effective_author: String, + } + + impl AuthorizedListenerEvent { + pub(crate) fn into_parts(self) -> (relay::BuzzEvent, String) { + (self.buzz_event, self.effective_author) + } + } + + /// Apply the configured raw-author policy after trusted workflow attribution. + /// + /// This stays private to the gate module so neither listener can bypass + /// workflow attribution by calling the raw-signer policy directly. + async fn author_allowed( + respond_to: &RespondTo, + allowlist: &HashSet, + author: &str, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> bool { + if is_dm { + return match respond_to { + RespondTo::Nobody => false, + _ => is_owner_or_sibling(author, owner_cache, rest_client).await, + }; + } + match respond_to { + RespondTo::Anyone => true, + RespondTo::Nobody => false, + RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::Allowlist => { + allowlist.contains(author) + || is_owner_or_sibling(author, owner_cache, rest_client).await + } + } + } + + #[cfg(test)] + pub(super) async fn test_author_allowed( + respond_to: &RespondTo, + allowlist: &HashSet, + author: &str, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> bool { + author_allowed( + respond_to, + allowlist, + author, + is_dm, + owner_cache, + rest_client, + ) + .await + } + + pub(crate) struct InboundAuthorGate { + agent_pubkey_hex: String, + relay_self: Option, + // None means no authoritative NIP-11 result yet, including at startup. + refreshed_generation: Option, + } + + pub(crate) fn refresh_needed(refreshed_generation: Option, event_generation: u64) -> bool { + refreshed_generation.is_none_or(|generation| event_generation > generation) + } + + impl InboundAuthorGate { + /// Load the relay signing identity for a freshly connected listener. + pub(crate) async fn connect( + rest_client: &relay::RestClient, + agent_pubkey_hex: &str, + context: &str, + ) -> Self { + let (relay_self, completed) = refresh_relay_self(rest_client, None, context).await; + Self { + agent_pubkey_hex: agent_pubkey_hex.to_string(), + relay_self, + refreshed_generation: completed.then_some(0), + } + } + + /// Whether delegated workflow attribution is currently available. + /// + /// Test-only: production code never branches on this. + /// `refresh_relay_self` already logs why attribution is unavailable, and + /// every runtime path treats a missing identity by falling back to the + /// raw signer. + #[cfg(test)] + pub(crate) fn has_relay_identity(&self) -> bool { + self.relay_self.is_some() + } + + #[cfg(test)] + pub(crate) fn relay_identity_for_test(&self) -> Option<&str> { + self.relay_self.as_deref() + } + + /// Refresh relay identity, resolve channel trust, and apply trusted + /// workflow attribution and author policy for one listener event. + /// + /// Both production listeners call this exact boundary. Identity refresh + /// cannot be omitted independently of authorization; the raw-author + /// policy and relay identity are private to this module. + pub(crate) async fn evaluate_listener_event( + &mut self, + buzz_event: &relay::BuzzEvent, + respond_to: &RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + // Retry failed startup discovery on generation 0 as well as failed + // reconnect refreshes. Only an authoritative result completes the + // generation; transient failure retains the last verified key. + if refresh_needed(self.refreshed_generation, buzz_event.connection_generation) { + let (relay_self, completed) = + refresh_relay_self(rest_client, self.relay_self.take(), "listener").await; + self.relay_self = relay_self; + if completed { + self.refreshed_generation = Some(buzz_event.connection_generation); + } + } + let is_dm = is_dm_channel(buzz_event.channel_id, channel_info).await; + self.evaluate_with_channel_trust( + &buzz_event.event, + respond_to, + allowlist, + is_dm, + owner_cache, + rest_client, + ) + .await + } + + async fn evaluate_with_channel_trust( + &self, + event: &nostr::Event, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + let effective_author = + effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex); + let allowed = author_allowed( + respond_to, + allowlist, + &effective_author, + is_dm, + owner_cache, + rest_client, + ) + .await; + InboundAuthorGateDecision { + effective_author, + allowed, + is_dm, + } + } + + pub(crate) async fn authorize_listener_event( + &mut self, + buzz_event: relay::BuzzEvent, + respond_to: &RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, + rest_client: &relay::RestClient, + ) -> Option { + let decision = self + .evaluate_listener_event( + &buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await; + if !decision.allowed { + tracing::debug!( + channel_id = %buzz_event.channel_id, + raw_author = %buzz_event.event.pubkey.to_hex(), + effective_author = %decision.effective_author, + mode = %respond_to, + is_dm = decision.is_dm, + "inbound author gate — dropping event" + ); + return None; + } + Some(AuthorizedListenerEvent { + buzz_event, + effective_author: decision.effective_author, + }) + } + + #[cfg(test)] + pub(crate) async fn evaluate_for_test( + &self, + event: &nostr::Event, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + self.evaluate_with_channel_trust( + event, + respond_to, + allowlist, + is_dm, + owner_cache, + rest_client, + ) + .await + } + } +} + +use inbound_author_gate::{AuthorizedListenerEvent, InboundAuthorGate}; + +struct AuthorizedNormalListenerEvent(AuthorizedListenerEvent); + +struct NormalListenerIngress { + buzz_event: relay::BuzzEvent, + effective_author: String, + prompt_tag: String, +} + +impl AuthorizedNormalListenerEvent { + async fn match_subscription( + self, + rules: &[SubscriptionRule], + agent_pubkey_hex: &str, + ) -> Option { + let (buzz_event, effective_author) = self.0.into_parts(); + let matched = filter::match_event( + &buzz_event.event, + buzz_event.channel_id, + rules, + agent_pubkey_hex, + ) + .await?; + Some(NormalListenerIngress { + buzz_event, + effective_author, + prompt_tag: matched.prompt_tag, + }) + } +} + +struct QueuedNormalListenerEvent { + accepted: bool, + scope: scope::SessionScope, + effective_author: String, + event_id_hex: String, + event_for_steer: nostr::Event, + prompt_tag_for_steer: String, +} + +impl QueuedNormalListenerEvent { + fn mark_seen(&self, rest_client: &relay::RestClient) { + if !self.accepted { + return; + } + let rest_client = rest_client.clone(); + let event_id = self.event_id_hex.clone(); + tokio::spawn(async move { + pool::reaction_add(&rest_client, &event_id, "👀").await; + }); + } + + fn steer_or_interrupt( + self, + handling: MultipleEventHandling, + owner: Option<&str>, + pool: &mut AgentPool, + queue: &mut EventQueue, + steer_ack_tx: &mpsc::UnboundedSender, + ) { + if !self.accepted || !queue.is_scope_in_flight(&self.scope) { + return; + } + let Some(signal) = mode_gate_signal(handling, &self.effective_author, owner) else { + return; + }; + let native_attempted = matches!(signal, ControlSignal::Steer) + && try_native_steer( + pool, + queue, + self.scope.clone(), + self.event_for_steer, + self.prompt_tag_for_steer, + steer_ack_tx, + ); + if !native_attempted { + signal_in_flight_task_for_scope(pool, &self.scope, signal); + } + } +} + +impl NormalListenerIngress { + fn push( + self, + queue: &mut EventQueue, + session_scope: scope::SessionScope, + ) -> QueuedNormalListenerEvent { + let Self { + buzz_event, + effective_author, + prompt_tag, + } = self; + let event_id_hex = buzz_event.event.id.to_hex(); + let event_for_steer = buzz_event.event.clone(); + let prompt_tag_for_steer = prompt_tag.clone(); + let channel_id = buzz_event.channel_id; + let accepted = queue.push(QueuedEvent { + channel_id, + scope: session_scope.clone(), + event: buzz_event.event, + received_at: std::time::Instant::now(), + prompt_tag, + }); + QueuedNormalListenerEvent { + accepted, + scope: session_scope, + effective_author, + event_id_hex, + event_for_steer, + prompt_tag_for_steer, + } + } +} + +/// Apply the complete normal-listener author boundary for one relay event. /// -/// Clients auto-p-tag every DM participant, so in a DM *any* participant's -/// message looks like a mention and would fire a turn. Combined with -/// agent-initiated DMs (the agent can be asked to DM a third party), that -/// turns `anyone`/`allowlist` modes into transitive access grants: whoever -/// lands in a DM with the agent can prompt it. To close that hole, when -/// `is_dm` is true only the owner and cryptographically verified same-owner -/// siblings may fire a turn — the explicit allowlist and `anyone` mode do -/// NOT apply inside DMs. `Nobody` still drops everything. Callers must -/// resolve `is_dm` fail-closed: unknown channel type ⇒ treat as DM. -async fn author_allowed( +/// The event is consumed here, so the production loop cannot recover it except +/// from the gate's private authorized capability. +async fn authorize_normal_listener_event( + author_gate: &mut InboundAuthorGate, + buzz_event: relay::BuzzEvent, respond_to: &RespondTo, allowlist: &HashSet, - author: &str, - is_dm: bool, owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, rest_client: &relay::RestClient, -) -> bool { - if is_dm { - return match respond_to { - RespondTo::Nobody => false, - _ => is_owner_or_sibling(author, owner_cache, rest_client).await, - }; - } - match respond_to { - RespondTo::Anyone => true, - RespondTo::Nobody => false, - RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, - RespondTo::Allowlist => { - allowlist.contains(author) - || is_owner_or_sibling(author, owner_cache, rest_client).await +) -> Option { + author_gate + .authorize_listener_event( + buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await +} + +/// Refresh the relay signing identity, logging why delegated workflow +/// attribution is unavailable. A transient fetch error keeps the last verified +/// key so a reconnect blip cannot disable workflow wakes. That availability +/// tradeoff creates a bounded-by-success revocation window: a rotated-away key +/// remains trusted while NIP-11 refreshes keep failing, then is replaced or +/// cleared by the next successful response. Refresh runs at startup and before +/// authorization on a new or still-pending generation; a completed generation +/// is not refreshed again until a reconnect. +async fn refresh_relay_self( + rest_client: &relay::RestClient, + current: Option, + context: &str, +) -> (Option, bool) { + match rest_client.relay_self().await { + Ok(Some(pubkey)) => (Some(pubkey), true), + Ok(None) => { + tracing::warn!( + %context, + "relay NIP-11 document has no `self` key — workflow attribution remains fail-closed" + ); + (None, true) + } + Err(error) => { + tracing::warn!( + %context, + %error, + retaining_previous_identity = current.is_some(), + "failed to refresh relay NIP-11 identity" + ); + (current, false) } } } @@ -1389,8 +1868,13 @@ fn handle_cancel_turn_control( return; }; - let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - let status = if fired { "sent" } else { "no_active_turn" }; + let status = if pool.channel_control_is_ambiguous(channel_id) { + "ambiguous_target" + } else if signal_in_flight_task(pool, channel_id, ControlSignal::Cancel) { + "sent" + } else { + "no_active_turn" + }; if let Some(observer) = observer { observer.emit( "control_result", @@ -1404,6 +1888,7 @@ fn handle_cancel_turn_control( serde_json::json!({ "type": "cancel_turn", "status": status, + "requestId": payload.get("requestId"), }), ); } @@ -1453,7 +1938,11 @@ fn handle_switch_model_control( .values() .any(|m| m.channel_id == Some(channel_id)); - let status = if turn_in_flight { + let status = if pool.channel_control_is_ambiguous(channel_id) { + // The Desktop protocol names channels, not sessions. Never switch one + // arbitrary sibling and report a channel-wide success. + "ambiguous_target" + } else if turn_in_flight { // Busy path: deliver over the oneshot. `false` means the oneshot was // already consumed this turn (a prior cancel/interrupt) — the turn is // already ending, so the switch cannot land on it. @@ -1472,6 +1961,7 @@ fn handle_switch_model_control( } else { // Idle path: validate against the cached catalog before invalidating. match pool.switch_idle_agent_model(channel_id, model_id, request_id.clone()) { + IdleSwitchResult::AmbiguousTarget => "ambiguous_target", IdleSwitchResult::Switched => "switched", IdleSwitchResult::UnsupportedModel => "unsupported_model", IdleSwitchResult::NoIdleAgent => "no_active_turn", @@ -1651,6 +2141,9 @@ struct RespawnResult { /// `event_id` is the hex id of the single event the steer carried. struct SteerAckEvent { channel_id: Uuid, + /// Session scope of the steered event — the queue-side withhold/release + /// and deadline extension target this, not the whole channel. + scope: scope::SessionScope, event_id: String, /// `Ok` if the read loop sent any of the locked `SteerAck` variants. /// `Err` if the oneshot was dropped without a send — should not happen @@ -2112,6 +2605,10 @@ async fn tokio_main() -> Result<()> { tracing::info!("connected to relay at {}", config.relay_url); + let relay_rest_client = relay.rest_client(); + let mut author_gate_ctx = + InboundAuthorGate::connect(&relay_rest_client, &pubkey_hex, "startup").await; + relay .subscribe_membership_notifications() .await @@ -2297,10 +2794,17 @@ async fn tokio_main() -> Result<()> { team_instructions: config.team_instructions.clone(), base_prompt: if config.no_base_prompt { None - } else if let Some(content) = base_prompt_content { - Some(Box::leak(content.into_boxed_str())) } else { - Some(include_str!("base_prompt.md")) + // Build standing context once under the configured policy, before + // any session/new. Both modern ACP and legacy first-turn framing + // consume this same assembled base (including custom base files). + Some( + config.session_policy.append_session_model( + base_prompt_content + .as_deref() + .unwrap_or(include_str!("base_prompt.md")), + ), + ) }, heartbeat_prompt: config.heartbeat_prompt.clone(), cwd, @@ -2355,7 +2859,7 @@ async fn tokio_main() -> Result<()> { } else { None }; - let mut typing_channels: HashMap = HashMap::new(); + let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; // Independent of pool readiness: a never-mentioned lazy agent must still @@ -2567,10 +3071,10 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -2619,10 +3123,10 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } @@ -2813,7 +3317,9 @@ async fn tokio_main() -> Result<()> { // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); - typing_channels.remove(&ch); + // Drop every thread scope's typing entry for + // the removed channel. + typing_channels.retain(|scope, _| scope.channel_id() != ch); // Best-effort: clean up 👀 on drained events. // Note: the relay revokes membership before // emitting the notification, so this DELETE may @@ -2885,21 +3391,36 @@ async fn tokio_main() -> Result<()> { &pubkey_hex, ); if is_cancel { - if let Some(owner) = owner_cache.get() { - if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( - &mut pool, - buzz_event.channel_id, - ControlSignal::Cancel, + let from_owner = owner_cache.get().is_some_and(|owner| { + buzz_event.event.pubkey.to_hex() == *owner + }); + if from_owner { + // Scope-exact: an owner's !cancel in thread A + // must cancel thread A's turn, never a sibling + // thread running in the same channel. Under + // the default channel policy the scope is the + // channel's sole conversation, so this is + // byte-for-byte the prior behavior. + let scope = scope::SessionScope::derive( + config.session_policy, + buzz_event.channel_id, + is_dm_channel(buzz_event.channel_id, &ctx.channel_info) + .await, + &buzz_event.event, + ); + let fired = signal_in_flight_task_for_scope( + &mut pool, + &scope, + ControlSignal::Cancel, + ); + if !fired { + tracing::warn!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + "!cancel received but no in-flight task — no-op" ); - if !fired { - tracing::warn!( - channel_id = %buzz_event.channel_id, - "!cancel received but no in-flight task — no-op" - ); - } - continue; // consume event — do NOT push to queue } + continue; // consume event — do NOT push to queue } // Not from owner — fall through to normal prompt handling. } @@ -2923,28 +3444,44 @@ async fn tokio_main() -> Result<()> { &pubkey_hex, ); if is_rotate { - if let Some(owner) = owner_cache.get() { - if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( - &mut pool, - buzz_event.channel_id, - ControlSignal::Rotate, + let from_owner = owner_cache.get().is_some_and(|owner| { + buzz_event.event.pubkey.to_hex() == *owner + }); + if from_owner { + // Scope-exact: rotate only the thread the + // owner's !rotate belongs to. Under the + // default channel policy the scope is the + // channel's sole conversation, matching the + // prior channel-wide rotate. + let scope = scope::SessionScope::derive( + config.session_policy, + buzz_event.channel_id, + is_dm_channel(buzz_event.channel_id, &ctx.channel_info) + .await, + &buzz_event.event, + ); + let fired = signal_in_flight_task_for_scope( + &mut pool, + &scope, + ControlSignal::Rotate, + ); + if fired { + tracing::info!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + "!rotate received — cancelling in-flight turn and rotating session" + ); + } else { + let invalidated = + pool.invalidate_scope_session(&scope); + tracing::info!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + invalidated, + "!rotate received — invalidated idle session for scope" ); - if fired { - tracing::info!( - channel_id = %buzz_event.channel_id, - "!rotate received — cancelling in-flight turn and rotating session" - ); - } else { - let invalidated = pool.invalidate_channel_sessions(buzz_event.channel_id); - tracing::info!( - channel_id = %buzz_event.channel_id, - invalidated, - "!rotate received — invalidated idle channel session(s)" - ); - } - continue; // consume event — do NOT push to queue } + continue; // consume event — do NOT push to queue } // Not from owner — fall through to normal prompt handling. } @@ -2960,125 +3497,75 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. - { - let author = buzz_event.event.pubkey.to_hex(); - // DM hardening: resolve channel type (fail-closed - // to DM) so allowlist/anyone modes cannot be - // exercised by non-owner authors inside DMs. - let is_dm = - is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; - let allowed = author_allowed( - &config.respond_to, - &config.respond_to_allowlist, - &author, - is_dm, - &owner_cache, - &ctx.rest_client, - ) - .await; - if !allowed { - tracing::debug!( - channel_id = %buzz_event.channel_id, - author = %buzz_event.event.pubkey.to_hex(), - mode = %config.respond_to, - is_dm, - "inbound author gate — dropping event" - ); - continue; - } - } - - let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; - let prompt_tag = match matched { - Some(m) => m.prompt_tag, - None => { - tracing::debug!(channel_id = %buzz_event.channel_id, kind = buzz_event.event.kind.as_u16(), "event matched no rule — dropping"); - continue; - } + let Some(authorized_event) = authorize_normal_listener_event( + &mut author_gate_ctx, + buzz_event, + &config.respond_to, + &config.respond_to_allowlist, + &owner_cache, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + else { + continue; + }; + let Some(ingress) = + AuthorizedNormalListenerEvent(authorized_event) + .match_subscription(&rules, &pubkey_hex) + .await + else { + tracing::debug!("authorized event matched no rule — dropping"); + continue; }; - // Capture author pubkey before queue.push() moves - // buzz_event.event (needed for mode gate below). - let author_hex = buzz_event.event.pubkey.to_hex(); - let event_id_hex = buzz_event.event.id.to_hex(); - // Clone for the non-cancelling steer fork, which - // needs the event to render the steer body. The - // clone is unconditional because we don't know - // yet whether the mode gate will demand a steer - // — checking `multiple_event_handling` here - // would couple the queueing path to the mode - // and break the existing invariant that every - // accepted event goes through `queue.push` - // first. `nostr::Event::clone` is cheap (Arc- - // backed payload) so the cost is negligible. - let event_for_steer = buzz_event.event.clone(); - let prompt_tag_for_steer = prompt_tag.clone(); - let accepted = queue.push(QueuedEvent { - channel_id: buzz_event.channel_id, - event: buzz_event.event, - received_at: std::time::Instant::now(), - prompt_tag, - }); + // Derive the session scope once, at admission, from + // the operator policy, DM status, and NIP-10 thread + // tags. Under the default `channel` policy this is + // always a conversation scope, preserving today's + // channel-keyed routing. Telemetry only for now — + // queue/pool partitioning by scope lands in a + // follow-up (see ticket outline steps 2–4). + let session_scope = scope::SessionScope::derive( + config.session_policy, + ingress.buzz_event.channel_id, + is_dm_channel( + ingress.buzz_event.channel_id, + &ctx.channel_info, + ) + .await, + &ingress.buzz_event.event, + ); + tracing::debug!( + channel_id = %session_scope.channel_id(), + scope = %session_scope.telemetry_label(), + thread_scoped = session_scope.is_thread(), + thread_root = session_scope.root_event_id().unwrap_or("-"), + policy = %config.session_policy, + "admitted event — resolved session scope" + ); + let queued = ingress.push(&mut queue, session_scope); // 👀 — immediate "seen" reaction, only if the event // was actually queued (not dropped by DedupMode::Drop). // Fire-and-forget: on rare fast-failure paths the // guard's cleanup may race with this add, leaving a // cosmetic stale 👀. Acceptable — see ReactionGuard docs. - if accepted { - let rc = ctx.rest_client.clone(); - let eid = event_id_hex.clone(); - tokio::spawn(async move { - pool::reaction_add(&rc, &eid, "👀").await; - }); - } - // Event is already queued. If mode requires it AND - // the channel has an in-flight task, fire cancel — - // OR take the non-cancelling (ACP steer) fork for Steer signals. - if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { - // Author eligibility (owner ∪ allowlist ∪ siblings) - // is already enforced by the inbound author gate - // above, so the mid-turn signal fires for every - // event that reaches here. - let signal = mode_gate_signal( - config.multiple_event_handling, - &author_hex, - owner_cache.get(), - ); - if let Some(signal) = signal { - // Non-cancelling fork: when the mode - // wants a Steer, attempt the - // non-cancelling path first. On accept, - // withhold the queued event and spawn an - // ack watcher; the main loop's - // `PoolEvent::SteerAck` arm decides - // success/release/fallback. On reject - // (including agents that advertise no - // steer transport at all), fall through - // to the universal cancel+merge `Steer` - // signal so the event still reaches the - // agent. - let native_attempted = matches!(signal, ControlSignal::Steer) - && try_native_steer( - &mut pool, - &mut queue, - buzz_event.channel_id, - event_for_steer, - prompt_tag_for_steer, - &steer_ack_tx, - ); - if !native_attempted { - signal_in_flight_task( - &mut pool, - buzz_event.channel_id, - signal, - ); - } - } - } + queued.mark_seen(&ctx.rest_client); + // Event is already queued. The authorized ingress + // retains its verified author, resolved scope, and + // event data through the optional steer/interrupt + // decision. + queued.steer_or_interrupt( + config.multiple_event_handling, + owner_cache.get(), + &mut pool, + &mut queue, + &steer_ack_tx, + ); if pool_ready { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -3175,10 +3662,10 @@ async fn tokio_main() -> Result<()> { tracing::debug!("heartbeat_skipped_pool_not_ready"); } else if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } else if pool.any_idle() { dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); @@ -3217,7 +3704,8 @@ async fn tokio_main() -> Result<()> { // Use try_publish (non-blocking) for typing indicators — // they're ephemeral and must not block the main loop during // relay reconnection (#35). - for (&ch, thread_tags) in &typing_channels { + for (scope, thread_tags) in &typing_channels { + let ch = scope.channel_id(); if let Ok(event) = relay.build_typing_event( ch, thread_tags.root_event_id.as_deref(), @@ -3239,9 +3727,11 @@ async fn tokio_main() -> Result<()> { match pool_event { Some(PoolEvent::Result(result)) => { - // Stop typing indicator for the completed channel. - if let PromptSource::Channel(ch) = &result.source { - typing_channels.remove(ch); + // Stop the typing indicator for the completed turn's exact scope, + // not the whole channel — a sibling thread still running in the + // same channel must keep its indicator. + if let Some(scope) = result.source.scope() { + typing_channels.remove(scope); } if handle_prompt_result( &mut pool, @@ -3274,10 +3764,10 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Panic(join_error)) => { @@ -3299,14 +3789,15 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::SteerAck(SteerAckEvent { channel_id, + scope, event_id, ack, })) => { @@ -3420,12 +3911,8 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if let Ok(pool::SteerAck::Success { session_id }) = &ack { - queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); - if !pool.record_successful_steer( - channel_id, - event_id.clone(), - session_id.clone(), - ) { + queue.extend_in_flight_deadline(&scope, config.max_turn_duration_secs); + if !pool.record_successful_steer(&scope, event_id.clone(), session_id.clone()) { tracing::warn!( channel = %channel_id, event_id = %event_id, @@ -3434,18 +3921,20 @@ async fn tokio_main() -> Result<()> { } } if drop_withheld { - queue.remove_event(channel_id, &event_id); + queue.remove_event(&scope, &event_id); } if release_withheld { - queue.release_native_steer(channel_id, &event_id); + queue.release_native_steer(&scope, &event_id); } if signal_fallback { // Universal cancel+merge fallback. Note: the // queued event has already been released to the - // front of `queues[channel_id]`, so the cancel - // will pick it up as part of the merged batch and - // re-prompt the agent. - signal_in_flight_task(&mut pool, channel_id, ControlSignal::Steer); + // front of `queues[scope]`, so the cancel will pick + // it up as part of the merged batch and re-prompt the + // agent. Scope-exact so the fallback cancels the + // steered event's OWN thread, not a sibling thread + // in the same channel. + signal_in_flight_task_for_scope(&mut pool, &scope, ControlSignal::Steer); } // After releasing a withheld event, give dispatch a chance // to re-flush. If the prompt is still in flight, the @@ -3454,10 +3943,10 @@ async fn tokio_main() -> Result<()> { // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Wake(attempt, result)) => { @@ -3482,10 +3971,10 @@ async fn tokio_main() -> Result<()> { "ready", None, ); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Err(error) => { @@ -3682,12 +4171,25 @@ fn mode_gate_signal( } /// Send a control signal to the in-flight task for `channel_id`. +/// +/// Channel-targeted: refuses channels with multiple session scopes. Used only +/// by desktop observer frames (`cancel_turn` / `switch_model`), which carry a +/// bare `channelId` and no thread context. Every thread-aware +/// path — mid-turn steering/interruption and the owner `!cancel` / `!rotate` +/// commands, whose triggering event carries NIP-10 thread tags — uses +/// [`signal_in_flight_task_for_scope`], which targets one exact +/// [`scope::SessionScope`] so a signal for thread A can never hit thread B +/// running in the same channel. +/// /// Returns `true` if a signal was sent, `false` if no in-flight task was found. fn signal_in_flight_task( pool: &mut AgentPool, channel_id: uuid::Uuid, mode: ControlSignal, ) -> bool { + if pool.channel_control_is_ambiguous(channel_id) { + return false; + } let entry = pool .task_map_mut() .values_mut() @@ -3703,6 +4205,39 @@ fn signal_in_flight_task( false } +/// Send a control signal to the in-flight task for one exact session scope. +/// +/// The scope-precise counterpart of [`signal_in_flight_task`]: mid-turn +/// steer/interrupt must target the thread the triggering event belongs to, not +/// “whichever task the channel happens to have first” — otherwise two threads +/// running concurrently in one channel could steer each other. +/// +/// Returns `true` if a signal was sent, `false` if no in-flight task matched. +fn signal_in_flight_task_for_scope( + pool: &mut AgentPool, + scope: &scope::SessionScope, + mode: ControlSignal, +) -> bool { + let entry = pool + .task_map_mut() + .values_mut() + .find(|m| m.scope.as_ref() == Some(scope)); + + if let Some(meta) = entry { + if let Some(tx) = meta.control_tx.take() { + tracing::info!( + channel = %scope.channel_id(), + scope = %scope.telemetry_label(), + ?mode, + "control signal sent to in-flight task (scope-exact)" + ); + let _ = tx.send(mode); + return true; + } + } + false +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: @@ -3730,11 +4265,12 @@ fn signal_in_flight_task( fn try_native_steer( pool: &mut AgentPool, queue: &mut EventQueue, - channel_id: uuid::Uuid, + scope: scope::SessionScope, event: nostr::Event, prompt_tag: String, steer_ack_tx: &mpsc::UnboundedSender, ) -> bool { + let channel_id = scope.channel_id(); // Build the steer body: framing strings come from // `queue::native_steer_framing()` (Eva's drift-proof requirement — // native and cancel+merge fallback share these so the agent gets the @@ -3770,14 +4306,14 @@ fn try_native_steer( ack_tx, }; - match pool.send_steer(channel_id, request) { + match pool.send_steer(&scope, request) { Ok(()) => { // Withhold the queued event synchronously BEFORE spawning // the watcher: this closes the race where `mark_complete` // clears `in_flight_channels` and a stray `flush_next` could // re-deliver the event via normal dispatch. See // `EventQueue::mark_native_steer_pending` docs at queue.rs:606. - let withheld = queue.mark_native_steer_pending(channel_id, &event_id_hex); + let withheld = queue.mark_native_steer_pending(&scope, &event_id_hex); if !withheld { // Race: the event was already drained out of the queue // before we got here (e.g. a concurrent flush picked it @@ -3795,10 +4331,12 @@ fn try_native_steer( } let ack_tx_clone = steer_ack_tx.clone(); let event_id_for_watcher = event_id_hex.clone(); + let scope_for_watcher = scope.clone(); tokio::spawn(async move { let ack = ack_rx.await; let _ = ack_tx_clone.send(SteerAckEvent { channel_id, + scope: scope_for_watcher, event_id: event_id_for_watcher, ack, }); @@ -3824,31 +4362,56 @@ fn dispatch_pending( queue: &mut EventQueue, ctx: &Arc, last_activity: &mut tokio::time::Instant, -) -> Vec<(Uuid, ThreadTags)> { +) -> Vec<(scope::SessionScope, ThreadTags)> { + // Keyed by the exact session scope, not the channel: two threads dispatching + // concurrently in one channel get distinct typing entries so completing one + // never clears the other's indicator. let mut dispatched_channels = Vec::new(); + // Batches held back this cycle because the worker that owns their thread's + // session is busy. They stay flushed-out of the queue (in-flight) until we + // release them at the end so `flush_next` cannot re-pick them mid-loop; + // releasing requeues them so the next dispatch (when the owner returns) + // reuses that exact session instead of forking a duplicate. + let mut held: Vec = Vec::new(); loop { let batch = match queue.flush_next() { Some(b) => b, None => break, }; let channel_id = batch.channel_id; + let scope = batch.scope.clone(); + // Authoritative affinity: if the worker that owns this thread's session + // is checked out (busy on another turn), hold the batch rather than let + // an idle worker open a second session for the same thread. + if pool.should_hold_for_busy_owner(&scope) { + tracing::debug!( + channel = %channel_id, + scope = %scope.telemetry_label(), + "holding batch — session owner busy; awaiting its return to avoid duplicate session" + ); + held.push(batch); + continue; + } let typing_scope = batch .events .last() .map(|event| queue::parse_thread_tags(&event.event)) .unwrap_or_default(); - let affinity_hit = pool.has_session_for(channel_id); - let mut agent = match pool.try_claim(Some(channel_id)) { + // Scope-level affinity: reuse the worker that already holds THIS + // thread's provider session so a temporarily busy worker cannot cause + // another to open a duplicate session for the same thread. + let affinity_hit = pool.has_session_for(&scope); + let mut agent = match pool.try_claim(Some(&scope)) { Some(a) => a, None => { let pending = queue.pending_channels(); tracing::debug!(pending_channels = pending, "pool_exhausted"); queue.requeue_preserve_timestamps(batch); - queue.mark_complete(channel_id); + queue.mark_complete(&scope); break; } }; - tracing::debug!(agent = agent.index, channel = %channel_id, affinity_hit, "agent_claimed"); + tracing::debug!(agent = agent.index, channel = %channel_id, scope = %scope.telemetry_label(), affinity_hit, "agent_claimed"); let recoverable_batch = match ctx.dedup_mode { DedupMode::Queue => Some(batch.clone()), @@ -3896,6 +4459,7 @@ fn dispatch_pending( pool::TaskMeta { agent_index, channel_id: Some(channel_id), + scope: Some(scope.clone()), turn_id, recoverable_batch, control_tx: Some(control_tx), @@ -3903,9 +4467,21 @@ fn dispatch_pending( successful_steer_deliveries: HashSet::new(), }, ); - dispatched_channels.push((channel_id, typing_scope)); + // Record this worker as the scope's session owner so a later dispatch + // while it is busy holds instead of forking a duplicate session. + pool.record_scope_owner(scope.clone(), agent_index); + dispatched_channels.push((scope, typing_scope)); *last_activity = tokio::time::Instant::now(); } + // Release held batches back to the queue (owner busy). They were flushed + // out (in-flight) so they could not be re-picked above; requeue preserves + // their timestamps and mark_complete clears the in-flight marker, leaving + // them queued for the next dispatch when the owner frees up. + for batch in held { + let scope = batch.scope.clone(); + queue.requeue_preserve_timestamps(batch); + queue.mark_complete(scope); + } tracing::debug!( dispatched = dispatched_channels.len(), queue_depth = queue.pending_channels(), @@ -3991,19 +4567,20 @@ fn handle_prompt_result( pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); - if let PromptSource::Channel(channel_id) = &result.source { + if let PromptSource::Channel(scope) = &result.source { // The task may have invalidated this session before returning. Never // resurrect delivery state for a dead session; its replacement must // receive fresh standing context and history. - if let Some(live_session_id) = result.agent.state.sessions.get(channel_id).cloned() { + if let Some(live_session_id) = result.agent.state.sessions.get(scope).cloned() { let event_ids = successful_steer_deliveries .into_iter() .filter(|delivery| delivery.session_id == live_session_id) .map(|delivery| delivery.event_id); + let scope = scope.clone(); result .agent .state - .mark_channel_delivery_success(*channel_id, false, event_ids); + .mark_scope_delivery_success(scope, false, event_ids); } } @@ -4126,7 +4703,7 @@ fn handle_prompt_result( } match &result.source { - PromptSource::Channel(ch) => queue.mark_complete(*ch), + PromptSource::Channel(scope) => queue.mark_complete(scope.clone()), PromptSource::Heartbeat => *heartbeat_in_flight = false, } @@ -4162,10 +4739,7 @@ fn handle_prompt_result( .to_string(); let harness_pid = std::process::id(); - let channel_id = match &result.source { - PromptSource::Channel(ch) => Some(*ch), - PromptSource::Heartbeat => None, - }; + let channel_id = result.source.channel_id(); let turn_id = result.turn_id.clone(); let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { @@ -4375,7 +4949,7 @@ fn recover_panicked_agent( join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -4406,8 +4980,23 @@ fn recover_panicked_agent( } if let Some(ch) = meta.channel_id { - queue.mark_complete(ch); - typing_channels.remove(&ch); + // Clear the EXACT session scope, not the channel. Passing a bare + // channel id would resolve to `Conversation(channel_id)` via IntoScope + // and, under thread policy, leave the actual `Thread(...)` entry wedged + // in-flight until the ~2h backstop deadline — blocking the batch we + // just requeued. `meta.scope` is the authoritative in-flight scope. + match &meta.scope { + Some(scope) => { + // Clear the panicked turn's exact scope so a sibling thread in + // the same channel keeps its typing indicator. + typing_channels.remove(scope); + queue.mark_complete(scope.clone()); + } + None => { + typing_channels.retain(|scope, _| scope.channel_id() != ch); + queue.mark_complete(ch); + } + } tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); } else { *heartbeat_in_flight = false; @@ -4473,7 +5062,7 @@ fn drain_ready_join_results( config: &Config, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -4544,6 +5133,7 @@ fn dispatch_heartbeat( pool::TaskMeta { agent_index, channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, @@ -5356,6 +5946,7 @@ mod owner_control_command_tests { pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -5382,111 +5973,1550 @@ mod owner_control_command_tests { )); } - #[test] - fn project_owner_control_signs_only_addressable_project_events() { - let keys = Keys::generate(); - let events = build_project_owner_announcement_events( - vec![ - ProjectOwnerAnnouncementTemplate { - kind: 30_621, - content: String::new(), - created_at: Some(1), - tags: vec![vec!["d".to_string(), "project".to_string()]], - }, - ProjectOwnerAnnouncementTemplate { - kind: 30_617, - content: String::new(), - created_at: Some(1), - tags: vec![vec!["d".to_string(), "repository".to_string()]], + fn thread_scope(channel_id: Uuid, root: &str) -> scope::SessionScope { + scope::SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + fn insert_task_meta( + pool: &mut AgentPool, + agent_index: usize, + scope: scope::SessionScope, + control_tx: tokio::sync::oneshot::Sender, + ) { + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index, + channel_id: Some(scope.channel_id()), + scope: Some(scope), + turn_id: "t".to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + } + + #[tokio::test] + async fn observer_channel_controls_reject_sibling_sessions_without_signalling() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let a = thread_scope(ch, &"a".repeat(64)); + let b = thread_scope(ch, &"b".repeat(64)); + let (tx_a, mut rx_a) = tokio::sync::oneshot::channel(); + let (tx_b, mut rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, a.clone(), tx_a); + insert_task_meta(&mut pool, 1, b.clone(), tx_b); + let observer = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "channelId": ch.to_string(), "modelId": "new-model", "requestId": "pick-1", + }); + + handle_cancel_turn_control(&payload, &mut pool, Some(&observer)); + handle_switch_model_control(&payload, &mut pool, Some(&observer)); + let results = observer.snapshot(); + assert_eq!(results.len(), 2); + for result in results { + assert_eq!(result.payload["status"], "ambiguous_target"); + assert_eq!(result.payload["requestId"], "pick-1"); + assert_eq!(result.channel_id, Some(ch.to_string())); + } + assert_eq!( + rx_a.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + assert_eq!( + rx_b.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + + // Completion does not make a channel-wide model switch safe: the + // sibling's retained session is still a distinct target. + pool.record_scope_owner(a, 0); + pool.record_scope_owner(b, 1); + pool.task_map_mut().clear(); + assert_eq!( + pool.switch_idle_agent_model(ch, "new-model", None), + IdleSwitchResult::AmbiguousTarget + ); + assert!(!pool.channel_control_is_ambiguous(Uuid::new_v4())); + } + + #[tokio::test] + async fn observer_channel_controls_allow_one_scope_and_ignore_other_channels() { + for signal in [ + ControlSignal::Cancel, + ControlSignal::SwitchModel { + model_id: "new-model".into(), + request_id: Some("pick-1".into()), + }, + ] { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let scope = scope::SessionScope::Conversation { channel_id: ch }; + pool.record_scope_owner(scope.clone(), 0); + pool.record_scope_owner(thread_scope(Uuid::new_v4(), &"a".repeat(64)), 1); + let (tx, rx) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, scope, tx); + let observer = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "channelId": ch.to_string(), "modelId": "new-model", "requestId": "pick-1", + }); + match &signal { + ControlSignal::Cancel => { + handle_cancel_turn_control(&payload, &mut pool, Some(&observer)) + } + _ => handle_switch_model_control(&payload, &mut pool, Some(&observer)), + } + assert_eq!(rx.await.unwrap(), signal); + assert_eq!(observer.snapshot()[0].payload["status"], "sent"); + } + } + + // Fix #2: mid-turn steer/interrupt must target the exact thread scope, not + // “the first task in the channel” — two threads in one channel must not + // interrupt each other. + #[tokio::test] + async fn signal_in_flight_task_for_scope_targets_only_matching_thread() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let (tx_a, rx_a) = tokio::sync::oneshot::channel(); + let (tx_b, rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, ta.clone(), tx_a); + insert_task_meta(&mut pool, 1, tb.clone(), tx_b); + + // Signalling thread A must reach A's task only. + assert!(signal_in_flight_task_for_scope( + &mut pool, + &ta, + ControlSignal::Steer + )); + assert_eq!(rx_a.await.unwrap(), ControlSignal::Steer); + + // Thread B's control channel is untouched (still open, no signal). + assert!(signal_in_flight_task_for_scope( + &mut pool, + &tb, + ControlSignal::Interrupt + )); + assert_eq!(rx_b.await.unwrap(), ControlSignal::Interrupt); + + // A scope with no in-flight task returns false. + assert!(!signal_in_flight_task_for_scope( + &mut pool, + &thread_scope(ch, &"c".repeat(64)), + ControlSignal::Steer + )); + } + + // Fix #1: a thread must not get a second provider session when the worker + // that owns its session is busy on another turn. + #[tokio::test] + async fn busy_session_owner_holds_batch_instead_of_forking_session() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + + // Worker 0 owns thread A's session and is currently busy running B. + pool.record_scope_owner(ta.clone(), 0); + let (tx_b, _rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, tb.clone(), tx_b); + + // A new A message must be HELD (owner busy, no idle worker holds A). + assert!( + pool.should_hold_for_busy_owner(&ta), + "owner busy => hold to avoid a duplicate session" + ); + + // A brand-new thread with no recorded owner is never held. + assert!(!pool.should_hold_for_busy_owner(&thread_scope(ch, &"d".repeat(64)))); + + // Channel-wide session invalidation prunes the directory so a stale + // owner can never strand a held batch. + pool.invalidate_channel_sessions(ch); + assert!( + !pool.should_hold_for_busy_owner(&ta), + "owner directory pruned on channel invalidation" + ); + } + + #[test] + fn project_owner_control_signs_only_addressable_project_events() { + let keys = Keys::generate(); + let events = build_project_owner_announcement_events( + vec![ + ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }, + ProjectOwnerAnnouncementTemplate { + kind: 30_617, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "repository".to_string()]], + }, + ], + &keys, + ) + .expect("valid project events"); + + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event.pubkey == keys.public_key())); + assert!(events.iter().all(|event| event.verify().is_ok())); + } + + #[test] + fn project_owner_control_rejects_arbitrary_or_unaddressed_events() { + let keys = Keys::generate(); + let arbitrary = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 1, + content: String::new(), + created_at: None, + tags: vec![vec!["d".to_string(), "project".to_string()]], + }], + &keys, + ); + assert!(arbitrary.is_err()); + + let unaddressed = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: None, + tags: vec![], + }], + &keys, + ); + assert!(unaddressed.is_err()); + } +} + +#[cfg(test)] +mod owner_cache_tests { + use super::*; + + #[test] + fn new_with_some_caches_immediately() { + let cache = OwnerCache::new(Some("abcd".into())); + assert_eq!(cache.get(), Some("abcd")); + } + + #[test] + fn new_with_none_returns_none() { + let cache = OwnerCache::new(None); + assert!(cache.get().is_none()); + } + + #[test] + fn get_returns_cached_value() { + let cache = OwnerCache::new(Some("ab".repeat(32))); + assert_eq!(cache.get(), Some("ab".repeat(32)).as_deref()); + } +} + +#[cfg(test)] +mod workflow_owner_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn workflow_event( + signer: &Keys, + owner: Option<&str>, + marker_tags: &[&[&str]], + workflow_mentions: &[&[&str]], + p_tags: &[&str], + ) -> nostr::Event { + let mut tags = Vec::new(); + for marker in marker_tags { + tags.push(Tag::parse(marker.iter().copied()).expect("workflow marker")); + } + if let Some(owner) = owner { + tags.push(Tag::parse(["buzz:workflow-owner", owner]).expect("workflow owner tag")); + } + for mention in workflow_mentions { + tags.push(Tag::parse(mention.iter().copied()).expect("workflow mention tag")); + } + for recipient in p_tags { + tags.push(Tag::parse(["p", *recipient]).expect("p tag")); + } + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags(tags) + .sign_with_keys(signer) + .expect("signed event") + } + + #[tokio::test] + async fn relay_identity_refresh_keeps_last_good_key_after_fetch_error() { + let previous = Keys::generate().public_key().to_hex(); + let client = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".into(), + keys: Keys::generate(), + auth_tag_json: None, + }; + + let (refreshed, completed) = + refresh_relay_self(&client, Some(previous.clone()), "test").await; + assert_eq!(refreshed, Some(previous)); + assert!(!completed); + } + + #[test] + fn trusted_relay_workflow_uses_owner_for_explicit_target() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str()]], + &[owner.as_str(), agent.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + owner + ); + } + + #[test] + fn multiple_explicit_targets_each_use_owner() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent_a = Keys::generate().public_key().to_hex(); + let agent_b = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[ + &["buzz:workflow-mention", agent_a.as_str()], + &["buzz:workflow-mention", agent_b.as_str()], + ], + &[owner.as_str(), agent_a.as_str(), agent_b.as_str()], + ); + + for agent in [&agent_a, &agent_b] { + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), agent), + owner + ); + } + } + + #[test] + fn owner_as_explicit_target_uses_owner_without_duplicate_p_tag() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", owner.as_str()]], + &[owner.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &owner), + owner + ); + } + + #[test] + fn legacy_owner_p_tag_without_explicit_target_keeps_relay_signer() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = owner.clone(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[], + &[owner.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + + #[test] + fn p_tag_without_matching_explicit_target_keeps_relay_signer() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let other = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", other.as_str()]], + &[owner.as_str(), agent.as_str(), other.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + + #[test] + fn forged_or_tampered_workflow_keeps_raw_signer() { + let relay = Keys::generate(); + let attacker = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let mentions = [&["buzz:workflow-mention", agent.as_str()][..]]; + let forged = workflow_event( + &attacker, + Some(&owner), + &[&["buzz:workflow", "true"]], + &mentions, + &[agent.as_str()], + ); + assert_eq!( + effective_prompt_author(&forged, Some(&relay.public_key().to_hex()), &agent), + attacker.public_key().to_hex() + ); + + let mut tampered = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &mentions, + &[agent.as_str()], + ); + tampered.content = "tampered".into(); + assert_eq!( + effective_prompt_author(&tampered, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + + #[test] + fn malformed_or_ambiguous_metadata_fails_closed() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + let valid_mentions = [&["buzz:workflow-mention", agent.as_str()][..]]; + + for event in [ + workflow_event( + &relay, + Some(&owner), + &[], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + None, + &[&["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"], &["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true", "extra"]], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str(), "extra"]], + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[ + &["buzz:workflow-mention", agent.as_str()], + &["buzz:workflow-mention", agent.as_str()], + ], + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", "not-a-pubkey"]], + &[agent.as_str()], + ), + ] { + assert_eq!( + effective_prompt_author(&event, Some(&relay_hex), &agent), + relay_hex + ); + } + + let duplicate_owner = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ); + let mut tags: Vec = duplicate_owner.tags.iter().cloned().collect(); + tags.push(Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("duplicate owner")); + let duplicate_owner = + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags(tags) + .sign_with_keys(&relay) + .expect("signed event"); + assert_eq!( + effective_prompt_author(&duplicate_owner, Some(&relay_hex), &agent), + relay_hex + ); + } + + #[test] + fn wrong_kind_or_missing_relay_identity_fails_closed() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + let wrong_kind = EventBuilder::new(Kind::TextNote, "scheduled prompt") + .tags([ + Tag::parse(["buzz:workflow", "true"]).expect("marker"), + Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("owner"), + Tag::parse(["buzz:workflow-mention", agent.as_str()]).expect("workflow mention"), + ]) + .sign_with_keys(&relay) + .expect("signed event"); + assert_eq!( + effective_prompt_author(&wrong_kind, Some(&relay_hex), &agent), + relay_hex + ); + + let valid = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str()]], + &[agent.as_str()], + ); + assert_eq!(effective_prompt_author(&valid, None, &agent), relay_hex); + } +} + +#[cfg(test)] +mod author_gate_tests { + use super::*; + + /// A `RestClient` for tests. The author-gate decisions exercised here all + /// resolve from the owner pubkey or sibling cache before any HTTP call, so + /// this client is never actually used to make a request. + fn dummy_rest_client() -> relay::RestClient { + relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://localhost:0".into(), + keys: nostr::Keys::generate(), + auth_tag_json: None, + } + } + + const OWNER: &str = "00"; + const SIBLING: &str = "11"; + const EXTERNAL: &str = "22"; + const STRANGER: &str = "33"; + + /// Owner + a known sibling, none of them on the explicit allowlist. + fn cache_with_sibling() -> OwnerCache { + let cache = OwnerCache::new(Some(OWNER.into())); + cache.cache_sibling(SIBLING.into(), true); + cache.cache_sibling(STRANGER.into(), false); + cache.cache_sibling(EXTERNAL.into(), false); + cache + } + + /// Serve a NIP-11 document on a loopback port so `InboundAuthorGate` can be + /// built through the *same* constructor the listeners use, rather than by + /// injecting an already-resolved relay identity. This is what makes the + /// listener-to-gate wiring testable: a gate that never loads its identity + /// fails these tests instead of silently degrading to the raw signer. + pub(super) async fn nip11_server( + document: serde_json::Value, + ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { + nip11_scripted_server(std::collections::VecDeque::from([Ok(document)])).await + } + + /// Serve scripted NIP-11 responses. `Err(())` returns HTTP 500. + async fn nip11_scripted_server( + responses: std::collections::VecDeque>, + ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind NIP-11 test server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let responses = std::sync::Arc::new(tokio::sync::Mutex::new((responses, None))); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + let response = { + let mut scripted = responses.lock().await; + let response = if let Some(next) = scripted.0.pop_front() { + Some(next) + } else { + scripted.1.clone() + }; + if let Some(Ok(document)) = &response { + scripted.1 = Some(Ok(document.clone())); + } + response + }; + let Some(response) = response else { + continue; + }; + let Ok(document) = response else { + let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(response.as_bytes()).await; + continue; + }; + let body = document.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let rest = relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + (rest, server) + } + + /// Build a gate through the real `connect` path against a NIP-11 document + /// advertising `relay_hex` as the relay signer. Tests use this instead of + /// constructing `InboundAuthorGate` literally so that the identity load + /// stays part of what they cover. + async fn connected_gate( + relay_hex: &str, + agent: &str, + ) -> ( + InboundAuthorGate, + relay::RestClient, + tokio::task::JoinHandle<()>, + ) { + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + let gate = InboundAuthorGate::connect(&rest_client, agent, "test").await; + (gate, rest_client, server) + } + + /// A genuine relay-signed workflow dispatch that explicitly targets `agent` + /// on behalf of `owner` — the exact event shape a scheduled workflow emits. + pub(super) fn relay_signed_workflow_dispatch( + relay_keys: &nostr::Keys, + owner: &str, + agent: &str, + ) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", owner]).expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent]).expect("workflow mention tag"), + nostr::Tag::parse(["p", agent]).expect("recipient tag"), + ]) + .sign_with_keys(relay_keys) + .expect("signed workflow event") + } + + struct ListenerBoundaryScenario<'a> { + listener: ListenerBoundary, + relay_keys: &'a nostr::Keys, + workflow_owner: &'a str, + responses: std::collections::VecDeque>, + event_generation: u64, + channel_type: &'a str, + respond_to: RespondTo, + allowlist: HashSet, + cache_owner: bool, + cache_sibling: bool, + } + + async fn listener_boundary_scenario( + scenario: ListenerBoundaryScenario<'_>, + ) -> (Option, bool) { + let ListenerBoundaryScenario { + listener, + relay_keys, + workflow_owner, + responses, + event_generation, + channel_type, + respond_to, + allowlist, + cache_owner, + cache_sibling, + } = scenario; + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let (rest_client, server) = nip11_scripted_server(responses).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "listener startup").await; + let configured_owner = if cache_owner { + Some(workflow_owner.to_string()) + } else if cache_sibling { + Some(nostr::Keys::generate().public_key().to_hex()) + } else { + None + }; + let owner_cache = OwnerCache::new(configured_owner); + owner_cache.cache_sibling(relay_hex, false); + owner_cache.cache_sibling(workflow_owner.to_string(), cache_sibling); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: channel_type.into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let event = relay::BuzzEvent { + connection_generation: event_generation, + channel_id, + event: relay_signed_workflow_dispatch(relay_keys, workflow_owner, &agent), + }; + let authorized = match listener { + ListenerBoundary::Normal => { + authorize_normal_listener_event( + &mut gate, + event, + &respond_to, + &allowlist, + &owner_cache, + &channel_info, + &rest_client, + ) + .await + } + ListenerBoundary::Setup => { + setup_mode::authorize_setup_listener_event( + &mut gate, + event, + &respond_to, + &allowlist, + &owner_cache, + &channel_info, + &rest_client, + ) + .await + } + }; + let result = authorized.map(|event| event.into_parts().1); + server.abort(); + let allowed = result.is_some(); + (result, allowed) + } + + #[derive(Clone, Copy, Debug)] + enum ListenerBoundary { + Normal, + Setup, + } + + impl ListenerBoundary { + fn name(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Setup => "setup", + } + } + } + + /// Both production listener callables must attribute relay-signed workflow + /// events to the workflow owner and enforce policy there. A local + /// `allowed: true` replacement at either call site makes the Nobody case + /// fail; using the raw relay signer makes the OwnerOnly case fail. + #[tokio::test] + async fn production_listener_boundaries_apply_workflow_owner_policy() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let accepted_workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let accepted = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &accepted_workflow_owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::OwnerOnly, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + accepted.1, + "{} listener must allow the workflow owner", + listener.name() + ); + assert_eq!( + accepted.0.as_deref(), + Some(accepted_workflow_owner.as_str()), + "{} listener must preserve the effective workflow owner", + listener.name() + ); + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let denied_workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let denied = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &denied_workflow_owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::Nobody, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + !denied.1, + "{} listener must enforce respond-to=nobody", + listener.name() + ); + } + } + + /// Both production boundaries must retain DM classification when composing + /// trusted workflow attribution with configured author policy. External + /// allowlist entries and `Anyone` stay denied in a DM; owner and sibling + /// principals remain allowed; `Nobody` remains absolute. + #[tokio::test] + async fn production_listener_boundaries_enforce_dm_author_policy() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let external = nostr::Keys::generate().public_key().to_hex(); + let external_allowlist = HashSet::from([external.clone()]); + let denied_external = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &external, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Allowlist, + allowlist: external_allowlist, + cache_owner: false, + cache_sibling: false, + }) + .await; + assert!( + !denied_external.1, + "{} listener must deny an external allowlist entry in a DM", + listener.name() + ); + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let stranger = nostr::Keys::generate().public_key().to_hex(); + let denied_stranger = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &stranger, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Anyone, + allowlist: HashSet::new(), + cache_owner: false, + cache_sibling: false, + }) + .await; + assert!( + !denied_stranger.1, + "{} listener must deny a stranger in a DM under Anyone", + listener.name() + ); + + for (principal, cache_owner, cache_sibling, label) in [ + ( + nostr::Keys::generate().public_key().to_hex(), + true, + false, + "owner", + ), + ( + nostr::Keys::generate().public_key().to_hex(), + false, + true, + "sibling", + ), + ] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let allowed = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &principal, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Anyone, + allowlist: HashSet::new(), + cache_owner, + cache_sibling, + }) + .await; + assert!( + allowed.1, + "{} listener must allow the {label} in a DM", + listener.name() + ); + } + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let owner = nostr::Keys::generate().public_key().to_hex(); + let denied_nobody = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Nobody, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + !denied_nobody.1, + "{} listener must enforce Nobody in a DM", + listener.name() + ); + } + } + + /// Both production boundaries must perform the pending generation-zero + /// refresh before policy evaluation. Bypassing the gate invocation leaves + /// the relay signer denied and makes this recovery assertion fail. + #[tokio::test] + async fn production_listener_boundaries_recover_relay_identity() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let result = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &workflow_owner, + responses: std::collections::VecDeque::from([ + Err(()), + Err(()), + Ok(serde_json::json!({ "self": relay_hex })), + ]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::OwnerOnly, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + result.1, + "{} listener must recover identity before authorization", + listener.name() + ); + assert_eq!( + result.0.as_deref(), + Some(workflow_owner.as_str()), + "{} listener must preserve the recovered workflow owner", + listener.name() + ); + } + } + + /// The listener decision-boundary regression. + /// + /// Both listeners call `evaluate_listener_event`; it owns identity refresh, + /// channel trust, workflow attribution, and policy, with no production-visible + /// raw-policy helper alongside it. This test drives that exact callable + /// against a live NIP-11 document, so it fails if identity loading, + /// effective-author resolution, DM classification, or policy application + /// regresses. Replacing either listener call with the former raw-signer + /// `author_allowed` path is now a compile error because that policy is + /// private to the gate module. + #[tokio::test] + async fn test_connected_gate_wakes_owner_only_agent_for_relay_signed_workflow() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + assert!( + gate.has_relay_identity(), + "the gate must load the relay signing identity during construction" + ); + + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); + cache.cache_sibling(relay_hex.clone(), false); + + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let buzz_event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event, + }; + let decision = gate + .evaluate_listener_event( + &buzz_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &cache, + &channel_info, + &rest_client, + ) + .await; + + assert_eq!( + decision.effective_author, workflow_owner, + "a connected gate must attribute a relay-signed workflow dispatch to its owner, not the relay signer" + ); + assert!( + decision.allowed, + "an owner-only agent must wake for its own workflow's explicit mention" + ); + server.abort(); + } + + /// A gate whose relay identity is unavailable must fall back to the raw + /// signer and stay closed — the documented fail-closed behavior, and the + /// exact state the wiring regression above proves the listeners avoid. + #[tokio::test] + async fn test_gate_without_relay_identity_fails_closed_to_raw_signer() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + // A NIP-11 document with no `self` key: attribution is unavailable. + let (rest_client, server) = nip11_server(serde_json::json!({ "name": "relay" })).await; + + let gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + assert!( + !gate.has_relay_identity(), + "a NIP-11 document without `self` must leave attribution unavailable" + ); + + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(relay_hex.clone(), false); + + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + + assert_eq!( + decision.effective_author, relay_hex, + "without a verified relay identity the gate must fall back to the raw signer" + ); + assert!( + !decision.allowed, + "unattributed relay-signed output must not wake an owner-only agent" + ); + server.abort(); + } + + /// The first authorized event after reconnect must restore attribution + /// through the same decision boundary both listeners use, without a + /// separate identity-refresh call. + #[tokio::test] + async fn test_gate_refresh_arms_attribution_after_reconnect() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + + // Construct against an unreachable relay: no identity yet. + let unreachable = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:1".into(), + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + let mut gate = InboundAuthorGate::connect(&unreachable, &agent, "test").await; + assert!(!gate.has_relay_identity()); + + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); + cache.cache_sibling(relay_hex, false); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let buzz_event = relay::BuzzEvent { + connection_generation: 1, + channel_id, + event, + }; + + let decision = gate + .evaluate_listener_event( + &buzz_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!( + decision.effective_author, workflow_owner, + "a reconnect refresh must restore delegated workflow attribution" + ); + assert!(decision.allowed); + server.abort(); + } + + #[test] + fn refresh_needed_until_generation_completes() { + use super::inbound_author_gate::refresh_needed; + assert!(refresh_needed(None, 0)); + assert!(refresh_needed(None, 1)); + assert!(!refresh_needed(Some(0), 0)); + assert!(refresh_needed(Some(0), 1)); + assert!(!refresh_needed(Some(1), 1)); + assert!(!refresh_needed(Some(1), 0)); + assert!(refresh_needed(Some(1), 2)); + } + + #[tokio::test] + async fn test_generation_zero_retries_failed_startup_identity() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + // Both startup probes fail; HTTP then recovers without a WS reconnect. + let (rest_client, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Err(()), + Err(()), + Ok(serde_json::json!({ "self": relay_hex.clone() })), + ])) + .await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "startup").await; + assert!(!gate.has_relay_identity()); + let channel_id = Uuid::new_v4(); + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(relay_hex.clone(), false); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent), + }; + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + server.abort(); + assert!( + decision.allowed, + "a generation-0 workflow wake must recover after the startup NIP-11 failure" + ); + assert_eq!(decision.effective_author, workflow_owner); + } + + #[tokio::test] + async fn test_authoritative_startup_result_completes_generation_zero() { + let relay_keys = nostr::Keys::generate(); + let next_relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let next_relay_hex = next_relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(relay_hex.clone(), false); + owner_cache.cache_sibling(next_relay_hex.clone(), false); + for identity in [Some(relay_hex.clone()), None] { + let document = match &identity { + Some(key) => serde_json::json!({ "self": key }), + None => serde_json::json!({ "name": "relay without stable identity" }), + }; + let mut responses = std::collections::VecDeque::from([Ok(document.clone())]); + if identity.is_none() { + // A missing `self` probes /info as well as the root. + responses.push_back(Ok(document)); + } + responses.push_back(Ok(serde_json::json!({ "self": next_relay_hex.clone() }))); + let (rest_client, server) = nip11_scripted_server(responses).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "startup").await; + assert_eq!(gate.relay_identity_for_test(), identity.as_deref()); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let mut event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent), + }; + for _ in 0..2 { + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!(decision.allowed, identity.is_some()); + assert_eq!( + gate.relay_identity_for_test(), + identity.as_deref(), + "an authoritative startup response must not be fetched again at generation 0" + ); + } + event.connection_generation = 1; + event.event = relay_signed_workflow_dispatch(&next_relay_keys, &workflow_owner, &agent); + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert!(decision.allowed); + assert_eq!(decision.effective_author, workflow_owner); + assert_eq!( + gate.relay_identity_for_test(), + Some(next_relay_hex.as_str()), + "a later connection must still refresh after authoritative startup" + ); + server.abort(); + } + } + + #[tokio::test] + async fn test_generation_refresh_retries_after_nip11_failure() { + let old_relay = nostr::Keys::generate(); + let new_relay = nostr::Keys::generate(); + let old_relay_hex = old_relay.public_key().to_hex(); + let new_relay_hex = new_relay.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let channel_id = uuid::Uuid::new_v4(); + let (rest_client, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Ok(serde_json::json!({ "self": old_relay_hex.clone() })), + Err(()), + Err(()), + Ok(serde_json::json!({ "self": new_relay_hex.clone() })), + ])) + .await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(old_relay_hex.clone(), false); + owner_cache.cache_sibling(new_relay_hex.clone(), false); + let channel_info = pool::ChannelInfoResolver::new( + std::collections::HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "test".into(), + channel_type: "stream".into(), + description: None, }, - ], - &keys, - ) - .expect("valid project events"); + )]), + rest_client.clone(), + ); - assert_eq!(events.len(), 2); - assert!(events.iter().all(|event| event.pubkey == keys.public_key())); - assert!(events.iter().all(|event| event.verify().is_ok())); - } + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); - #[test] - fn project_owner_control_rejects_arbitrary_or_unaddressed_events() { - let keys = Keys::generate(); - let arbitrary = build_project_owner_announcement_events( - vec![ProjectOwnerAnnouncementTemplate { - kind: 1, - content: String::new(), - created_at: None, - tags: vec![vec!["d".to_string(), "project".to_string()]], - }], - &keys, + let new_event = relay::BuzzEvent { + connection_generation: 2, + channel_id, + event: relay_signed_workflow_dispatch(&new_relay, &workflow_owner, &agent), + }; + let first_new = gate + .evaluate_listener_event( + &new_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); + assert!( + !first_new.allowed, + "the new signer must remain fail-closed while NIP-11 is unavailable" ); - assert!(arbitrary.is_err()); - let unaddressed = build_project_owner_announcement_events( - vec![ProjectOwnerAnnouncementTemplate { - kind: 30_621, - content: String::new(), - created_at: None, - tags: vec![], - }], - &keys, + let recovered = gate + .evaluate_listener_event( + &new_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!(gate.relay_identity_for_test(), Some(new_relay_hex.as_str())); + assert_eq!(recovered.effective_author, workflow_owner); + assert!( + recovered.allowed, + "a later event on the same connection must use the refreshed relay key" ); - assert!(unaddressed.is_err()); - } -} -#[cfg(test)] -mod owner_cache_tests { - use super::*; + let stale_old_event = relay::BuzzEvent { + connection_generation: 2, + channel_id, + event: relay_signed_workflow_dispatch(&old_relay, &workflow_owner, &agent), + }; + let stale = gate + .evaluate_listener_event( + &stale_old_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert!(!stale.allowed, "the rotated-away relay key must be evicted"); - #[test] - fn new_with_some_caches_immediately() { - let cache = OwnerCache::new(Some("abcd".into())); - assert_eq!(cache.get(), Some("abcd")); + server.abort(); } - #[test] - fn new_with_none_returns_none() { - let cache = OwnerCache::new(None); - assert!(cache.get().is_none()); - } + #[tokio::test] + async fn test_combined_gate_accepts_explicit_trusted_workflow_target_only() { + let relay = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent.as_str()]) + .expect("workflow mention tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("recipient tag"), + ]) + .sign_with_keys(&relay) + .expect("signed workflow event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); - #[test] - fn get_returns_cached_value() { - let cache = OwnerCache::new(Some("ab".repeat(32))); - assert_eq!(cache.get(), Some("ab".repeat(32)).as_deref()); + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + assert_eq!(decision.effective_author, workflow_owner); + assert!( + decision.allowed, + "a verified workflow owner for an explicitly targeted agent must flow through the existing sibling policy" + ); + server.abort(); } -} -#[cfg(test)] -mod author_gate_tests { - use super::*; - - /// A `RestClient` for tests. The author-gate decisions exercised here all - /// resolve from the owner pubkey or sibling cache before any HTTP call, so - /// this client is never actually used to make a request. - fn dummy_rest_client() -> relay::RestClient { - relay::RestClient { - http: reqwest::Client::new(), - base_url: "http://localhost:0".into(), - keys: nostr::Keys::generate(), - auth_tag_json: None, - } + #[tokio::test] + async fn test_combined_gate_rejects_owner_p_tag_without_explicit_workflow_target() { + let relay = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = workflow_owner.clone(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("legacy owner p tag"), + ]) + .sign_with_keys(&relay) + .expect("signed workflow event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(relay.public_key().to_hex(), false); + + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + server.abort(); + assert_eq!(decision.effective_author, relay.public_key().to_hex()); + assert!( + !decision.allowed, + "the legacy owner p tag alone must not wake an agent-owned workflow" + ); } - const OWNER: &str = "00"; - const SIBLING: &str = "11"; - const EXTERNAL: &str = "22"; - const STRANGER: &str = "33"; - - /// Owner + a known sibling, none of them on the explicit allowlist. - fn cache_with_sibling() -> OwnerCache { - let cache = OwnerCache::new(Some(OWNER.into())); - cache.cache_sibling(SIBLING.into(), true); - cache.cache_sibling(STRANGER.into(), false); - cache.cache_sibling(EXTERNAL.into(), false); - cache + #[tokio::test] + async fn test_combined_gate_rejects_forged_workflow_attribution() { + let relay = nostr::Keys::generate(); + let attacker = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent.as_str()]) + .expect("workflow mention tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("recipient tag"), + ]) + .sign_with_keys(&attacker) + .expect("signed forged event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(attacker.public_key().to_hex(), false); + + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate_for_test( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + server.abort(); + assert_eq!(decision.effective_author, attacker.public_key().to_hex()); + assert!( + !decision.allowed, + "an attacker-signed workflow event must not borrow trusted owner authority" + ); } #[tokio::test] @@ -5494,7 +7524,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, SIBLING, @@ -5512,7 +7542,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -5530,7 +7560,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, STRANGER, @@ -5548,7 +7578,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::new(); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, OWNER, @@ -5569,7 +7599,7 @@ mod author_gate_tests { async fn test_owner_only_rejects_stranger_so_no_steer() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::OwnerOnly, &HashSet::new(), STRANGER, @@ -5587,7 +7617,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::OwnerOnly, &HashSet::new(), who, @@ -5613,7 +7643,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -5630,7 +7660,7 @@ mod author_gate_tests { async fn test_dm_rejects_stranger_under_anyone() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Anyone, &HashSet::new(), STRANGER, @@ -5653,7 +7683,7 @@ mod author_gate_tests { ] { for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &mode, &HashSet::new(), who, @@ -5672,7 +7702,7 @@ mod author_gate_tests { async fn test_dm_nobody_rejects_even_owner() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Nobody, &HashSet::new(), OWNER, @@ -5812,7 +7842,7 @@ mod author_gate_tests { let is_dm = is_dm_channel(id, &channel_info).await; assert!(is_dm, "unknown startup metadata must fail closed as DM"); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -6900,6 +8930,7 @@ mod build_mcp_servers_tests { initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, + session_policy: scope::SessionPolicy::Channel, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -7124,6 +9155,7 @@ mod error_outcome_emission_tests { initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, + session_policy: scope::SessionPolicy::Channel, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -7199,14 +9231,14 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let steer_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "live-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "live-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7215,6 +9247,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7241,7 +9274,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7262,23 +9295,25 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn in_flight_stale_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "replacement-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7287,6 +9322,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7313,7 +9349,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7334,9 +9370,11 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7344,50 +9382,54 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let steer_event_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "live-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "live-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &scope::SessionScope::Conversation { channel_id }, steer_event_id.into(), "live-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("idle returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn late_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "replacement-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(!pool.record_successful_steer( - channel_id, + &scope::SessionScope::Conversation { channel_id }, "stale-event".into(), "old-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("replacement agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7402,6 +9444,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7427,7 +9470,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7448,7 +9491,10 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(!returned.state.deliveries.contains_key(&channel_id)); + assert!(!returned + .state + .deliveries + .contains_key(&scope::SessionScope::Conversation { channel_id })); } /// Drive one error outcome through `handle_prompt_result` and return how @@ -7467,6 +9513,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7490,7 +9537,9 @@ mod error_outcome_emission_tests { let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7544,6 +9593,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7595,6 +9645,103 @@ mod error_outcome_emission_tests { assert_eq!(panic.turn_id.as_deref(), Some("panic-turn-id")); } + // Fix #3: a panicked thread-scoped task must clear its EXACT scope from the + // in-flight set (via meta.scope), not `Conversation(channel_id)`. Otherwise + // the requeued batch stays wedged until the ~2h in-flight backstop. + #[tokio::test] + async fn panic_recovery_frees_the_exact_thread_scope() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let scope = scope::SessionScope::Thread { + channel_id, + root_event_id: "a".repeat(64), + }; + + // A thread-scoped batch is in flight (queue marks the Thread scope). + let mut queue = EventQueue::new(config::DedupMode::Queue); + let event = EventBuilder::new(Kind::Custom(9), "x") + .tags([]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + queue.push(queue::QueuedEvent { + channel_id, + scope: scope.clone(), + event, + received_at: std::time::Instant::now(), + prompt_tag: "t".into(), + }); + let batch = queue.flush_next().expect("flush thread batch"); + assert!(queue.is_scope_in_flight(&scope)); + + // Spawn a task we can panic/abort, wired to the same scope + a + // recoverable batch so recovery requeues it. + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async move { + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }); + pool.task_map_mut().insert( + abort_handle.id(), + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + scope: Some(scope.clone()), + turn_id: "panic-turn-id".to_string(), + recoverable_batch: Some(batch), + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + started_rx.await.unwrap(); + abort_handle.abort(); + let join_error = pool.join_set.join_next().await.unwrap().unwrap_err(); + + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut typing_channels = HashMap::new(); + // Pre-open the circuit so recovery returns before attempting a real + // respawn subprocess (mark_complete runs before the circuit check). + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: Some(std::time::Instant::now() + Duration::from_secs(3600)), + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + + recover_panicked_agent( + &mut pool, + &mut queue, + &config, + join_error, + &mut heartbeat_in_flight, + &removed_channels, + &mut typing_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + ); + + // The exact Thread scope is freed and the requeued batch is flushable + // again immediately — not stranded behind a Conversation(channel_id) + // entry until the backstop deadline. + assert!( + !queue.is_scope_in_flight(&scope), + "panic recovery must clear the exact Thread scope" + ); + // The requeued batch is queued again (recovery uses `requeue`, which + // applies a short retry backoff — so it is undispatched work now and + // becomes flushable once the backoff expires, rather than being stranded + // in-flight behind the wrong scope until the ~2h backstop). + assert!( + queue.has_undispatched_work(), + "requeued thread batch must be queued (undispatched) after recovery" + ); + } + #[tokio::test] async fn idle_timeout_emits_exactly_one_feed_event() { assert_eq!( @@ -7637,6 +9784,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7658,7 +9806,9 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7706,8 +9856,10 @@ mod error_outcome_emission_tests { let event = EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&keys) .unwrap(); + let __cid = Uuid::new_v4(); FlushBatch { - channel_id: Uuid::new_v4(), + channel_id: __cid, + scope: scope::SessionScope::Conversation { channel_id: __cid }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7729,6 +9881,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7749,7 +9902,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -7769,7 +9922,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -7815,6 +9968,7 @@ mod error_outcome_emission_tests { .unwrap(); FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7835,6 +9989,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7855,7 +10010,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -7875,7 +10030,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -7912,6 +10067,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7933,6 +10089,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&Keys::generate()) @@ -7945,7 +10102,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -8007,6 +10164,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8027,6 +10185,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "final-attempt") .sign_with_keys(&Keys::generate()) @@ -8039,7 +10198,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -8073,7 +10232,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 0, "batch with an exhausted retry budget must be dead-lettered, not requeued" ); @@ -8107,6 +10266,7 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: original_event.clone(), prompt_tag: "test".into(), @@ -8124,6 +10284,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8138,6 +10299,7 @@ mod error_outcome_emission_tests { // handle_prompt_result runs. queue.push(QueuedEvent { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, event: new_event.clone(), received_at: std::time::Instant::now(), prompt_tag: "test".into(), @@ -8156,7 +10318,7 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), batch: Some(batch), @@ -8264,6 +10426,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8286,7 +10449,9 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), // Explicit Stop already dropped the batch upstream in @@ -8366,11 +10531,13 @@ mod error_outcome_emission_tests { #[tokio::test] async fn indeterminate_project_context_requeues_without_poisoning_agent_or_circuit() { let channel_id = Uuid::new_v4(); + let session_scope = scope::SessionScope::Conversation { channel_id }; let event = EventBuilder::new(Kind::Custom(9), "project work") .sign_with_keys(&Keys::generate()) .unwrap(); let batch = FlushBatch { channel_id, + scope: session_scope.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8384,7 +10551,7 @@ mod error_outcome_emission_tests { agent .state .sessions - .insert(channel_id, "healthy-session".into()); + .insert(session_scope.clone(), "healthy-session".into()); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); pool.task_map_mut().insert( @@ -8392,6 +10559,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(session_scope.clone()), turn_id: "indeterminate-project".into(), recoverable_batch: None, control_tx: None, @@ -8412,7 +10580,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(session_scope.clone()), turn_id: "indeterminate-project".into(), outcome: PromptOutcome::ProjectContextIndeterminate( "project context is indeterminate".into(), @@ -8441,10 +10609,14 @@ mod error_outcome_emission_tests { .as_ref() .expect("healthy agent returns to its slot"); assert_eq!( - returned.state.sessions.get(&channel_id).map(String::as_str), + returned + .state + .sessions + .get(&session_scope) + .map(String::as_str), Some("healthy-session") ); - assert_eq!(queue.queued_event_count(&channel_id), 1); + assert_eq!(queue.queued_event_count(channel_id), 1); assert!(crash_history[0].crash_times.is_empty()); assert!(crash_history[0].open_until.is_none()); assert!(!crash_history[0].respawn_in_flight); @@ -8518,6 +10690,7 @@ mod error_outcome_emission_tests { let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8541,6 +10714,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8561,7 +10735,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(auth_error), batch: Some(batch), @@ -8587,7 +10761,7 @@ mod error_outcome_emission_tests { "auth error must dead-letter immediately — batch must not be requeued" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 0, "auth error must dead-letter immediately — no events should be pending" ); @@ -8604,6 +10778,7 @@ mod error_outcome_emission_tests { let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8627,6 +10802,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8647,7 +10823,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(usage_error), batch: Some(batch), @@ -8673,7 +10849,7 @@ mod error_outcome_emission_tests { "non-auth application error must requeue the batch for retry" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 1, "non-auth application error must preserve the event for retry" ); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index f18f7d6fea2..4d20e30ee23 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -34,7 +34,7 @@ use crate::acp::{ model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, StopReason, SystemPromptTransport, }; -use crate::config::{compose_session_title, DedupMode, PermissionMode}; +use crate::config::{compose_scoped_session_title, DedupMode, PermissionMode}; use crate::observer; use crate::prompt_project::{pick_authoritative_project_home, PromptProjectInfo}; use crate::queue::{ @@ -42,6 +42,7 @@ use crate::queue::{ PromptProfile, PromptProfileLookup, ThreadTags, }; use crate::relay::{ChannelInfo, RestClient}; +use crate::scope::SessionScope; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). @@ -60,6 +61,10 @@ pub struct SuccessfulSteerDelivery { pub struct TaskMeta { pub agent_index: usize, pub channel_id: Option, + /// Session scope of the in-flight turn (mid-turn steer/signal routing and + /// scope-to-worker affinity target this). `None` for heartbeat tasks. + /// Invariant when `Some`: `scope.channel_id() == channel_id.unwrap()`. + pub scope: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -113,37 +118,37 @@ pub struct ChannelDeliveryState { /// spawning a real agent subprocess. #[derive(Default)] pub struct SessionState { - /// channel_id → session_id - pub sessions: HashMap, + /// session scope → session_id + pub sessions: HashMap, pub heartbeat_session: Option, - /// Per-channel turn counters for proactive session rotation. + /// Per-scope turn counters for proactive session rotation. /// Incremented on each successful prompt; reset when the session is rotated. - pub turn_counts: HashMap, + pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, /// Whether the live heartbeat session has successfully received ``. pub heartbeat_standing_context_sent: bool, - /// channel_id → rendered NIP-AE core prompt section, populated once at + /// session scope → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). - pub core_sections: HashMap, - /// channel_id → rendered `` metadata section. + pub core_sections: HashMap, + /// session scope → rendered `` metadata section. /// /// Populated once before session creation (same lifecycle as `core_sections`). /// Absent when the channel has no canvas, the canvas content is blank, or the /// fetch fails — all fail open. Cleared on session invalidation alongside /// `core_sections` so the next session picks up any canvas change. - pub canvas_sections: HashMap, - /// Per-channel successful-delivery state. Created with the ACP session and + pub canvas_sections: HashMap, + /// Per-scope successful-delivery state. Created with the ACP session and /// cleared atomically with every invalidation path. - pub deliveries: HashMap, + pub deliveries: HashMap, } impl SessionState { /// Invalidate the session (and turn counter) for a specific prompt source. pub fn invalidate(&mut self, source: &PromptSource) { match source { - PromptSource::Channel(cid) => { - self.invalidate_channel(cid); + PromptSource::Channel(scope) => { + self.invalidate_scope(scope); } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -153,14 +158,39 @@ impl SessionState { } } - /// Invalidate a single channel's session and turn counter. - /// Returns `true` if the channel had an active session. - pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool { - self.turn_counts.remove(channel_id); - self.core_sections.remove(channel_id); - self.canvas_sections.remove(channel_id); - self.deliveries.remove(channel_id); - self.sessions.remove(channel_id).is_some() + /// Invalidate a single session scope's session and turn counter. + /// Returns `true` if the scope had an active session. + pub fn invalidate_scope(&mut self, scope: &SessionScope) -> bool { + self.turn_counts.remove(scope); + self.core_sections.remove(scope); + self.canvas_sections.remove(scope); + self.deliveries.remove(scope); + self.sessions.remove(scope).is_some() + } + + /// Invalidate every session scope belonging to `channel_id` (channel-wide + /// cleanup, e.g. when the agent is removed from a channel). Returns the + /// number of scopes that had an active session. + pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> usize { + let scopes: Vec = self + .sessions + .keys() + .chain(self.turn_counts.keys()) + .chain(self.core_sections.keys()) + .chain(self.canvas_sections.keys()) + .chain(self.deliveries.keys()) + .filter(|s| s.channel_id() == *channel_id) + .cloned() + .collect::>() + .into_iter() + .collect(); + let mut count = 0; + for scope in scopes { + if self.invalidate_scope(&scope) { + count += 1; + } + } + count } /// Invalidate all sessions and turn counters (e.g. after agent exit). @@ -175,24 +205,25 @@ impl SessionState { self.deliveries.clear(); } - pub(crate) fn mark_channel_delivery_success( + pub(crate) fn mark_scope_delivery_success( &mut self, - channel_id: Uuid, + scope: SessionScope, standing_context_sent: bool, event_ids: impl IntoIterator, ) { - let delivery = self.deliveries.entry(channel_id).or_default(); + let delivery = self.deliveries.entry(scope).or_default(); delivery.standing_context_sent |= standing_context_sent; delivery.delivered_event_ids.extend(event_ids); } #[cfg(test)] fn has_channel_state(&self, channel_id: &Uuid) -> bool { - self.sessions.contains_key(channel_id) - || self.turn_counts.contains_key(channel_id) - || self.core_sections.contains_key(channel_id) - || self.canvas_sections.contains_key(channel_id) - || self.deliveries.contains_key(channel_id) + let matches = |s: &SessionScope| s.channel_id() == *channel_id; + self.sessions.keys().any(matches) + || self.turn_counts.keys().any(matches) + || self.core_sections.keys().any(matches) + || self.canvas_sections.keys().any(matches) + || self.deliveries.keys().any(matches) } } @@ -299,6 +330,13 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver, pub join_set: JoinSet<()>, task_map: HashMap, + /// Authoritative directory of which worker most recently owned each session + /// scope's provider session. Survives while a worker is checked out (its + /// `SessionState` is invisible to the pool then), so a busy owner does not + /// cause another worker to open a duplicate session for the same thread. + /// Best-effort: stale entries (rotation, crash/respawn) self-heal on the + /// next dispatch and are pruned on channel-wide session invalidation. + session_owners: HashMap, } /// Result returned by a completed prompt task. @@ -313,12 +351,40 @@ pub struct PromptResult { } /// Whether the prompt came from a channel event or a heartbeat. +/// +/// The channel variant carries the full [`SessionScope`] resolved at admission +/// (conversation or thread), not just the channel id, so completion and +/// invalidation target the exact session. Use [`channel_id`](PromptSource::channel_id) +/// where only the channel is needed. #[derive(Debug)] pub enum PromptSource { - Channel(Uuid), + Channel(SessionScope), Heartbeat, } +impl PromptSource { + /// The channel this prompt belongs to, or `None` for heartbeats. + pub fn channel_id(&self) -> Option { + match self { + Self::Channel(scope) => Some(scope.channel_id()), + Self::Heartbeat => None, + } + } + + /// The exact session scope this prompt belongs to, or `None` for + /// heartbeats. Callers that must target the precise thread (e.g. clearing a + /// typing indicator on completion) use this rather than [`channel_id`], so a + /// finishing turn never disturbs a sibling thread in the same channel. + /// + /// [`channel_id`]: PromptSource::channel_id + pub fn scope(&self) -> Option<&SessionScope> { + match self { + Self::Channel(scope) => Some(scope), + Self::Heartbeat => None, + } + } +} + /// Apply state effects for Race 1, where a control signal arrives just after the /// prompt completed naturally. The prompt result has already been consumed by /// `select!`, so the harness must synthesize a successful result while still @@ -700,18 +766,16 @@ pub struct PromptContext { pub turn_liveness_interval: Duration, pub dedup_mode: DedupMode, pub system_prompt: Option, - /// Sanitized title for each new ACP session, sent as `_meta.sessionTitle` - /// on `session/new`. Never part of the prompt. + /// Sanitized agent name used to compose `_meta.sessionTitle` on session/new. + /// Channel sessions add the channel name; thread sessions also add the root + /// ID prefix. Never part of the prompt. pub session_title: Option, pub team_instructions: Option, pub heartbeat_prompt: Option, - /// Base prompt content, or `None` if `--no-base-prompt` was passed. - /// - /// `'static` because `PromptContext` is `Arc`-shared across async tasks. - /// Content from `--base-prompt-file` is promoted via `Box::leak` in `main.rs` - /// after validated file read in `Config::from_cli()`. The compiled-in default - /// (`include_str!`) is inherently `'static`. - pub base_prompt: Option<&'static str>, + /// Base instructions with the configured policy's Session Model appended, + /// assembled once and shared by modern and legacy ACP standing context. + /// `None` when `--no-base-prompt` was passed. + pub base_prompt: Option, pub cwd: String, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, @@ -759,21 +823,50 @@ impl AgentPool { result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + session_owners: HashMap::new(), + } + } + + /// Record which worker is handling `scope` so a later dispatch can detect a + /// busy owner and avoid opening a duplicate session on another worker. + pub fn record_scope_owner(&mut self, scope: SessionScope, agent_index: usize) { + self.session_owners.insert(scope, agent_index); + } + + /// True when this scope should be **held** (left queued) rather than + /// dispatched to a fresh worker, because the worker that owns its provider + /// session is currently checked out (busy on another turn). + /// + /// Only holds when no idle worker already holds the session + /// ([`has_session_for`](Self::has_session_for) is false): if an idle owner + /// exists, [`try_claim`](Self::try_claim) reuses it directly. Holding waits + /// for the busy owner to return so its exact session (and tool/turn + /// context) is reused, instead of forking a second session for the thread. + pub fn should_hold_for_busy_owner(&self, scope: &SessionScope) -> bool { + if self.has_session_for(scope) { + return false; + } + match self.session_owners.get(scope) { + Some(&owner_idx) => self.task_map.values().any(|m| m.agent_index == owner_idx), + None => false, } } - /// Try to claim an idle agent for the given channel (or heartbeat if `None`). + /// Try to claim an idle agent for the given session scope (or heartbeat if + /// `None`). /// - /// Pass 1: prefer an agent that already has a session for `channel_id`. + /// Pass 1: prefer an agent that already has a session for this exact scope + /// (thread affinity — repeated activity in a thread reuses that thread's + /// provider session). /// Pass 2: any idle agent. /// /// Returns `None` if all agents are checked out. - pub fn try_claim(&mut self, channel_id: Option) -> Option { - // Pass 1: prefer agent with existing session for this channel. - if let Some(cid) = channel_id { + pub fn try_claim(&mut self, scope: Option<&SessionScope>) -> Option { + // Pass 1: prefer agent with existing session for this scope. + if let Some(scope) = scope { let idx = self.agents.iter().position(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&cid)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }); if let Some(i) = idx { @@ -807,12 +900,12 @@ impl AgentPool { self.agents.iter().any(|slot| slot.is_some()) } - /// Whether any idle agent already has a session for `channel_id`. + /// Whether any idle agent already has a session for `scope`. /// Used to compute `affinity_hit` before calling `try_claim`. - pub fn has_session_for(&self, channel_id: Uuid) -> bool { + pub fn has_session_for(&self, scope: &SessionScope) -> bool { self.agents.iter().any(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&channel_id)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }) } @@ -858,13 +951,13 @@ impl AgentPool { /// event and let normal dispatch handle delivery. pub fn send_steer( &mut self, - channel_id: Uuid, + scope: &SessionScope, request: SteerRequest, ) -> Result<(), SteerError> { let meta = self .task_map .values_mut() - .find(|m| m.channel_id == Some(channel_id)) + .find(|m| m.scope.as_ref() == Some(scope)) .ok_or(SteerError::PromptCompleted)?; let tx = meta .steer_tx @@ -880,14 +973,14 @@ impl AgentPool { /// we write directly to the idle agent's matching live-session ledger. pub fn record_successful_steer( &mut self, - channel_id: Uuid, + scope: &SessionScope, event_id: String, session_id: String, ) -> bool { if let Some(meta) = self .task_map .values_mut() - .find(|meta| meta.channel_id == Some(channel_id)) + .find(|meta| meta.scope.as_ref() == Some(scope)) { meta.successful_steer_deliveries .insert(SuccessfulSteerDelivery { @@ -898,13 +991,13 @@ impl AgentPool { } let Some(agent) = self.agents.iter_mut().flatten().find(|agent| { - agent.state.sessions.get(&channel_id).map(String::as_str) == Some(session_id.as_str()) + agent.state.sessions.get(scope).map(String::as_str) == Some(session_id.as_str()) }) else { return false; }; agent .state - .mark_channel_delivery_success(channel_id, false, [event_id]); + .mark_scope_delivery_success(scope.clone(), false, [event_id]); true } @@ -955,17 +1048,65 @@ impl AgentPool { let mut count = 0; for slot in &mut self.agents { if let Some(agent) = slot.as_mut() { - if agent.state.invalidate_channel(&channel_id) { + // Channel-wide: clears every child thread scope for the channel. + count += agent.state.invalidate_channel(&channel_id); + } + } + // Drop every scope-owner entry for this channel so the directory does + // not grow without bound and cannot strand a held batch behind a stale + // owner after the channel's sessions are gone. + self.session_owners + .retain(|scope, _| scope.channel_id() != channel_id); + count + } + + /// Invalidate the session for one exact scope across every worker, and drop + /// its scope-owner entry. The scope-precise counterpart of + /// [`invalidate_channel_sessions`](Self::invalidate_channel_sessions): under + /// thread policy an idle `!rotate` in thread A must rotate only thread A's + /// session, leaving sibling threads in the same channel untouched. Under the + /// default channel policy the scope is `Conversation(channel_id)` — the sole + /// scope for the channel — so this matches the channel-wide behavior. + /// Returns the number of workers that held a session for the scope. + pub fn invalidate_scope_session(&mut self, scope: &SessionScope) -> usize { + let mut count = 0; + for slot in &mut self.agents { + if let Some(agent) = slot.as_mut() { + if agent.state.invalidate_scope(scope) { count += 1; } } } + self.session_owners.remove(scope); count } + /// Whether a channel-only control could name more than one session scope. + /// + /// Include idle and checked-out sessions, not just active turns: selecting + /// the first worker for an idle model switch is equally ambiguous. Stale + /// ownership entries may conservatively reject a control until reconciled. + pub fn channel_control_is_ambiguous(&self, channel_id: Uuid) -> bool { + let mut scopes = self + .session_owners + .keys() + .chain( + self.agents + .iter() + .flatten() + .flat_map(|a| a.state.sessions.keys()), + ) + .chain(self.task_map.values().filter_map(|m| m.scope.as_ref())) + .filter(|scope| scope.channel_id() == channel_id); + let Some(first) = scopes.next() else { + return false; + }; + scopes.any(|scope| scope != first) + } + /// Idle-path model switch: set `desired_model` on the idle agent for - /// `channel_id` and invalidate its session so the next turn re-creates the - /// session under the new model. + /// `channel_id` and invalidate its exact session scope so the next turn + /// re-creates that session under the new model. /// /// Pre-cancel guard: the desired model is validated against the agent's /// cached catalog *before* the session is invalidated, so an unsupported @@ -982,14 +1123,27 @@ impl AgentPool { model_id: &str, request_id: Option, ) -> IdleSwitchResult { - let Some(agent) = self - .agents - .iter_mut() - .flatten() - .find(|a| a.state.sessions.contains_key(&channel_id)) + if self.channel_control_is_ambiguous(channel_id) { + return IdleSwitchResult::AmbiguousTarget; + } + let Some((agent_index, scope)) = + self.agents.iter().enumerate().find_map(|(index, slot)| { + slot.as_ref().and_then(|agent| { + agent + .state + .sessions + .keys() + .find(|scope| scope.channel_id() == channel_id) + .cloned() + .map(|scope| (index, scope)) + }) + }) else { return IdleSwitchResult::NoIdleAgent; }; + let Some(agent) = self.agents.get_mut(agent_index).and_then(Option::as_mut) else { + return IdleSwitchResult::NoIdleAgent; + }; // Pre-cancel guard against the cached catalog. None = catalog not yet // populated (no session ever created); defer validation to apply time. @@ -1008,7 +1162,8 @@ impl AgentPool { // Carry the pick's correlator so a deferred-validation miss on the next // turn's session creation emits a late frame the Desktop can match. agent.desired_model_request_id = request_id; - agent.state.invalidate_channel(&channel_id); + agent.state.invalidate_scope(&scope); + self.session_owners.remove(&scope); IdleSwitchResult::Switched } } @@ -1016,7 +1171,9 @@ impl AgentPool { /// Outcome of [`AgentPool::switch_idle_agent_model`]. #[derive(Debug, PartialEq, Eq)] pub enum IdleSwitchResult { - /// `desired_model` set and the channel session invalidated. + /// More than one session scope belongs to this channel; nothing changed. + AmbiguousTarget, + /// `desired_model` set and the selected session invalidated. Switched, /// Desired model is not in the agent's cached catalog — pick rejected, /// session untouched. @@ -1100,7 +1257,7 @@ struct NewSessionChannelContext<'a> { huddle_instructions: Option<&'a str>, canvas: Option<&'a str>, name: Option<&'a str>, - id: Option, + scope: Option<&'a SessionScope>, channel_type: Option<&'a str>, } @@ -1121,7 +1278,11 @@ async fn create_session_and_apply_model( with_huddle_instructions( with_core( with_team( - framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + framed_system_prompt( + &ctx.cwd, + ctx.base_prompt.as_deref(), + ctx.system_prompt.as_deref(), + ), ctx.team_instructions.as_deref(), ), agent_core, @@ -1131,13 +1292,16 @@ async fn create_session_and_apply_model( channel.canvas, ); - let session_title = ctx - .session_title - .as_deref() - .map(|agent_name| compose_session_title(agent_name, channel.name)); + let session_title = ctx.session_title.as_deref().map(|agent_name| { + compose_scoped_session_title( + agent_name, + channel.name, + channel.scope.and_then(SessionScope::root_event_id), + ) + }); let mcp_servers = mcp_servers_with_git_origin( &ctx.mcp_servers, - channel.id, + channel.scope.map(SessionScope::channel_id), channel.channel_type, ctx.session_title.as_deref(), ); @@ -1877,13 +2041,10 @@ pub async fn run_prompt_task( ) { // Is this a channel prompt or a heartbeat? let source = match &batch { - Some(b) => PromptSource::Channel(b.channel_id), + Some(b) => PromptSource::Channel(b.scope.clone()), None => PromptSource::Heartbeat, }; - let observer_channel_id = match &source { - PromptSource::Channel(channel_id) => Some(*channel_id), - PromptSource::Heartbeat => None, - }; + let observer_channel_id = source.channel_id(); let turn_started_at = chrono::Utc::now().to_rfc3339(); agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, @@ -1959,11 +2120,11 @@ pub async fn run_prompt_task( // outcome: fail closed and preserve the batch without poisoning the healthy // ACP process. let resolved_channel_info = match &source { - PromptSource::Channel(channel_id) => match ctx.channel_info.resolve(*channel_id).await { + PromptSource::Channel(scope) => match ctx.channel_info.resolve(scope.channel_id()).await { Ok(info) => info, Err(error) => { tracing::warn!( - channel_id = %channel_id, + channel_id = %scope.channel_id(), "project context is indeterminate; requeueing turn before ACP session creation: {}", error.0 ); @@ -2007,11 +2168,15 @@ pub async fn run_prompt_task( // // Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch. if ctx.memory_enabled { - if let (PromptSource::Channel(cid), Some(owner_pk)) = + if let (PromptSource::Channel(scope), Some(owner_pk)) = (&source, ctx.agent_owner_pubkey.as_ref()) { - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - if is_new_channel_session && !agent.state.core_sections.contains_key(cid) { + // Session state is keyed by scope: repeated activity in a thread + // reuses exactly that thread's session. `cid` is only for + // channel-level fetches/logging. + let cid = &scope.channel_id(); + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + if is_new_channel_session && !agent.state.core_sections.contains_key(scope) { // Bounded — we'd rather start the session with no core hint // than block session creation on a stalled relay. const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); @@ -2036,10 +2201,11 @@ pub async fn run_prompt_task( tracing::info!( target: "engram::core", channel = %cid, + scope = %scope.telemetry_label(), section_len = rendered.len(), "injected NIP-AE core section into system prompt" ); - agent.state.core_sections.insert(*cid, rendered); + agent.state.core_sections.insert(scope.clone(), rendered); } } } @@ -2057,29 +2223,30 @@ pub async fn run_prompt_task( // commit it to `canvas_sections` only after session creation succeeds. This // prevents a stale revision A surviving a failed create and being re-used by // the next attempt after the canvas was cleared. - let mut pending_canvas: Option<(Uuid, String)> = None; + let mut pending_canvas: Option<(SessionScope, String)> = None; let mut huddle_instructions: Option = None; // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; let mut origin_channel_type: Option = None; - if let PromptSource::Channel(cid) = &source { - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); + if let PromptSource::Channel(scope) = &source { + let cid = scope.channel_id(); + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + let needs_canvas = + is_new_channel_session && !agent.state.canvas_sections.contains_key(scope); if is_new_channel_session { let (is_dm, resolved_channel, resolved_channel_type) = resolve_new_session_channel_context(resolved_channel_info.as_ref()).await; title_channel = resolved_channel; origin_channel_type = resolved_channel_type; if let Some(owner) = ctx.agent_owner_pubkey.as_ref() { - huddle_instructions = - fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await; + huddle_instructions = fetch_huddle_instructions(cid, owner, &ctx.rest_client).await; } // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { - if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await { - pending_canvas = Some((*cid, section)); + if let Some(section) = fetch_canvas_section(cid, &ctx.rest_client).await { + pending_canvas = Some((scope.clone(), section)); } } } @@ -2088,31 +2255,31 @@ pub async fn run_prompt_task( // The core section to fold into the system prompt for this turn's session. // Channel-scoped; heartbeats carry no owner core. let agent_core: Option = match &source { - PromptSource::Channel(cid) => agent.state.core_sections.get(cid).cloned(), + PromptSource::Channel(scope) => agent.state.core_sections.get(scope).cloned(), PromptSource::Heartbeat => None, }; // The canvas metadata section — channel-scoped, absent for heartbeats/DMs. // Prefer the committed cache; fall back to pending (for new sessions being created now). let agent_canvas: Option = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Channel(scope) => agent .state .canvas_sections - .get(cid) + .get(scope) .cloned() .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())), PromptSource::Heartbeat => None, }; let (session_id, is_new_session) = match &source { - PromptSource::Channel(cid) => { - if let Some(sid) = agent.state.sessions.get(cid) { + PromptSource::Channel(scope) => { + let cid = &scope.channel_id(); + if let Some(sid) = agent.state.sessions.get(scope) { (sid.clone(), false) } else { - // The title is channel-qualified (`Agent · #channel`) so one - // agent in several channels doesn't produce identical session - // rows; `title_channel` comes from the single resolve above and - // is `None` for DM, unresolved, and unnamed channels. + // The title includes channel and, for thread sessions, the + // canonical root prefix so sibling sessions are distinguishable. + // DMs, unresolved, and unnamed channels omit the channel name. match create_session_and_apply_model( &mut agent, &ctx, @@ -2121,7 +2288,7 @@ pub async fn run_prompt_task( huddle_instructions: huddle_instructions.as_deref(), canvas: agent_canvas.as_deref(), name: title_channel.as_deref(), - id: Some(*cid), + scope: Some(scope), channel_type: origin_channel_type.as_deref(), }, ) @@ -2130,19 +2297,20 @@ pub async fn run_prompt_task( Ok(sid) => { tracing::info!( target: "pool::session", - "created session {sid} for channel {cid}" + "created session {sid} for channel {cid} (scope {})", + scope.telemetry_label() ); - agent.state.sessions.insert(*cid, sid.clone()); + agent.state.sessions.insert(scope.clone(), sid.clone()); agent .state .deliveries - .insert(*cid, ChannelDeliveryState::default()); + .insert(scope.clone(), ChannelDeliveryState::default()); // Seed a zero usage baseline: buzz-acp spawned this session // so prior usage is zero by definition — first turn is reliable. agent.acp.notify_session_spawned(&sid); // Commit canvas only after session creation succeeds (I3). - if let Some((pending_cid, section)) = pending_canvas.take() { - agent.state.canvas_sections.insert(pending_cid, section); + if let Some((pending_scope, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_scope, section); } (sid, true) } @@ -2186,7 +2354,7 @@ pub async fn run_prompt_task( huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -2255,7 +2423,7 @@ pub async fn run_prompt_task( // whenever a session is invalidated — so the replacement session re-delivers // rather than leaving the agent unbriefed. let standing = crate::queue::StandingContext { - base_prompt: ctx.base_prompt, + base_prompt: ctx.base_prompt.as_deref(), system_prompt: ctx.system_prompt.as_deref(), team_instructions: ctx.team_instructions.as_deref(), agent_core: agent_core.as_deref(), @@ -2266,17 +2434,19 @@ pub async fn run_prompt_task( // sessions created before this field existed fail safe by behaving as // undelivered once, rather than silently omitting standing context. let mut standing_context_sent = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Channel(scope) => agent .state .deliveries - .get(cid) + .get(scope) .is_some_and(|delivery| delivery.standing_context_sent), PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, }; if is_new_session { - if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) + if let (PromptSource::Channel(scope), Some(ref initial_msg)) = + (&source, &ctx.initial_message) { + let cid = &scope.channel_id(); tracing::info!( target: "pool::session", "sending initial_message to session {session_id} for channel {cid}" @@ -2310,7 +2480,9 @@ pub async fn run_prompt_task( // prompt below must not repeat it. Every other arm returns. standing_context_sent = true; if !agent.has_system_prompt_support() { - agent.state.mark_channel_delivery_success(*cid, true, []); + agent + .state + .mark_scope_delivery_success(scope.clone(), true, []); } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -2452,7 +2624,7 @@ pub async fn run_prompt_task( 1 }, &crate::queue::StandingContext { - base_prompt: ctx.base_prompt, + base_prompt: ctx.base_prompt.as_deref(), ..Default::default() }, &text, @@ -2478,7 +2650,7 @@ pub async fn run_prompt_task( let delivered_ids = agent .state .deliveries - .get(&b.channel_id) + .get(&b.scope) .map(|delivery| &delivery.delivered_event_ids) .cloned() .unwrap_or_default(); @@ -2747,11 +2919,11 @@ pub async fn run_prompt_task( ); } log_stop_reason(&source, &StopReason::EndTurn); - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); - record_channel_delivery_success( + record_scope_delivery_success( &mut agent, - *cid, + scope.clone(), standing_sent, &pending_delivered_event_ids, ); @@ -2790,11 +2962,11 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); - record_channel_delivery_success( + record_scope_delivery_success( &mut agent, - *cid, + scope.clone(), standing_sent, &pending_delivered_event_ids, ); @@ -2811,8 +2983,8 @@ pub async fn run_prompt_task( let limit = ctx.max_turns_per_session; if limit > 0 { match &source { - PromptSource::Channel(cid) => { - let count = agent.state.turn_counts.entry(*cid).or_insert(0); + PromptSource::Channel(scope) => { + let count = agent.state.turn_counts.entry(scope.clone()).or_insert(0); *count += 1; *count >= limit } @@ -3491,8 +3663,21 @@ fn conversation_context_delta( /// - The REST fetch fails or times out (graceful degradation) /// - `context_message_limit` is 0 /// -/// For batches with multiple events, thread context is fetched for the **last** -/// reply event only (most recent = most likely to need a response). +/// Context is scoped by the batch's resolved [`SessionScope`], never inferred +/// from whichever event happens to be last: +/// +/// - **Thread scope** → fetch only that canonical thread's history (all +/// messages under the root, including intervening non-mention human +/// messages). A brand-new thread (root == the triggering event, first turn) +/// has no prior history, so this returns `None`, which is correct: the +/// trigger itself is delivered as the `[Event]` block. +/// - **Conversation scope** (DMs always; channels under the `channel` policy) +/// → preserve legacy behavior: a threaded reply fetches its reply chain; +/// a DM non-reply fetches recent conversation history. +/// +/// The delivery-delta filter (`conversation_context_delta`) then removes any +/// events this scope's live session already received, so subsequent turns +/// deliver only intervening same-thread messages plus the trigger. async fn fetch_conversation_context( batch: &FlushBatch, channel_info: &Option, @@ -3504,28 +3689,54 @@ async fn fetch_conversation_context( .map(|ci| ci.channel_type == "dm") .unwrap_or(false); - // Check thread tags on the last event first — this applies to both - // channels and DMs. A DM reply needs thread context (not channel history) - // because /api/channels/{id}/messages excludes thread replies. - let last_event = batch.events.last()?; - let tags = crate::queue::parse_thread_tags(&last_event.event); - if let Some(root_id) = tags.root_event_id { - return fetch_thread_context( - batch.channel_id, - &root_id, - limit, - ctx.agent_keys.public_key(), - &ctx.rest_client, - ) - .await; + match resolve_context_target(batch, is_dm) { + ContextTarget::Thread(root_id) => { + fetch_thread_context( + batch.channel_id, + &root_id, + limit, + ctx.agent_keys.public_key(), + &ctx.rest_client, + ) + .await + } + ContextTarget::Dm => fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await, + ContextTarget::None => None, } +} - // DM non-reply: fetch recent conversation history. +/// Which history to fetch for a batch's context section. +#[derive(Debug, PartialEq, Eq)] +enum ContextTarget { + /// Fetch the canonical thread rooted at this event id. + Thread(String), + /// Fetch recent DM conversation history. + Dm, + /// No supplementary context (new thread's first turn, or plain channel). + None, +} + +/// Decide which history to gather, driven by the batch's resolved +/// [`SessionScope`] — never by inferring scope from the last event. +/// +/// - Thread scope: the canonical root is authoritative. +/// - Conversation scope (DMs always; channels under `channel` policy): a +/// threaded reply fetches its reply chain; a DM non-reply fetches recent +/// conversation history; a plain top-level channel message has none. +fn resolve_context_target(batch: &FlushBatch, is_dm: bool) -> ContextTarget { + if let Some(root_id) = batch.scope.root_event_id() { + return ContextTarget::Thread(root_id.to_string()); + } + let Some(last_event) = batch.events.last() else { + return ContextTarget::None; + }; + if let Some(root_id) = crate::queue::parse_thread_tags(&last_event.event).root_event_id { + return ContextTarget::Thread(root_id); + } if is_dm { - return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await; + return ContextTarget::Dm; } - - None + ContextTarget::None } /// Normalize AND validate a pubkey for the batch profile API request. @@ -4248,7 +4459,11 @@ fn classify_control_cancel_failure( /// Shared by the turn-start and turn-stop lines so a log can be read as pairs. fn prompt_label(source: &PromptSource) -> String { match source { - PromptSource::Channel(cid) => format!("channel {cid}"), + PromptSource::Channel(scope) => format!( + "channel {} ({})", + scope.channel_id(), + scope.telemetry_label() + ), PromptSource::Heartbeat => "heartbeat".to_string(), } } @@ -4284,19 +4499,19 @@ fn delivery_receipt_line(channel_id: Uuid, event_ids: &HashSet) -> Strin ) } -fn record_channel_delivery_success( +fn record_scope_delivery_success( agent: &mut OwnedAgent, - channel_id: Uuid, + scope: SessionScope, standing_context_sent: bool, event_ids: &HashSet, ) { tracing::info!( target: "pool::prompt", "{}", - delivery_receipt_line(channel_id, event_ids) + delivery_receipt_line(scope.channel_id(), event_ids) ); - agent.state.mark_channel_delivery_success( - channel_id, + agent.state.mark_scope_delivery_success( + scope, standing_context_sent, event_ids.iter().cloned(), ); @@ -4917,6 +5132,12 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + /// Conversation scope for a channel — the scope these pool tests exercise + /// (equivalent to the pre-thread-scoping channel key). + fn conv(channel_id: Uuid) -> SessionScope { + SessionScope::Conversation { channel_id } + } + fn test_mcp_server() -> McpServer { McpServer { name: "dev".into(), @@ -6128,8 +6349,10 @@ mod tests { .sign_with_keys(&keys) .unwrap(); let author_hex = event.pubkey.to_hex(); + let channel_id = Uuid::new_v4(); let batch = FlushBatch { - channel_id: Uuid::new_v4(), + channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "@mention".into(), @@ -6263,7 +6486,7 @@ done"# agent.state.heartbeat_session = Some("live-session".into()); let mut ctx = make_prompt_context_no_owner(); - ctx.base_prompt = Some("standing-once"); + ctx.base_prompt = Some("standing-once".into()); let ctx = Arc::new(ctx); let (result_tx, mut result_rx) = mpsc::unbounded_channel(); @@ -6363,14 +6586,14 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); - ctx.base_prompt = Some("standing-once"); + ctx.base_prompt = Some("standing-once".into()); let ctx = Arc::new(ctx); let (result_tx, mut result_rx) = mpsc::unbounded_channel(); @@ -6381,6 +6604,7 @@ done"# let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -6407,7 +6631,7 @@ done"# PromptOutcome::Ok(StopReason::EndTurn) )), } - let delivery = &result.agent.state.deliveries[&channel_id]; + let delivery = &result.agent.state.deliveries[&conv(channel_id)]; assert_eq!( delivery.standing_context_sent, turn >= 2, @@ -6463,6 +6687,7 @@ done"# .unwrap(); let merged_batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: new_event.clone(), prompt_tag: "test".into(), @@ -6477,6 +6702,7 @@ done"# }; let next_batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: next_event, prompt_tag: "test".into(), @@ -6538,11 +6764,11 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); ctx.context_message_limit = 10; @@ -6584,7 +6810,7 @@ done"# )); agent = result.agent; } - let delivery = &agent.state.deliveries[&channel_id]; + let delivery = &agent.state.deliveries[&conv(channel_id)]; assert!(delivery.delivered_event_ids.contains(&carry_over_id)); assert!(delivery.delivered_event_ids.contains(&new_event_id)); agent.acp.shutdown().await; @@ -6632,6 +6858,7 @@ done"# .unwrap(); let batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: trigger, prompt_tag: "test".into(), @@ -6691,22 +6918,22 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); // Model the adversarial ordering: the task result has already retired // its TaskMeta and returned the agent before the successful ack arrives. let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &conv(channel_id), steered_event_id.clone(), "live-session".into(), )); let agent = pool - .try_claim(Some(channel_id)) + .try_claim(Some(&conv(channel_id))) .expect("claim returned agent"); let mut ctx = make_prompt_context_no_owner(); @@ -6769,19 +6996,19 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let mut state = SessionState::default(); state .deliveries - .insert(channel, ChannelDeliveryState::default()); + .insert(conv(channel), ChannelDeliveryState::default()); // Building or attempting a prompt does not mutate delivery state. - let delivery = state.deliveries.get(&channel).unwrap(); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(!delivery.standing_context_sent); assert!(delivery.delivered_event_ids.is_empty()); - state.mark_channel_delivery_success( - channel, + state.mark_scope_delivery_success( + conv(channel), true, ["trigger".to_string(), "context".to_string()], ); - let delivery = state.deliveries.get(&channel).unwrap(); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(delivery.standing_context_sent); assert_eq!(delivery.delivered_event_ids.len(), 2); } @@ -6790,17 +7017,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn delivery_state_is_cleared_on_rotation_and_restarts_empty() { let channel = Uuid::new_v4(); let mut state = SessionState::default(); - state.sessions.insert(channel, "old-session".into()); - state.mark_channel_delivery_success(channel, true, ["old-event".to_string()]); + state.sessions.insert(conv(channel), "old-session".into()); + state.mark_scope_delivery_success(conv(channel), true, ["old-event".to_string()]); - assert!(state.invalidate_channel(&channel)); - assert!(!state.deliveries.contains_key(&channel)); + assert!(state.invalidate_channel(&channel) > 0); + assert!(!state.deliveries.contains_key(&conv(channel))); - state.sessions.insert(channel, "new-session".into()); + state.sessions.insert(conv(channel), "new-session".into()); state .deliveries - .insert(channel, ChannelDeliveryState::default()); - let delivery = state.deliveries.get(&channel).unwrap(); + .insert(conv(channel), ChannelDeliveryState::default()); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(!delivery.standing_context_sent); assert!(delivery.delivered_event_ids.is_empty()); } @@ -6910,21 +7137,21 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.sessions.insert(ch_a, "sess-a".into()); - s.sessions.insert(ch_b, "sess-b".into()); - s.turn_counts.insert(ch_a, 5); - s.turn_counts.insert(ch_b, 3); - s.core_sections.insert(ch_a, "core-a".into()); - s.core_sections.insert(ch_b, "core-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); + s.sessions.insert(conv(ch_b), "sess-b".into()); + s.turn_counts.insert(conv(ch_a), 5); + s.turn_counts.insert(conv(ch_b), 3); + s.core_sections.insert(conv(ch_a), "core-a".into()); + s.core_sections.insert(conv(ch_b), "core-b".into()); s.deliveries.insert( - ch_a, + conv(ch_a), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-a".into()]), }, ); s.deliveries.insert( - ch_b, + conv(ch_b), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-b".into()]), @@ -6936,23 +7163,242 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" (s, ch_a, ch_b) } + fn thread_scope(channel_id: Uuid, root: &str) -> SessionScope { + SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + #[test] + fn two_threads_in_one_channel_get_distinct_sessions() { + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let mut s = SessionState::default(); + s.sessions.insert(ta.clone(), "sess-thread-a".into()); + s.sessions.insert(tb.clone(), "sess-thread-b".into()); + // Distinct roots key distinct provider sessions. + assert_eq!( + s.sessions.get(&ta).map(String::as_str), + Some("sess-thread-a") + ); + assert_eq!( + s.sessions.get(&tb).map(String::as_str), + Some("sess-thread-b") + ); + // Repeated activity under one root reuses that exact session. + assert_eq!( + s.sessions.get(&ta).map(String::as_str), + Some("sess-thread-a") + ); + // The conversation scope is a different key again (no accidental reuse). + assert!(!s.sessions.contains_key(&conv(ch))); + } + + #[test] + fn invalidate_scope_leaves_sibling_thread_untouched() { + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let mut s = SessionState::default(); + s.sessions.insert(ta.clone(), "a".into()); + s.sessions.insert(tb.clone(), "b".into()); + s.turn_counts.insert(ta.clone(), 2); + assert!(s.invalidate_scope(&ta)); + assert!(!s.sessions.contains_key(&ta)); + assert!(!s.turn_counts.contains_key(&ta)); + // Sibling thread's session survives. + assert_eq!(s.sessions.get(&tb).map(String::as_str), Some("b")); + } + + fn batch_with_scope(scope: SessionScope, event: nostr::Event) -> FlushBatch { + FlushBatch { + channel_id: scope.channel_id(), + scope, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + fn signed_event_with_tags(tags: Vec>) -> nostr::Event { + let keys = Keys::generate(); + let tags: Vec = tags.into_iter().map(|t| Tag::parse(t).unwrap()).collect(); + EventBuilder::new(Kind::Custom(9), "hi") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn context_target_uses_thread_scope_root_not_last_event_tags() { + let ch = Uuid::new_v4(); + let scope_root = "a".repeat(64); + // Last event carries a DIFFERENT root tag than the scope; the scope + // must win so context is gathered for the canonical thread. + let ev = signed_event_with_tags(vec![vec![ + "e".into(), + "b".repeat(64), + String::new(), + "root".into(), + ]]); + let batch = batch_with_scope(thread_scope(ch, &scope_root), ev); + assert_eq!( + resolve_context_target(&batch, false), + ContextTarget::Thread(scope_root) + ); + } + + #[test] + fn context_target_new_top_level_thread_has_no_history() { + // A top-level mention opens a thread rooted at its own id; on the first + // turn there is no prior thread history to fetch, but the scope still + // resolves to that root (subsequent turns fetch it). + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let root = ev.id.to_hex(); + let batch = batch_with_scope(thread_scope(ch, &root), ev); + assert_eq!( + resolve_context_target(&batch, false), + ContextTarget::Thread(root) + ); + } + + #[test] + fn context_target_conversation_channel_plain_has_none() { + // Channel-policy conversation scope + a plain (no-thread-tag) event => + // no unrelated channel transcript is injected. + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!(resolve_context_target(&batch, false), ContextTarget::None); + } + + #[test] + fn context_target_dm_nonreply_is_dm_history() { + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!(resolve_context_target(&batch, true), ContextTarget::Dm); + } + + #[test] + fn context_target_conversation_reply_uses_reply_chain() { + // DM (or legacy channel-policy) reply: conversation scope but the last + // event has thread tags => fetch that reply chain. + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + let ev = signed_event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "d".repeat(64), String::new(), "reply".into()], + ]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!( + resolve_context_target(&batch, true), + ContextTarget::Thread(root) + ); + } + + #[test] + fn invalidate_channel_clears_every_thread_scope() { + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + let mut s = SessionState::default(); + s.sessions + .insert(thread_scope(ch, &"a".repeat(64)), "a".into()); + s.sessions + .insert(thread_scope(ch, &"b".repeat(64)), "b".into()); + s.sessions.insert(conv(ch), "c".into()); + s.sessions + .insert(thread_scope(other, &"d".repeat(64)), "d".into()); + let cleared = s.invalidate_channel(&ch); + assert_eq!(cleared, 3, "all three ch scopes had sessions"); + assert!(s.sessions.keys().all(|k| k.channel_id() == other)); + } + + #[test] + fn prompt_source_scope_exposes_thread_scope_and_none_for_heartbeat() { + let ch = Uuid::new_v4(); + let scope = thread_scope(ch, &"a".repeat(64)); + let channel = PromptSource::Channel(scope.clone()); + // The scope-precise accessor returns the exact thread so a completing + // turn clears only its own typing indicator. + assert_eq!(channel.scope(), Some(&scope)); + assert_eq!(channel.channel_id(), Some(ch)); + assert_eq!(PromptSource::Heartbeat.scope(), None); + } + + #[tokio::test] + async fn invalidate_scope_session_targets_one_thread_and_drops_its_owner() { + // The idle `!rotate` path: rotating thread A must invalidate only thread + // A's session and drop its scope-owner entry, leaving a sibling thread + // in the same channel fully intact. + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let acp = AcpClient::spawn("bash", &["-c".into(), "sleep 10".into()], &[], false) + .await + .expect("spawn dummy ACP"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "test".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + agent.state.sessions.insert(ta.clone(), "sess-a".into()); + agent.state.sessions.insert(tb.clone(), "sess-b".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.record_scope_owner(ta.clone(), 0); + pool.record_scope_owner(tb.clone(), 0); + + let cleared = pool.invalidate_scope_session(&ta); + + assert_eq!(cleared, 1, "exactly one worker held thread A's session"); + assert!(!pool.has_session_for(&ta), "thread A session invalidated"); + assert!( + pool.has_session_for(&tb), + "sibling thread B session survives" + ); + assert!( + !pool.session_owners.contains_key(&ta), + "thread A owner dropped" + ); + assert!( + pool.session_owners.contains_key(&tb), + "thread B owner retained" + ); + } + #[test] fn test_rotate_after_natural_completion_invalidates_channel_state() { let (mut s, ch_a, ch_b) = make_state(); apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::Rotate, ); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); } @@ -6963,29 +7409,31 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::Cancel, ); - assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a"); - assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); + assert_eq!(s.sessions.get(&conv(ch_a)).unwrap(), "sess-a"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); } #[test] fn test_invalidate_channel_clears_session_and_turn_count() { let (mut s, ch_a, ch_b) = make_state(); - s.invalidate(&PromptSource::Channel(ch_a)); + s.invalidate(&PromptSource::Channel(SessionScope::Conversation { + channel_id: ch_a, + })); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); // ch_b untouched - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -7001,10 +7449,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(!s.heartbeat_standing_context_sent); // channels untouched assert_eq!(s.sessions.len(), 2); - assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } #[test] @@ -7024,15 +7472,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_nonexistent_channel_is_noop() { let (mut s, ch_a, ch_b) = make_state(); let ghost = Uuid::new_v4(); - s.invalidate(&PromptSource::Channel(ghost)); + s.invalidate(&PromptSource::Channel(SessionScope::Conversation { + channel_id: ghost, + })); // Everything still intact. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); - assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } #[test] @@ -7047,15 +7497,15 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn test_invalidate_channel_returns_true_when_session_existed() { let (mut s, ch_a, ch_b) = make_state(); - assert!(s.invalidate_channel(&ch_a)); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(s.invalidate_channel(&ch_a) > 0); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); // ch_b untouched - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -7065,7 +7515,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_channel_returns_false_when_no_session() { let (mut s, _ch_a, _ch_b) = make_state(); let ghost = Uuid::new_v4(); - assert!(!s.invalidate_channel(&ghost)); + assert_eq!(s.invalidate_channel(&ghost), 0); // Nothing changed. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); @@ -7080,13 +7530,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" for ch in &removed { s.invalidate_channel(ch); } - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(ch_a))); assert!(!s.has_channel_state(&ch_a)); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } // ── ControlSignal::SwitchModel (Phase 3a, Option ii) ───────────────────── @@ -7099,7 +7549,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" // re-creates a fresh session that re-applies the new desired_model. apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::SwitchModel { model_id: "gpt-5".into(), request_id: None, @@ -7108,8 +7558,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(!s.has_channel_state(&ch_a)); // ch_b untouched — the switch is channel-scoped. - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); } // ── requeue_cancelled_batch ──────────────────────────────────────────── @@ -7127,6 +7577,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" .unwrap(); FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -8348,14 +8799,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_channel_clears_canvas_section() { let ch = Uuid::new_v4(); let mut s = SessionState::default(); - s.sessions.insert(ch, "sess".into()); + s.sessions.insert(conv(ch), "sess".into()); s.canvas_sections - .insert(ch, "[Channel Canvas]\nrev abc".into()); + .insert(conv(ch), "[Channel Canvas]\nrev abc".into()); s.invalidate_channel(&ch); - assert!(!s.canvas_sections.contains_key(&ch)); - assert!(!s.sessions.contains_key(&ch)); + assert!(!s.canvas_sections.contains_key(&conv(ch))); + assert!(!s.sessions.contains_key(&conv(ch))); } #[test] @@ -8363,9 +8814,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.canvas_sections.insert(ch_a, "canvas-a".into()); - s.canvas_sections.insert(ch_b, "canvas-b".into()); - s.sessions.insert(ch_a, "sess-a".into()); + s.canvas_sections.insert(conv(ch_a), "canvas-a".into()); + s.canvas_sections.insert(conv(ch_b), "canvas-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); s.invalidate_all(); @@ -8378,22 +8829,22 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.sessions.insert(ch_a, "sess-a".into()); - s.sessions.insert(ch_b, "sess-b".into()); - s.canvas_sections.insert(ch_a, "canvas-a".into()); - s.canvas_sections.insert(ch_b, "canvas-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); + s.sessions.insert(conv(ch_b), "sess-b".into()); + s.canvas_sections.insert(conv(ch_a), "canvas-a".into()); + s.canvas_sections.insert(conv(ch_b), "canvas-b".into()); s.invalidate_channel(&ch_a); - assert!(!s.canvas_sections.contains_key(&ch_a)); - assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b"); + assert!(!s.canvas_sections.contains_key(&conv(ch_a))); + assert_eq!(s.canvas_sections.get(&conv(ch_b)).unwrap(), "canvas-b"); } #[test] fn test_has_channel_state_true_when_only_canvas_section_present() { let ch = Uuid::new_v4(); let mut s = SessionState::default(); - s.canvas_sections.insert(ch, "canvas".into()); + s.canvas_sections.insert(conv(ch), "canvas".into()); assert!(s.has_channel_state(&ch)); } @@ -8874,6 +9325,7 @@ done"# let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id, + scope: conv(channel_id), events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -9322,7 +9774,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9359,7 +9811,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9393,7 +9845,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9426,7 +9878,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9466,7 +9918,7 @@ exit 0"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9566,6 +10018,154 @@ done"# // agent wants to switch to. const OPTS_MODEL_A_AND_B: &str = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#; + #[tokio::test] + async fn session_new_sends_policy_specific_base_and_scope_specific_title() { + use crate::scope::SessionPolicy; + + let channel_id = Uuid::new_v4(); + let thread_a = SessionScope::Thread { + channel_id, + root_event_id: "abcdef01".repeat(8), + }; + let thread_b = SessionScope::Thread { + channel_id, + root_event_id: "12345678".repeat(8), + }; + let conversation = SessionScope::Conversation { channel_id }; + for (policy, scope, name, channel_type, title) in [ + ( + SessionPolicy::Channel, + Some(&conversation), + Some("engineering"), + Some("stream"), + "Fizz · #engineering", + ), + ( + SessionPolicy::Thread, + Some(&thread_a), + Some("engineering"), + Some("stream"), + "Fizz · #engineering · abcdef01", + ), + ( + SessionPolicy::Thread, + Some(&thread_b), + Some("engineering"), + Some("stream"), + "Fizz · #engineering · 12345678", + ), + ( + SessionPolicy::Thread, + Some(&conversation), + None, + Some("dm"), + "Fizz", + ), + (SessionPolicy::Thread, None, None, None, "Fizz"), + ] { + for (version, include_base) in [(1, true), (2, true), (1, false), (2, false)] { + let acp = spawn_switch_acp("[]", r#""result":{}"#).await; + let mut agent = switching_agent(acp, "unused"); + agent.desired_model = None; + agent.protocol_version = version; + let observer = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(observer.clone()), 0); + let mut ctx = make_prompt_context_no_owner(); + ctx.session_title = Some("Fizz".into()); + ctx.base_prompt = + include_base.then(|| policy.append_session_model("Custom base instructions.")); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name, + scope, + channel_type, + }, + ) + .await + .unwrap(); + let request = observer + .snapshot() + .into_iter() + .find(|event| { + event.kind == "acp_write" && event.payload["method"] == "session/new" + }) + .unwrap() + .payload; + assert_eq!(request["params"]["_meta"]["sessionTitle"], title); + let base = ctx + .base_prompt + .as_deref() + .map(crate::queue::base_section) + .unwrap_or_default(); + if !include_base { + assert!(request["params"].get("systemPrompt").is_none()); + } else if version == 2 { + let system = request["params"]["systemPrompt"].as_str().unwrap(); + assert!(system.starts_with(&base)); + assert_eq!(system.matches("## Session Model").count(), 1); + } else { + assert!(request["params"].get("systemPrompt").is_none()); + let legacy = prepend_standing_for_legacy( + version, + &crate::queue::StandingContext { + base_prompt: ctx.base_prompt.as_deref(), + ..Default::default() + }, + "hello", + ); + assert!(legacy.starts_with(&base)); + assert_eq!(legacy.matches("## Session Model").count(), 1); + } + agent.acp.shutdown().await; + } + } + } + + #[tokio::test] + async fn idle_channel_switch_preserves_all_sibling_sessions_and_model() { + let channel_id = Uuid::new_v4(); + let scopes = ["a", "b"].map(|root| SessionScope::Thread { + channel_id, + root_event_id: root.repeat(64), + }); + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{}"#).await; + let mut agent = switching_agent(acp, "model-a"); + for scope in &scopes { + agent + .state + .sessions + .insert(scope.clone(), scope.telemetry_label()); + } + let original_sessions = agent.state.sessions.clone(); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + assert_eq!( + pool.switch_idle_agent_model(channel_id, "model-b", Some("pick".into())), + IdleSwitchResult::AmbiguousTarget, + ); + let agent = pool.agents[0].as_ref().unwrap(); + assert_eq!(agent.desired_model.as_deref(), Some("model-a")); + assert_eq!(agent.desired_model_request_id, None); + assert_eq!(agent.state.sessions, original_sessions); + + // One remaining session is an unambiguous channel control again. The + // selected scope and its owner are cleared without broad channel cleanup. + pool.invalidate_scope_session(&scopes[1]); + pool.record_scope_owner(scopes[0].clone(), 0); + assert_eq!( + pool.switch_idle_agent_model(channel_id, "model-b", Some("pick".into())), + IdleSwitchResult::Switched, + ); + let agent = pool.agents[0].as_ref().unwrap(); + assert_eq!(agent.desired_model.as_deref(), Some("model-b")); + assert!(!agent.state.sessions.contains_key(&scopes[0])); + assert!(!pool.session_owners.contains_key(&scopes[0])); + } + #[tokio::test] async fn test_applied_switch_refreshes_capabilities_from_post_switch_snapshot() { // The adapter accepts the switch and echoes the target model's rebuilt @@ -9593,7 +10193,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9664,7 +10264,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9719,7 +10319,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9761,7 +10361,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9802,7 +10402,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9868,7 +10468,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9905,7 +10505,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -9978,7 +10578,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) @@ -10019,7 +10619,7 @@ done"# huddle_instructions: None, canvas: None, name: None, - id: None, + scope: None, channel_type: None, }, ) diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index d62b99114cf..b2fbde6242f 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -21,10 +21,62 @@ use uuid::Uuid; use crate::prompt_project::PromptProjectInfo; use crate::config::DedupMode; +use crate::scope::SessionScope; -/// Maximum events queued per channel before oldest events are dropped. +/// Maximum events queued per session scope before oldest events are dropped. +/// +/// Under the `channel` policy there is exactly one scope per channel, so this +/// is the historical per-channel cap. Under the `thread` policy it caps each +/// thread partition; the channel as a whole is additionally bounded by +/// [`MAX_PENDING_PER_CHANNEL`] so per-thread partitioning cannot multiply the +/// total admitted backlog. +const MAX_PENDING_PER_SCOPE: usize = 500; + +/// Aggregate cap on events queued across ALL scopes of a single channel. +/// +/// Preserves the pre-thread-scoping backlog protection: moving the per-scope +/// limit to “per thread” must not let one channel with many threads hold an +/// unbounded multiple of the old cap. Equal to [`MAX_PENDING_PER_SCOPE`] so a +/// single-scope channel behaves exactly as before. const MAX_PENDING_PER_CHANNEL: usize = 500; +/// A key that identifies a queue partition (session scope). +/// +/// Lets the queue's public API accept either a bare channel [`Uuid`] (treated +/// as a conversation scope — the pre-thread-scoping default, and what the +/// queue's own unit tests use) or an explicit [`SessionScope`] (what the +/// harness passes once a thread scope has been resolved at admission). This +/// keeps the large existing channel-keyed test suite compiling unchanged while +/// the hot path routes by full scope. +pub trait IntoScope { + /// Convert into the owned [`SessionScope`] used as the partition key. + fn into_scope(self) -> SessionScope; +} + +impl IntoScope for SessionScope { + fn into_scope(self) -> SessionScope { + self + } +} + +impl IntoScope for &SessionScope { + fn into_scope(self) -> SessionScope { + self.clone() + } +} + +impl IntoScope for Uuid { + fn into_scope(self) -> SessionScope { + SessionScope::Conversation { channel_id: self } + } +} + +impl IntoScope for &Uuid { + fn into_scope(self) -> SessionScope { + SessionScope::Conversation { channel_id: *self } + } +} + /// Maximum events drained into a single batch. const MAX_BATCH_EVENTS: usize = 50; @@ -47,6 +99,11 @@ const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; #[derive(Debug, Clone)] pub struct QueuedEvent { pub channel_id: Uuid, + /// Session scope resolved once at admission. Under `channel` policy this is + /// always `Conversation { channel_id }`; under `thread` policy it is the + /// canonical thread scope. The queue partitions on this, never on the + /// channel alone. Invariant: `scope.channel_id() == channel_id`. + pub scope: SessionScope, pub event: Event, pub received_at: Instant, /// Tag identifying which rule (or mode) matched this event. @@ -78,6 +135,9 @@ pub enum CancelReason { #[derive(Debug, Clone)] pub struct FlushBatch { pub channel_id: Uuid, + /// The single session scope every event in this batch belongs to. Events + /// from different scopes are never combined into one batch. + pub scope: SessionScope, pub events: Vec, /// Events from a cancelled batch that triggered this re-prompt. /// Empty for normal (non-cancel) batches. When non-empty, `format_prompt()` @@ -137,24 +197,24 @@ pub struct FlushBatch { /// else: push_front with original received_at, set exponential backoff retry_after with jitter /// ``` pub struct EventQueue { - queues: HashMap>, - in_flight_channels: HashSet, - /// Per-channel deadline for auto-expiring stuck in-flight entries. - in_flight_deadlines: HashMap, + queues: HashMap>, + in_flight_scopes: HashSet, + /// Per-scope deadline for auto-expiring stuck in-flight entries. + in_flight_deadlines: HashMap, /// Number of events in each in-flight batch (for expiry logging). - in_flight_batch_sizes: HashMap, - retry_after: HashMap, - /// Per-channel retry attempt counter for exponential backoff / dead-lettering. - retry_counts: HashMap, + in_flight_batch_sizes: HashMap, + retry_after: HashMap, + /// Per-scope retry attempt counter for exponential backoff / dead-lettering. + retry_counts: HashMap, dedup_mode: DedupMode, /// Events from cancelled batches, keyed by channel. Merged into the next /// `FlushBatch` for that channel as `cancelled_events` so `format_prompt()` /// can produce annotated "[Previous request — interrupted]" sections. - cancelled_batches: HashMap>, - /// Why each channel's cancelled batch was cancelled (steer vs interrupt). + cancelled_batches: HashMap>, + /// Why each scope's cancelled batch was cancelled (steer vs interrupt). /// Set by `requeue_as_cancelled`, consumed by `flush_next` to set - /// `FlushBatch::cancel_reason`. Keyed by channel, cleared on flush. - cancel_reasons: HashMap, + /// `FlushBatch::cancel_reason`. Keyed by scope, cleared on flush. + cancel_reasons: HashMap, /// Events withheld from `queues` while a goose-native steer is in flight /// for that event. Invisible to `flush_next` / `has_flushable_work` / /// `drain` (the events have been moved out of `queues`), so the queue's @@ -165,7 +225,7 @@ pub struct EventQueue { /// at line 453). Bulk recovery on in-flight deadline expiry is performed /// by `flush_next` / `has_flushable_work` (recover, not log-and-drop — /// the events were never delivered to the agent). - withheld_native_steer: HashMap>, + withheld_native_steer: HashMap>, /// Duration after which an in-flight channel is auto-expired as orphaned. /// Must be strictly greater than `max_turn_duration` so a turn running to /// the hard cap returns via `mark_complete` before the backstop fires. @@ -181,7 +241,7 @@ impl EventQueue { pub fn new(dedup_mode: DedupMode) -> Self { Self { queues: HashMap::new(), - in_flight_channels: HashSet::new(), + in_flight_scopes: HashSet::new(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), retry_after: HashMap::new(), @@ -209,13 +269,15 @@ impl EventQueue { /// moves backward. If the channel is not in-flight (already completed /// via `mark_complete`), this is a no-op: a late ack never resurrects /// a deadline. - pub fn extend_in_flight_deadline(&mut self, channel_id: Uuid, max_turn_secs: u64) { - if let Some(current) = self.in_flight_deadlines.get_mut(&channel_id) { + pub fn extend_in_flight_deadline(&mut self, scope: K, max_turn_secs: u64) { + let scope = scope.into_scope(); + if let Some(current) = self.in_flight_deadlines.get_mut(&scope) { let extended = Instant::now() + Duration::from_secs(max_turn_secs + IN_FLIGHT_DEADLINE_BUFFER_SECS); if extended > *current { tracing::info!( - %channel_id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), "extending in-flight deadline by {max_turn_secs}s + {IN_FLIGHT_DEADLINE_BUFFER_SECS}s buffer" ); *current = extended; @@ -230,29 +292,77 @@ impl EventQueue { /// /// Returns `true` if the event was accepted, `false` if dropped. pub fn push(&mut self, event: QueuedEvent) -> bool { + debug_assert_eq!( + event.scope.channel_id(), + event.channel_id, + "QueuedEvent.scope must belong to its channel_id" + ); if matches!(self.dedup_mode, DedupMode::Drop) - && self.in_flight_channels.contains(&event.channel_id) + && self.in_flight_scopes.contains(&event.scope) { tracing::debug!( channel_id = %event.channel_id, - "dropping event for in-flight channel (drop mode)" + scope = %event.scope.telemetry_label(), + "dropping event for in-flight scope (drop mode)" ); return false; } - let queue = self.queues.entry(event.channel_id).or_default(); - // Enforce per-channel depth cap: drop oldest to make room. - if queue.len() >= MAX_PENDING_PER_CHANNEL { + let channel_id = event.channel_id; + let scope = event.scope.clone(); + let queue = self.queues.entry(scope.clone()).or_default(); + // Enforce per-scope depth cap: drop oldest in this partition. + if queue.len() >= MAX_PENDING_PER_SCOPE { queue.pop_front(); tracing::warn!( - channel_id = %event.channel_id, - limit = MAX_PENDING_PER_CHANNEL, - "queue depth cap reached — dropped oldest event" + channel_id = %channel_id, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, + "per-scope queue depth cap reached — dropped oldest event" ); } queue.push_back(event); + // Enforce the aggregate per-channel cap across all scopes so thread + // partitioning cannot multiply the admitted backlog. + self.enforce_channel_cap(channel_id); true } + /// Total queued events across every scope belonging to `channel_id`. + fn channel_event_total(&self, channel_id: Uuid) -> usize { + self.queues + .iter() + .filter(|(s, _)| s.channel_id() == channel_id) + .map(|(_, q)| q.len()) + .sum() + } + + /// Drop the globally-oldest queued event(s) across a channel's scopes until + /// its aggregate depth is within [`MAX_PENDING_PER_CHANNEL`]. Preserves + /// cross-scope FIFO fairness by always evicting the oldest head event. + fn enforce_channel_cap(&mut self, channel_id: Uuid) { + while self.channel_event_total(channel_id) > MAX_PENDING_PER_CHANNEL { + // Find the channel's scope whose head event is oldest. + let victim = self + .queues + .iter() + .filter(|(s, q)| s.channel_id() == channel_id && !q.is_empty()) + .min_by_key(|(_, q)| q.front().unwrap().received_at) + .map(|(s, _)| s.clone()); + let Some(scope) = victim else { break }; + if let Some(q) = self.queues.get_mut(&scope) { + q.pop_front(); + if q.is_empty() { + self.queues.remove(&scope); + } + } + tracing::warn!( + channel_id = %channel_id, + limit = MAX_PENDING_PER_CHANNEL, + "aggregate per-channel queue cap reached — dropped oldest event" + ); + } + } + /// Try to flush the next batch. /// /// Returns `None` if all non-in-flight, non-throttled queues are empty. @@ -263,67 +373,70 @@ impl EventQueue { let now = Instant::now(); // Auto-expire any stuck in-flight entries that missed mark_complete. - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) + .map(|(scope, _)| scope.clone()) .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); + for scope in expired { + let lost_events = self.in_flight_batch_sizes.remove(&scope).unwrap_or(0); tracing::error!( - channel_id = %id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight scope expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); // Recover any withheld goose-native steer events for the expired - // channel back to the queue front so normal dispatch delivers + // scope back to the queue front so normal dispatch delivers // them. Unlike the in-flight batch above (already delivered to a // now-hung prompt — nothing to recover), these events were never // delivered to the agent. - self.recover_withheld_for_expired_channel(id); + self.recover_withheld_for_expired_scope(&scope); } - // Find the channel whose head event has the oldest received_at, - // excluding in-flight channels and throttled channels. - let channel_id = self + // Find the scope whose head event has the oldest received_at, + // excluding in-flight scopes and throttled scopes. + let scope = self .queues .iter() - .filter(|(id, q)| { + .filter(|(scope, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_scopes.contains(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) .min_by_key(|(_, q)| q.front().unwrap().received_at) - .map(|(id, _)| *id); + .map(|(scope, _)| scope.clone()); - // Fallback: if no queued events are ready but a channel has cancelled + // Fallback: if no queued events are ready but a scope has cancelled // events waiting (e.g., explicit !cancel with no new @mention), flush // those as a regular batch (re-dispatch unchanged). - let channel_id = match channel_id { - Some(id) => id, + let scope = match scope { + Some(scope) => scope, None => { - let cancelled_id = self + let cancelled_scope = self .cancelled_batches .keys() - .find(|id| !self.in_flight_channels.contains(id)) - .copied(); - match cancelled_id { - Some(id) => { + .find(|scope| !self.in_flight_scopes.contains(scope)) + .cloned(); + match cancelled_scope { + Some(scope) => { // Move cancelled events into the regular events slot. // No new events to merge — re-dispatch the original batch. - let cancelled = self.cancelled_batches.remove(&id).unwrap_or_default(); - let cancel_reason = self.cancel_reasons.remove(&id); - self.in_flight_channels.insert(id); + let cancelled = self.cancelled_batches.remove(&scope).unwrap_or_default(); + let cancel_reason = self.cancel_reasons.remove(&scope); + self.in_flight_scopes.insert(scope.clone()); self.in_flight_deadlines - .insert(id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(id, cancelled.len()); + .insert(scope.clone(), now + self.in_flight_deadline); + self.in_flight_batch_sizes + .insert(scope.clone(), cancelled.len()); return Some(FlushBatch { - channel_id: id, + channel_id: scope.channel_id(), + scope, events: cancelled, cancelled_events: vec![], cancel_reason, @@ -333,9 +446,10 @@ impl EventQueue { } } }; + let channel_id = scope.channel_id(); // Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue. - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); let drain_count = MAX_BATCH_EVENTS.min(queue.len()); let mut events: Vec = queue .drain(..drain_count) @@ -352,29 +466,28 @@ impl EventQueue { events.sort_by_key(|be| be.event.created_at); // Remove the queue entry if now empty. - if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) { - self.queues.remove(&channel_id); + if self.queues.get(&scope).is_some_and(|q| q.is_empty()) { + self.queues.remove(&scope); } - self.in_flight_channels.insert(channel_id); + self.in_flight_scopes.insert(scope.clone()); self.in_flight_deadlines - .insert(channel_id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(channel_id, events.len()); + .insert(scope.clone(), now + self.in_flight_deadline); + self.in_flight_batch_sizes + .insert(scope.clone(), events.len()); // Merge any cancelled events stored by requeue_as_cancelled(). - let cancelled_events = self - .cancelled_batches - .remove(&channel_id) - .unwrap_or_default(); + let cancelled_events = self.cancelled_batches.remove(&scope).unwrap_or_default(); let cancel_reason = if cancelled_events.is_empty() { - self.cancel_reasons.remove(&channel_id); + self.cancel_reasons.remove(&scope); None } else { - self.cancel_reasons.remove(&channel_id) + self.cancel_reasons.remove(&scope) }; Some(FlushBatch { channel_id, + scope, events, cancelled_events, cancel_reason, @@ -391,22 +504,23 @@ impl EventQueue { /// so the backoff sequence continues on the next attempt. /// /// Also cleans up any already-expired `retry_after` entry. - pub fn mark_complete(&mut self, channel_id: Uuid) { - self.in_flight_channels.remove(&channel_id); - self.in_flight_deadlines.remove(&channel_id); - self.in_flight_batch_sizes.remove(&channel_id); + pub fn mark_complete(&mut self, scope: K) { + let scope = scope.into_scope(); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); + self.in_flight_batch_sizes.remove(&scope); let now = Instant::now(); - match self.retry_after.get(&channel_id) { - // Active throttle → channel was requeued; keep retry_counts intact. + match self.retry_after.get(&scope) { + // Active throttle → scope was requeued; keep retry_counts intact. Some(&deadline) if deadline > now => {} // Expired or absent throttle → successful completion; reset counter // and clean up the stale retry_after entry. Some(_) => { - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); + self.retry_after.remove(&scope); + self.retry_counts.remove(&scope); } None => { - self.retry_counts.remove(&channel_id); + self.retry_counts.remove(&scope); } } } @@ -430,8 +544,9 @@ impl EventQueue { /// `mark_complete` separately. pub fn requeue(&mut self, batch: FlushBatch) -> Option { let channel_id = batch.channel_id; + let scope = batch.scope.clone(); let attempt = { - let count = self.retry_counts.entry(channel_id).or_insert(0); + let count = self.retry_counts.entry(scope.clone()).or_insert(0); *count += 1; *count }; @@ -445,10 +560,10 @@ impl EventQueue { MAX_RETRIES, batch.events.len(), ); - self.retry_counts.remove(&channel_id); - // Also clear retry_after so fresh traffic on this channel isn't + self.retry_counts.remove(&scope); + // Also clear retry_after so fresh traffic on this scope isn't // throttled by stale backoff from the discarded poison batch. - self.retry_after.remove(&channel_id); + self.retry_after.remove(&scope); return Some(batch); } @@ -474,60 +589,92 @@ impl EventQueue { "requeueing failed batch with backoff" ); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, // preserve original timestamp (#46) }); } - // Enforce per-channel cap: trim oldest (back) events if requeue pushed - // the queue over the limit. Without this, repeated requeue+push cycles - // can grow the queue unboundedly. - while queue.len() > MAX_PENDING_PER_CHANNEL { + // Enforce per-scope cap: trim oldest (back) events if requeue pushed + // the partition over the limit. Without this, repeated requeue+push + // cycles can grow the queue unboundedly. + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "requeue overflow — dropped oldest event to enforce cap" ); } - self.retry_after.insert(channel_id, Instant::now() + delay); + self.retry_after.insert(scope, Instant::now() + delay); + self.enforce_channel_cap(channel_id); None } - /// Re-queue a batch preserving original `received_at` timestamps. + /// Re-queue a **complete** flushed batch preserving original `received_at` + /// timestamps. + /// + /// Used when a batch was flushed but could not run — no agent was available, + /// or the batch's session-owning worker was busy (thread-scope affinity + /// hold) — so we retry without penalizing the scope's fairness position and + /// without imposing a retry throttle. /// - /// Used when a batch was flushed but no agent was available — we want to - /// retry without penalizing the channel's position in the fairness queue - /// and without imposing a retry throttle. + /// Restores the **entire** batch, not just `events`: any + /// [`cancelled_events`](FlushBatch::cancelled_events) and their + /// [`cancel_reason`](FlushBatch::cancel_reason) are returned to the pending + /// cancelled-carryover so the next flush reconstructs the same merged + /// (interrupt/steer) prompt. Dropping them here would silently lose the + /// original request of an interrupted turn. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` — + /// Does NOT set `retry_after`. Does NOT remove from `in_flight_scopes` — /// caller must call `mark_complete` separately. pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) { let channel_id = batch.channel_id; - let queue = self.queues.entry(channel_id).or_default(); + let scope = batch.scope.clone(); + + // Restore cancelled carryover FIRST so it precedes any carryover a + // concurrent cancel may have already staged for this scope, preserving + // original-before-newer ordering. `flush_next` re-merges it as the next + // batch's `cancelled_events`. + if !batch.cancelled_events.is_empty() { + let existing = self.cancelled_batches.remove(&scope).unwrap_or_default(); + let mut restored = batch.cancelled_events; + restored.extend(existing); + self.cancelled_batches.insert(scope.clone(), restored); + if let Some(reason) = batch.cancel_reason { + // Keep the most recent reason if one was already staged. + self.cancel_reasons.entry(scope.clone()).or_insert(reason); + } + } + + let queue = self.queues.entry(scope.clone()).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, }); } - // Enforce per-channel cap: trim newest (back) events if over limit. - while queue.len() > MAX_PENDING_PER_CHANNEL { + // Enforce per-scope cap: trim newest (back) events if over limit. + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "requeue_preserve overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); } /// Requeue a cancelled batch so its events appear as `cancelled_events` @@ -542,11 +689,12 @@ impl EventQueue { /// the generic queue — they are stored separately and merged by /// `flush_next()`. No retry throttle, no backoff. pub fn requeue_as_cancelled(&mut self, batch: FlushBatch, reason: CancelReason) { - let entry = self.cancelled_batches.entry(batch.channel_id).or_default(); + let scope = batch.scope.clone(); + let entry = self.cancelled_batches.entry(scope.clone()).or_default(); // Preserve any already-cancelled events from a prior cancel (double-cancel). entry.extend(batch.cancelled_events); entry.extend(batch.events); - self.cancel_reasons.insert(batch.channel_id, reason); + self.cancel_reasons.insert(scope, reason); } /// Returns `true` if any channel has pending events that are not in-flight @@ -559,37 +707,38 @@ impl EventQueue { let now = Instant::now(); // Auto-expire stuck in-flight entries (same logic as flush_next). - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) + .map(|(scope, _)| scope.clone()) .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); + for scope in expired { + let lost_events = self.in_flight_batch_sizes.remove(&scope).unwrap_or(0); tracing::error!( - channel_id = %id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight scope expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); // Symmetric with the flush_next expiry block: recover withheld - // goose-native steer events for the expired channel so they are + // goose-native steer events for the expired scope so they are // not permanently orphaned in the side table. - self.recover_withheld_for_expired_channel(id); + self.recover_withheld_for_expired_scope(&scope); } - self.queues.iter().any(|(id, q)| { + self.queues.iter().any(|(scope, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_scopes.contains(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) || self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)) + .any(|scope| !self.in_flight_scopes.contains(scope)) } /// Returns `true` if any undispatched work remains for a channel that is @@ -613,27 +762,31 @@ impl EventQueue { let has_queued = self .queues .iter() - .any(|(id, q)| !q.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, q)| !q.is_empty() && !self.in_flight_scopes.contains(scope)); let has_cancelled = self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)); + .any(|scope| !self.in_flight_scopes.contains(scope)); let has_withheld = self .withheld_native_steer .iter() - .any(|(id, v)| !v.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, v)| !v.is_empty() && !self.in_flight_scopes.contains(scope)); has_queued || has_cancelled || has_withheld } - /// Number of channels with pending events. + /// Number of pending partitions (session scopes) with queued events. + /// + /// Under `channel` policy this equals the number of channels with pending + /// events; under `thread` policy it counts distinct thread partitions. pub fn pending_channels(&self) -> usize { self.queues.len() } - /// Number of queued events for a specific channel. Test-only. + /// Number of queued events for a specific scope (or channel, treated as its + /// conversation scope). Test-only. #[cfg(test)] - pub fn queued_event_count(&self, channel_id: &Uuid) -> usize { - self.queues.get(channel_id).map_or(0, |q| q.len()) + pub fn queued_event_count(&self, scope: K) -> usize { + self.queues.get(&scope.into_scope()).map_or(0, |q| q.len()) } /// Force a channel's retry-attempt counter to `count`, simulating `count` @@ -642,8 +795,8 @@ impl EventQueue { /// Test-only — lets integration tests outside this module exercise /// `requeue()`'s dead-letter threshold directly. #[cfg(test)] - pub fn set_retry_count_for_test(&mut self, channel_id: Uuid, count: u32) { - self.retry_counts.insert(channel_id, count); + pub fn set_retry_count_for_test(&mut self, scope: K, count: u32) { + self.retry_counts.insert(scope.into_scope(), count); } /// Drop all queued (non-in-flight) events for a channel. @@ -658,32 +811,47 @@ impl EventQueue { /// Returns the event IDs of dropped events so the caller can clean up /// any reactions (👀) that were added at queue-push time. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { - let ids = self + // Channel-wide cleanup must find and clear EVERY child thread scope for + // this channel, not just the conversation scope. + let scopes: Vec = self .queues - .remove(&channel_id) - .map(|q| q.into_iter().map(|e| e.event.id.to_hex()).collect()) - .unwrap_or_default(); - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); - self.cancelled_batches.remove(&channel_id); - self.cancel_reasons.remove(&channel_id); - self.withheld_native_steer.remove(&channel_id); - // Preserve in_flight_channels AND in_flight_deadlines: the in-flight + .keys() + .filter(|s| s.channel_id() == channel_id) + .cloned() + .collect(); + let mut ids = Vec::new(); + for scope in &scopes { + if let Some(q) = self.queues.remove(scope) { + ids.extend(q.into_iter().map(|e| e.event.id.to_hex())); + } + } + // Also purge side-tables for every scope of this channel. + self.retry_after.retain(|s, _| s.channel_id() != channel_id); + self.retry_counts + .retain(|s, _| s.channel_id() != channel_id); + self.cancelled_batches + .retain(|s, _| s.channel_id() != channel_id); + self.cancel_reasons + .retain(|s, _| s.channel_id() != channel_id); + self.withheld_native_steer + .retain(|s, _| s.channel_id() != channel_id); + // Preserve in_flight_scopes AND in_flight_deadlines: the in-flight // task will eventually complete (calling mark_complete) or the deadline - // will expire (auto-cleaning the channel). Removing deadlines without - // removing in_flight_channels would disable auto-expiry and leave a - // wedged task permanently blocking the channel. + // will expire (auto-cleaning the scope). Removing deadlines without + // removing in_flight_scopes would disable auto-expiry and leave a + // wedged task permanently blocking the scope. ids } - /// Whether a prompt is currently in-flight for the given channel. - pub fn is_channel_in_flight(&self, channel_id: Uuid) -> bool { - self.in_flight_channels.contains(&channel_id) + /// Whether a prompt is currently in-flight for the given scope (or channel, + /// treated as its conversation scope). + pub fn is_scope_in_flight(&self, scope: K) -> bool { + self.in_flight_scopes.contains(&scope.into_scope()) } - /// Whether any channel currently has a turn in flight. + /// Whether any scope currently has a turn in flight. pub fn has_in_flight(&self) -> bool { - !self.in_flight_channels.is_empty() + !self.in_flight_scopes.is_empty() } // ── Goose-native steer withhold (side table) ────────────────────────── @@ -710,8 +878,9 @@ impl EventQueue { /// after `pool.send_steer` returns `Ok(())` and before any watcher task /// is spawned, so the withhold is established before `mark_complete` / /// any subsequent `flush_next` tick can run. - pub fn mark_native_steer_pending(&mut self, channel_id: Uuid, event_id: &str) -> bool { - let Some(q) = self.queues.get_mut(&channel_id) else { + pub fn mark_native_steer_pending(&mut self, scope: K, event_id: &str) -> bool { + let scope = scope.into_scope(); + let Some(q) = self.queues.get_mut(&scope) else { return false; }; let Some(pos) = q.iter().position(|qe| qe.event.id.to_hex() == event_id) else { @@ -721,10 +890,10 @@ impl EventQueue { .remove(pos) .expect("position came from iter so remove must succeed"); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(&scope); } self.withheld_native_steer - .entry(channel_id) + .entry(scope) .or_default() .push(qe); true @@ -740,8 +909,9 @@ impl EventQueue { /// /// Push-to-front matches the discipline of `requeue_preserve_timestamps` /// at line 453, preserving fairness across channels. - pub fn release_native_steer(&mut self, channel_id: Uuid, event_id: &str) { - let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) else { + pub fn release_native_steer(&mut self, scope: K, event_id: &str) { + let scope = scope.into_scope(); + let Some(entries) = self.withheld_native_steer.get_mut(&scope) else { return; }; let Some(pos) = entries @@ -752,21 +922,24 @@ impl EventQueue { }; let qe = entries.remove(pos); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } + let channel_id = scope.channel_id(); // Push to FRONT so original `received_at` keeps the event at the head - // of the channel's queue. Per-channel cap is enforced below in case + // of the scope's queue. Per-scope cap is enforced below in case // a flood of events arrived during the ack window. - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); queue.push_front(qe); - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "release_native_steer overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); } /// Drop a specific event by id from both the side table and the main @@ -775,17 +948,18 @@ impl EventQueue { /// Called on `SteerAck::Success` — the agent received the steer, so the /// event has been "delivered" via the non-cancelling path and must not /// be redelivered via normal dispatch. Idempotent across both stores. - pub fn remove_event(&mut self, channel_id: Uuid, event_id: &str) { - if let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) { + pub fn remove_event(&mut self, scope: K, event_id: &str) { + let scope = scope.into_scope(); + if let Some(entries) = self.withheld_native_steer.get_mut(&scope) { entries.retain(|qe| qe.event.id.to_hex() != event_id); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } } - if let Some(q) = self.queues.get_mut(&channel_id) { + if let Some(q) = self.queues.get_mut(&scope) { q.retain(|qe| qe.event.id.to_hex() != event_id); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(&scope); } } } @@ -803,25 +977,29 @@ impl EventQueue { /// Iterates the stored entries in reverse so per-entry `push_front` /// composes to original-FIFO order at the queue front (same discipline /// as `requeue_preserve_timestamps` at line 453). - fn recover_withheld_for_expired_channel(&mut self, channel_id: Uuid) { - let Some(entries) = self.withheld_native_steer.remove(&channel_id) else { + fn recover_withheld_for_expired_scope(&mut self, scope: &SessionScope) { + let Some(entries) = self.withheld_native_steer.remove(scope) else { return; }; let n = entries.len(); - let queue = self.queues.entry(channel_id).or_default(); + let channel_id = scope.channel_id(); + let queue = self.queues.entry(scope.clone()).or_default(); for qe in entries.into_iter().rev() { queue.push_front(qe); } - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "withheld-steer recovery overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); tracing::warn!( channel_id = %channel_id, + scope = %scope.telemetry_label(), recovered = n, "in-flight expiry recovered withheld steer event(s) — \ steer ack never arrived; normal dispatch will deliver" @@ -850,10 +1028,10 @@ impl EventQueue { // Remove retry_counts for channels with no active throttle, no // queued events, AND no in-flight prompt — they completed their // retry cycle and are truly idle. - self.retry_counts.retain(|ch, _| { - self.retry_after.contains_key(ch) - || self.queues.get(ch).is_some_and(|q| !q.is_empty()) - || self.in_flight_channels.contains(ch) + self.retry_counts.retain(|scope, _| { + self.retry_after.contains_key(scope) + || self.queues.get(scope).is_some_and(|q| !q.is_empty()) + || self.in_flight_scopes.contains(scope) }); } } @@ -1402,7 +1580,7 @@ fn append_project_home(s: &mut String, channel_info: Option<&PromptChannelInfo>, )); } -/// Format a `` hints section based on event scope. +/// Format a `` section from the resolved session scope and turn routing. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see /// [`resolve_reply_anchor`]). In the thread/DM branches it threads ordinary @@ -1410,13 +1588,14 @@ fn append_project_home(s: &mut String, channel_info: Option<&PromptChannelInfo>, /// top-level mention whose reply should open a new thread rooted at the /// triggering event. fn format_context_hints( - channel_id: Uuid, + scope: &SessionScope, channel_info: Option<&PromptChannelInfo>, thread_tags: &ThreadTags, is_dm: bool, conversation_context_status: ConversationContextStatus, reply_anchor: Option<&str>, ) -> String { + let channel_id = scope.channel_id(); let channel_display = match channel_info { Some(ci) => format!("{} (#{channel_id})", ci.name), None => channel_id.to_string(), @@ -1455,6 +1634,7 @@ fn format_context_hints( }; let mut s = format!( "Scope: dm\n\ + Session scope: dm conversation\n\ Channel: {channel_display}\n\ {ctx_hint}" ); @@ -1471,7 +1651,10 @@ fn format_context_hints( } } crate::prompt_framing::semantic_section("context", &s) - } else if let Some(ref root) = thread_tags.root_event_id { + } else if let Some(root) = scope + .root_event_id() + .or(thread_tags.root_event_id.as_deref()) + { let ctx_hint = if complete_conversation_context { "Thread context included below." } else if has_conversation_context { @@ -1481,8 +1664,14 @@ fn format_context_hints( } else { "Use `buzz messages thread --channel --event ` to fetch thread context." }; + let session_scope = if scope.is_thread() { + "thread" + } else { + "channel" + }; let mut s = format!( "Scope: thread\n\ + Session scope: {session_scope}\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1495,12 +1684,17 @@ fn format_context_hints( } s.push_str(&format!("\n{ctx_hint}")); if let Some(event_id) = reply_anchor { - append_reply_instruction(&mut s, event_id); + if thread_tags.root_event_id.is_some() { + append_reply_instruction(&mut s, event_id); + } else { + append_new_thread_reply_instruction(&mut s, event_id); + } } crate::prompt_framing::semantic_section("context", &s) } else { let mut s = format!( "Scope: channel\n\ + Session scope: channel\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1777,10 +1971,9 @@ pub(crate) fn base_section(base_prompt: &str) -> String { /// For agents with `protocol_version >= 2`, base_prompt and system_prompt are /// delivered via the system role in `session/new` and omitted from this message. pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec { - // Scope is always derived from the LAST event in the batch — that's the - // one the agent is responding to. Thread/DM context is supplementary info - // included alongside, not a scope override. This prevents mixed batches - // (thread reply + later plain message) from being mislabeled as "thread". + // Session identity comes from admission (`batch.scope`). The last event + // determines reply routing only: a top-level trigger already owns a thread + // session under thread policy, even though it has no NIP-10 reply tags. let last_event = match batch.events.last() { Some(e) => e, None => { @@ -1837,7 +2030,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec SessionScope { + SessionScope::Conversation { channel_id } + } + + /// Build a QueuedEvent for the given channel (conversation scope). fn make_queued(channel_id: Uuid, content: &str) -> QueuedEvent { QueuedEvent { channel_id, + scope: conv(channel_id), event: make_event(content), received_at: Instant::now(), prompt_tag: "test".into(), @@ -2027,6 +2227,7 @@ mod tests { fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent { QueuedEvent { channel_id, + scope: conv(channel_id), event: make_event(content), received_at: Instant::now() - age, prompt_tag: "test".into(), @@ -2047,6 +2248,7 @@ mod tests { .unwrap(); QueuedEvent { channel_id, + scope: conv(channel_id), event, received_at: Instant::now(), prompt_tag: "test".into(), @@ -2058,7 +2260,145 @@ mod tests { } fn any_in_flight(q: &EventQueue) -> bool { - !q.in_flight_channels.is_empty() + !q.in_flight_scopes.is_empty() + } + + /// Thread scope within a channel, keyed by a synthetic 64-hex root. + fn thread(channel_id: Uuid, root: &str) -> SessionScope { + SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + /// Build a QueuedEvent for an explicit scope. + fn make_scoped(scope: SessionScope, content: &str) -> QueuedEvent { + QueuedEvent { + channel_id: scope.channel_id(), + scope, + event: make_event(content), + received_at: Instant::now(), + prompt_tag: "test".into(), + } + } + + // ── Step 2: scope partitioning ────────────────────────────────────────── + + #[test] + fn two_threads_in_one_channel_are_independent_partitions() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + let tb = thread(ch, &"b".repeat(64)); + q.push(make_scoped(ta.clone(), "thread-a")); + q.push(make_scoped(tb.clone(), "thread-b")); + + // First flush claims one thread; the other is still flushable because + // it is a distinct scope in the same channel. + let first = q.flush_next().expect("first batch"); + assert_eq!(first.channel_id, ch); + assert!(first.scope.is_thread()); + assert!(q.is_scope_in_flight(&first.scope)); + + // The sibling thread is NOT blocked by the first thread's in-flight turn. + let second = q.flush_next().expect("second batch"); + assert_eq!(second.channel_id, ch); + assert_ne!(first.scope, second.scope); + // Batches never mix scopes. + assert_eq!(first.events.len(), 1); + assert_eq!(second.events.len(), 1); + } + + #[test] + fn events_from_different_roots_never_share_a_batch() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + let tb = thread(ch, &"b".repeat(64)); + // Interleave pushes across the two thread scopes. + q.push(make_scoped(ta.clone(), "a1")); + q.push(make_scoped(tb.clone(), "b1")); + q.push(make_scoped(ta.clone(), "a2")); + q.push(make_scoped(tb.clone(), "b2")); + + let batch = q.flush_next().expect("batch"); + // Every event in the drained batch belongs to the single flushed scope. + let contents: Vec<&str> = batch + .events + .iter() + .map(|e| e.event.content.as_str()) + .collect(); + if batch.scope == ta { + assert_eq!(contents, vec!["a1", "a2"]); + } else { + assert_eq!(contents, vec!["b1", "b2"]); + } + } + + #[test] + fn in_flight_scope_blocks_only_that_scope_not_the_channel() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + q.push(make_scoped(ta.clone(), "a1")); + let _b = q.flush_next().expect("flush a"); + assert!(q.is_scope_in_flight(&ta)); + + // A new event on the SAME thread is blocked while in-flight (queue mode + // keeps it, but it is not re-flushable until mark_complete). + q.push(make_scoped(ta.clone(), "a2")); + assert!(q.flush_next().is_none()); + + // A new event on a DIFFERENT thread flushes immediately. + let tb = thread(ch, &"b".repeat(64)); + q.push(make_scoped(tb.clone(), "b1")); + let batch = q.flush_next().expect("sibling flushes"); + assert_eq!(batch.scope, tb); + + // Completing thread A unblocks its queued event. + q.mark_complete(ta.clone()); + let batch = q.flush_next().expect("a2 flushes after complete"); + assert_eq!(batch.scope, ta); + assert_eq!(batch.events[0].event.content, "a2"); + } + + #[test] + fn drain_channel_clears_every_child_thread_scope() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + q.push(make_scoped(thread(ch, &"a".repeat(64)), "a1")); + q.push(make_scoped(thread(ch, &"b".repeat(64)), "b1")); + q.push(make_scoped(conv(ch), "conv")); + q.push(make_scoped(thread(other, &"c".repeat(64)), "other")); + + let dropped = q.drain_channel(ch); + assert_eq!(dropped.len(), 3, "all three ch scopes drained"); + // The other channel's thread survives. + let batch = q.flush_next().expect("other channel still has work"); + assert_eq!(batch.channel_id, other); + } + + #[test] + fn aggregate_channel_cap_not_multiplied_by_threads() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + // Spread well over the aggregate cap across many thread scopes. + let total = MAX_PENDING_PER_CHANNEL + 250; + for i in 0..total { + let root = format!("{:064x}", i % 5); + q.push(make_scoped(thread(ch, &root), "x")); + } + let channel_total: usize = q + .queues + .iter() + .filter(|(s, _)| s.channel_id() == ch) + .map(|(_, v)| v.len()) + .sum(); + assert!( + channel_total <= MAX_PENDING_PER_CHANNEL, + "aggregate per-channel cap must bound all thread scopes combined, got {channel_total}" + ); } #[test] @@ -2243,6 +2583,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -2273,6 +2614,7 @@ mod tests { let ch = Uuid::new_v4(); FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("the new message"), prompt_tag: "@mention".into(), @@ -2404,6 +2746,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: make_event("new one"), @@ -2461,6 +2804,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: steering, prompt_tag: "@mention".into(), @@ -2512,7 +2856,7 @@ mod tests { queue.mark_complete(ch); // retry_after is set, so manually clear it for this test. - queue.retry_after.remove(&ch); + queue.retry_after.remove(&conv(ch)); // Should be able to flush again and get the same events in order. let batch2 = queue.flush_next().unwrap(); @@ -2553,7 +2897,7 @@ mod tests { assert!( queue .retry_after - .get(&ch) + .get(&conv(ch)) .is_some_and(|&t| t > Instant::now()), "requeue must have set a future backoff deadline" ); @@ -2632,6 +2976,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: e1, @@ -2672,6 +3017,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2695,6 +3041,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2727,6 +3074,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2757,6 +3105,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2784,6 +3133,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2808,6 +3158,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2866,6 +3217,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hello"), prompt_tag: "test".into(), @@ -2919,6 +3271,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2957,6 +3310,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3065,7 +3419,7 @@ mod tests { assert_eq!(batch_b.channel_id, ch_b); // Both in-flight. - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete A only. q.mark_complete(ch_a); @@ -3164,13 +3518,13 @@ mod tests { let _batch_a = q.flush_next().expect("flush A"); let _batch_b = q.flush_next().expect("flush B"); - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete only A. q.mark_complete(ch_a); - assert_eq!(q.in_flight_channels.len(), 1); - assert!(q.in_flight_channels.contains(&ch_b)); - assert!(!q.in_flight_channels.contains(&ch_a)); + assert_eq!(q.in_flight_scopes.len(), 1); + assert!(q.in_flight_scopes.contains(&conv(ch_b))); + assert!(!q.in_flight_scopes.contains(&conv(ch_a))); // B still in-flight. assert!(any_in_flight(&q)); @@ -3187,6 +3541,7 @@ mod tests { q.push(QueuedEvent { channel_id: ch, + scope: conv(ch), event: make_event("old-msg"), received_at: old_time, prompt_tag: "test".into(), @@ -3204,6 +3559,52 @@ mod tests { assert_eq!(batch2.events[0].received_at, original_received_at); } + #[test] + fn test_requeue_preserve_timestamps_round_trips_cancelled_carryover() { + // Regression: a held/exhausted merged batch (cancel + re-prompt) must + // not lose its original request. requeue_preserve_timestamps must + // restore events AND cancelled_events + cancel_reason so the next flush + // reconstructs the same merged batch. + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let scope = conv(ch); + let batch = FlushBatch { + channel_id: ch, + scope: scope.clone(), + events: vec![BatchEvent { + event: make_event("the follow-up"), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![BatchEvent { + event: make_event("the original request"), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancel_reason: Some(CancelReason::Interrupt), + }; + // Simulate the flushed-then-held state: scope is in-flight. + q.push(make_queued(ch, "placeholder")); + let _ = q.flush_next().expect("scope now in-flight"); + + q.requeue_preserve_timestamps(batch); + q.mark_complete(scope); + + let restored = q.flush_next().expect("merged batch re-flushes"); + assert_eq!(restored.events.len(), 1); + assert_eq!(restored.events[0].event.content, "the follow-up"); + assert_eq!( + restored.cancelled_events.len(), + 1, + "cancelled carryover (original request) must survive the requeue" + ); + assert_eq!( + restored.cancelled_events[0].event.content, + "the original request" + ); + assert_eq!(restored.cancel_reason, Some(CancelReason::Interrupt)); + } + #[test] fn test_requeue_preserve_timestamps_no_retry_after() { let mut q = EventQueue::new(DedupMode::Queue); @@ -3216,7 +3617,7 @@ mod tests { q.mark_complete(ch); // No retry_after — channel should be immediately flushable. - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_after.contains_key(&conv(ch))); assert!(q.flush_next().is_some()); } @@ -3322,7 +3723,7 @@ mod tests { // Manually expire the retry_after to simulate time passing. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); assert!( q.has_flushable_work(), "expired throttle should be flushable" @@ -3337,7 +3738,7 @@ mod tests { q.push(make_queued(ch, "poison")); for attempt in 1..=MAX_RETRIES { q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); assert!( q.requeue(batch).is_none(), @@ -3348,15 +3749,15 @@ mod tests { // The MAX_RETRIES+1'th failure dead-letters: batch is returned. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); let dead = q.requeue(batch).expect("should dead-letter"); assert_eq!(dead.channel_id, ch); assert_eq!(dead.events.len(), 1); q.mark_complete(ch); // Retry state is cleared so fresh traffic isn't throttled. - assert!(!q.retry_counts.contains_key(&ch)); - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_counts.contains_key(&conv(ch))); + assert!(!q.retry_after.contains_key(&conv(ch))); } #[test] @@ -3382,7 +3783,7 @@ mod tests { // After retry_after expires, ch should be flushable again. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); q.mark_complete(ch2); let batch3 = q .flush_next() @@ -3498,6 +3899,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3531,6 +3933,7 @@ mod tests { let event = make_event("hey"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3557,6 +3960,95 @@ mod tests { assert!(prompt.contains("Scope: dm")); } + #[test] + fn prompt_session_scope_matrix_preserves_turn_routing() { + use crate::scope::SessionPolicy; + + let channel_id = Uuid::new_v4(); + let top = make_event("start work"); + let root = top.id.to_hex(); + let reply = make_event_with_tags( + "continue work", + vec![vec![ + "e".into(), + root.to_uppercase(), + "".into(), + "reply".into(), + ]], + ); + for policy in [SessionPolicy::Channel, SessionPolicy::Thread] { + for is_dm in [false, true] { + for (event, is_reply) in [(&top, false), (&reply, true)] { + let batch = FlushBatch { + channel_id, + scope: SessionScope::derive(policy, channel_id, is_dm, event), + events: vec![BatchEvent { + event: event.clone(), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let ci = PromptChannelInfo { + name: "test".into(), + channel_type: if is_dm { "dm" } else { "stream" }.into(), + description: None, + project: None, + }; + // Session scope must remain visible on every turn, even + // after standing context was sent or via modern ACP. + for modern in [false, true] { + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + has_system_prompt_support: modern, + standing_context_sent: true, + ..Default::default() + }, + ) + .join("\n\n"); + if is_dm { + assert!(prompt.contains("Session scope: dm conversation")); + assert!(prompt.contains("Scope: dm")); + } else if policy == SessionPolicy::Thread { + assert!(prompt.contains("Session scope: thread")); + assert!(prompt.contains("Scope: thread")); + assert!(prompt.contains(&format!("Thread root: {root}"))); + assert!(prompt.contains("buzz messages thread")); + assert!(!prompt.contains("buzz messages get")); + } else { + assert!(prompt.contains("Session scope: channel")); + assert!(prompt.contains(if is_reply { + "Scope: thread" + } else { + "Scope: channel" + })); + } + assert_eq!( + prompt.contains("This is a new top-level message"), + !is_dm && !is_reply + ); + if !is_dm || is_reply { + let anchor = if is_dm { + reply.id.to_hex() + } else if is_reply { + root.to_uppercase() + } else { + root.clone() + }; + assert!(prompt.contains(&format!("--reply-to {anchor}"))); + } else { + assert!(!prompt.contains("--reply-to")); + assert!(prompt.contains("buzz messages get")); + } + } + } + } + } + } + #[test] fn test_format_prompt_thread_scope() { let ch = Uuid::new_v4(); @@ -3571,6 +4063,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3597,6 +4090,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3729,6 +4223,7 @@ mod tests { let mixed_batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ reply("older reply in thread A", &root_a), reply("newer reply in thread B", &root_b), @@ -3753,6 +4248,7 @@ mod tests { let same_thread_batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ reply("older reply in thread B", &root_b), reply("newer reply in thread B", &root_b), @@ -3780,6 +4276,7 @@ mod tests { let event = make_event("ok do that"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3836,6 +4333,7 @@ mod tests { let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4045,6 +4543,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -4115,6 +4614,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4148,6 +4648,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("follow up"), prompt_tag: "dm".into(), @@ -4198,6 +4699,7 @@ mod tests { let event = make_event("hey there"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -4240,6 +4742,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4264,6 +4767,7 @@ mod tests { let npub = event.pubkey.to_bech32().unwrap(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4287,6 +4791,7 @@ mod tests { let event = make_event_with_tags("hello", vec![vec!["h".into(), ch.to_string()]]); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4445,25 +4950,25 @@ mod tests { let batch = q.flush_next().unwrap(); q.requeue(batch); q.mark_complete(ch); - assert!(q.retry_after.contains_key(&ch)); - assert!(q.retry_counts.contains_key(&ch)); + assert!(q.retry_after.contains_key(&conv(ch))); + assert!(q.retry_counts.contains_key(&conv(ch))); // The requeued event is back in the queue. Flush it again so the // queue is empty (simulating a successful retry dispatch). // We need to wait for retry_after to expire first. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Now mark_complete with no active throttle — clears retry_counts. q.mark_complete(ch); - assert!(!q.retry_counts.contains_key(&ch)); + assert!(!q.retry_counts.contains_key(&conv(ch))); // Re-create the orphan scenario: manually insert stale retry_counts // with no queue, no throttle, and no in-flight. - q.retry_counts.insert(ch, 3); + q.retry_counts.insert(conv(ch), 3); q.compact_expired_state(); assert!( - !q.retry_counts.contains_key(&ch), + !q.retry_counts.contains_key(&conv(ch)), "orphaned retry_counts should be removed" ); } @@ -4481,17 +4986,17 @@ mod tests { // Expire the throttle so the requeued event can be flushed. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Channel is now in-flight with empty queue and expired throttle. - assert!(q.in_flight_channels.contains(&ch)); - assert!(q.queues.get(&ch).is_none_or(|q| q.is_empty())); + assert!(q.in_flight_scopes.contains(&conv(ch))); + assert!(q.queues.get(&conv(ch)).is_none_or(|q| q.is_empty())); // compact must NOT remove retry_counts — the in-flight attempt // may fail and requeue, which needs the existing count. q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&conv(ch)), "retry_counts must survive while channel is in-flight" ); } @@ -4503,11 +5008,11 @@ mod tests { // Manually set up: retry_counts exists, queue is non-empty, no throttle. q.push(make_queued(ch, "msg1")); - q.retry_counts.insert(ch, 2); + q.retry_counts.insert(conv(ch), 2); q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&conv(ch)), "retry_counts should survive when queue is non-empty" ); } @@ -4714,6 +5219,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4756,6 +5262,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4792,6 +5299,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4821,6 +5329,7 @@ mod tests { let event = make_event("hey there"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4865,6 +5374,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4901,6 +5411,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4936,6 +5447,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: plain, @@ -4973,6 +5485,7 @@ mod tests { let plain_id = plain.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: threaded, @@ -5004,8 +5517,10 @@ mod tests { /// Build a single-event FlushBatch with the given content. fn make_single_batch(content: &str) -> FlushBatch { + let channel_id = Uuid::new_v4(); FlushBatch { - channel_id: Uuid::new_v4(), + channel_id, + scope: conv(channel_id), events: vec![BatchEvent { event: make_event(content), prompt_tag: "test".into(), @@ -5140,7 +5655,10 @@ mod tests { "withheld-only channel must not register as flushable work" ); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(1)); + assert_eq!( + q.withheld_native_steer.get(&conv(ch)).map(|v| v.len()), + Some(1) + ); } /// Earlier events on the same channel must flush normally during the @@ -5208,9 +5726,9 @@ mod tests { // Simulate a prompt in flight for `ch`, then withhold the queued // event for an in-flight goose-native steer. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), Instant::now()); + q.in_flight_batch_sizes.insert(conv(ch), 1); assert!(q.mark_native_steer_pending(ch, &event_id)); // Force the in-flight deadline to be in the past, simulating the @@ -5218,7 +5736,7 @@ mod tests { // for `in_flight_deadline` to elapse. Same expiry-simulation // trick used by `test_retry_throttle_blocks_requeue_channel`. q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); // `has_flushable_work` runs the expiry block first; it must recover // the withheld event so the channel registers as flushable. @@ -5270,20 +5788,23 @@ mod tests { assert!(q.mark_native_steer_pending(ch, &e2_id)); assert!(q.mark_native_steer_pending(ch, &e3_id)); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(3)); + assert_eq!( + q.withheld_native_steer.get(&conv(ch)).map(|v| v.len()), + Some(3) + ); // Trigger expiry → bulk-release path. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.in_flight_batch_sizes.insert(ch, 3); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); + q.in_flight_batch_sizes.insert(conv(ch), 3); assert!(q.has_flushable_work()); // After recovery, the queue front-to-back order must match the // original FIFO: e1, e2, e3. let recovered: Vec = q .queues - .get(&ch) + .get(&conv(ch)) .expect("queue restored") .iter() .map(|qe| qe.event.id.to_hex()) @@ -5300,6 +5821,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -5329,6 +5851,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -5357,6 +5880,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -5405,11 +5929,11 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let old_deadline = Instant::now() + Duration::from_secs(100); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, old_deadline); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), old_deadline); q.extend_in_flight_deadline(ch, 7200); - let new = *q.in_flight_deadlines.get(&ch).unwrap(); + let new = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert!( new > old_deadline, "extended deadline must be past the original" @@ -5421,11 +5945,11 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let far_future = Instant::now() + Duration::from_secs(999_999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, far_future); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), far_future); q.extend_in_flight_deadline(ch, 7200); - let after = *q.in_flight_deadlines.get(&ch).unwrap(); + let after = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert_eq!(after, far_future, "deadline must never move backward"); } @@ -5433,17 +5957,17 @@ mod tests { fn extend_in_flight_deadline_noop_after_mark_complete() { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(100)); + q.in_flight_batch_sizes.insert(conv(ch), 1); q.mark_complete(ch); - assert!(!q.in_flight_deadlines.contains_key(&ch)); + assert!(!q.in_flight_deadlines.contains_key(&conv(ch))); q.extend_in_flight_deadline(ch, 7200); assert!( - !q.in_flight_deadlines.contains_key(&ch), + !q.in_flight_deadlines.contains_key(&conv(ch)), "extend after mark_complete must not resurrect a deadline" ); } @@ -5453,17 +5977,17 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let extended = Instant::now() + Duration::from_secs(9999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, extended); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), extended); q.compact_expired_state(); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines.contains_key(&conv(ch)), "compaction must not touch in-flight deadlines" ); assert_eq!( - *q.in_flight_deadlines.get(&ch).unwrap(), + *q.in_flight_deadlines.get(&conv(ch)).unwrap(), extended, "compaction must leave extended deadline intact" ); @@ -5482,9 +6006,9 @@ mod tests { // Insert the channel as in-flight with a deadline already in the past // (Instant::now() — by the time flush_next runs, now >= deadline). - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), Instant::now()); + q.in_flight_batch_sizes.insert(conv(ch), 1); // Also push an event so flush_next has something to do after expiry. q.push(make_queued(ch, "after-expiry")); @@ -5510,10 +6034,10 @@ mod tests { let ch = Uuid::new_v4(); // Put the channel in-flight with an extended deadline far in the future. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(conv(ch), 1); // Push an event for another channel so flush_next has work to do. let ch2 = Uuid::new_v4(); @@ -5527,11 +6051,11 @@ mod tests { // ch must still be in-flight — the extended deadline did not expire. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must remain in-flight after flush_next with an extended deadline" ); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines.contains_key(&conv(ch)), "in-flight deadline for ch must not be removed by flush_next" ); } @@ -5548,10 +6072,10 @@ mod tests { let ch = Uuid::new_v4(); // In-flight channel with extended (far-future) deadline. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(conv(ch), 1); // No other channels — nothing flushable. assert!( @@ -5559,7 +6083,7 @@ mod tests { "has_flushable_work must return false when the only channel is in-flight with extended deadline" ); assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must remain in-flight after has_flushable_work with extended deadline" ); @@ -5572,7 +6096,7 @@ mod tests { ); // ch still in-flight and not expired. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must still be in-flight after has_flushable_work finds ch2 work" ); } @@ -5587,15 +6111,15 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); + .insert(conv(ch), Instant::now() + Duration::from_secs(100)); q.extend_in_flight_deadline(ch, 7200); - let after_first = *q.in_flight_deadlines.get(&ch).unwrap(); + let after_first = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); q.extend_in_flight_deadline(ch, 7200); - let after_second = *q.in_flight_deadlines.get(&ch).unwrap(); + let after_second = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert!( after_second >= after_first, @@ -5818,6 +6342,7 @@ mod tests { fn description_batch(ch: Uuid, event: Event) -> FlushBatch { FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 6188e57a11d..e4e41b4660d 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -277,6 +277,75 @@ fn unix_now_secs() -> u64 { } impl RestClient { + /// Fetch the relay's stable signing identity from its NIP-11 document. + /// + /// Relay-authored workflow attribution is trusted only when the event signer + /// matches this key. Missing, malformed, or unavailable identity data fails + /// closed by returning an error/`None` to the caller. NIP-11 is standardized + /// at the relay root; `/info` remains a compatibility fallback for relays + /// that expose the document through Buzz's explicit alias. + pub async fn relay_self(&self) -> Result, RelayError> { + let mut failures = Vec::new(); + let mut saw_document_without_self = false; + + for path in ["/", "/info"] { + let url = format!("{}{path}", self.base_url); + let response = match self + .http + .get(&url) + .header(reqwest::header::ACCEPT, "application/nostr+json") + .send() + .await + { + Ok(response) => response, + Err(error) => { + failures.push(format!("GET {path} failed: {error}")); + continue; + } + }; + + if !response.status().is_success() { + failures.push(format!("GET {path} returned HTTP {}", response.status())); + continue; + } + + let document: serde_json::Value = match response.json().await { + Ok(document) => document, + Err(error) => { + failures.push(format!("GET {path} returned invalid NIP-11 JSON: {error}")); + continue; + } + }; + let Some(relay_self) = document.get("self") else { + saw_document_without_self = true; + continue; + }; + let Some(relay_self) = relay_self.as_str() else { + failures.push(format!("GET {path} returned a non-string NIP-11 self key")); + continue; + }; + let relay_self = match nostr::PublicKey::from_hex(relay_self) { + Ok(pubkey) => pubkey.to_hex(), + Err(error) => { + failures.push(format!( + "GET {path} returned an invalid NIP-11 self key: {error}" + )); + continue; + } + }; + return Ok(Some(relay_self)); + } + + if saw_document_without_self { + Ok(None) + } else { + Err(RelayError::Http(format!( + "failed to fetch a usable NIP-11 document: {}", + failures.join("; ") + ))) + } + } + /// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body. /// /// Returns the `Authorization: Nostr ` header value (without the @@ -515,6 +584,10 @@ impl RestClient { /// Events the harness cares about. #[derive(Debug, Clone)] pub struct BuzzEvent { + /// Which authenticated relay connection delivered this event. Generation 0 + /// is the initial connection; each successful reconnect increments it + /// before any buffered or live event from that connection is forwarded. + pub connection_generation: u64, /// Which channel this event belongs to. pub channel_id: Uuid, /// The underlying Nostr event. @@ -1140,6 +1213,10 @@ struct BgState { /// A single failed channel REQ is parked here instead of aborting the whole /// reconnect. Drained by the main loop. Flushed on each reconnect attempt. resubscribe_retry: HashSet, + /// Current authenticated WebSocket generation. Incremented immediately + /// after each successful reconnect handshake, before buffered or live + /// events from the new connection are forwarded. + connection_generation: u64, /// Current position in the exponential backoff ladder. /// /// Persisted across calls to `wait_for_reconnect` so a flapping link stays at @@ -1171,6 +1248,7 @@ impl BgState { observer_in_flight: VecDeque::new(), gated_observer_dropped: 0, resubscribe_retry: HashSet::new(), + connection_generation: 0, backoff_step: 0, } } @@ -1292,6 +1370,40 @@ impl BgState { while let Some(event) = self.observer_in_flight.pop_back() { self.gated_observer_pending.push_front(event); } + self.trim_gated_observer_pending(); + } + + /// Re-park a frame the relay explicitly refused, ahead of frames parked + /// after the gate armed. + /// + /// An `OK(id, false, …)` names the refused frame, so only that frame is + /// retried — frames still awaiting their own verdict stay in the + /// acknowledgment window. This is the correlated counterpart to + /// [`Self::requeue_observer_in_flight`], which must retry everything + /// because a NOTICE identifies nothing. + fn requeue_rejected_observer_frame(&mut self, event_id: &str) { + let Some(index) = self + .observer_in_flight + .iter() + .position(|event| event.id.to_hex() == event_id) + else { + return; + }; + if let Some(event) = self.observer_in_flight.remove(index) { + if self.gated_observer_pending.len() >= GATED_OBSERVER_QUEUE_CAP { + self.gated_observer_pending.pop_front(); + self.gated_observer_dropped += 1; + warn!( + dropped_total = self.gated_observer_dropped, + "gated observer queue full — dropped oldest parked frame for refused retry" + ); + } + self.gated_observer_pending.push_front(event); + } + } + + /// Enforce the parked-queue bound, counting evictions so loss stays visible. + fn trim_gated_observer_pending(&mut self) { while self.gated_observer_pending.len() > GATED_OBSERVER_QUEUE_CAP { self.gated_observer_pending.pop_front(); self.gated_observer_dropped += 1; @@ -2189,6 +2301,7 @@ async fn handle_ws_message( } let ts = event.created_at.as_secs(); let buzz_event = BuzzEvent { + connection_generation: state.connection_generation, channel_id: channel_uuid, event: *event, }; @@ -2230,6 +2343,7 @@ async fn handle_ws_message( let event_id_hex = event.id.to_hex(); if state.record_event(channel_id, &event) { let buzz_event = BuzzEvent { + connection_generation: state.connection_generation, channel_id, event: *event, }; @@ -2282,7 +2396,10 @@ async fn handle_ws_message( RelayMessage::Notice { message } => { // Fix 4: NOTICE at warn level. tracing::warn!("relay NOTICE: {message}"); - // The relay sends NOTICE for rate-limited EVENT/COUNT frames. + // NOTICE now carries only connection-scoped refusals: an + // EVENT is refused via OK and a REQ/COUNT via CLOSED. A + // NOTICE names nothing, so every unacknowledged observer + // write must be retried. if message.starts_with("rate-limited:") { let secs = parse_rate_limit_retry_secs(&message).unwrap_or(0); let deadline = state.set_rate_limit_gate(secs); @@ -2450,6 +2567,25 @@ async fn handle_ws_message( warn!("mid-session AUTH rejected (event {event_id}): {message} — triggering reconnect"); return false; } + // A refused EVENT is acknowledged on its own channel, so the + // backoff must arm here — not only in the NOTICE arm. Without + // this the harness would publish straight back into the same + // quota it was just refused on. + if !accepted && message.starts_with("rate-limited:") { + let secs = parse_rate_limit_retry_secs(&message).unwrap_or(0); + let deadline = state.set_rate_limit_gate(secs); + // The OK names the refused frame, so re-park only that + // one rather than every unacknowledged frame. + state.requeue_rejected_observer_frame(&event_id); + warn!( + "rate-limit gate armed via OK for event {event_id} until ~{:.1}s from now", + deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or_default() + .as_secs_f64() + ); + return true; + } state.acknowledge_observer_frame(&event_id); debug!("OK for event {event_id}: accepted={accepted} message={message}"); } @@ -3013,6 +3149,7 @@ async fn try_autonomous_reconnect( match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; + state.connection_generation = state.connection_generation.saturating_add(1); info!("autonomous reconnect succeeded (attempt {})", attempt + 1); let handshake_ok = process_handshake_buffer( ws, @@ -3151,6 +3288,7 @@ async fn wait_for_reconnect( match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; + state.connection_generation = state.connection_generation.saturating_add(1); info!("relay reconnected to {relay_url}"); let handshake_ok = process_handshake_buffer( ws, @@ -4084,6 +4222,147 @@ async fn wait_for_any_ok( mod tests { use super::*; + async fn nip11_test_client( + responses: HashMap, + ) -> ( + RestClient, + std::sync::Arc>>, + tokio::task::JoinHandle<()>, + ) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind NIP-11 test server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("test server address") + ); + let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let server_requests = requests.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 8192]; + let bytes_read = socket.read(&mut request).await.unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..bytes_read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + let has_nip11_accept = request + .lines() + .any(|line| line.eq_ignore_ascii_case("accept: application/nostr+json")); + server_requests + .lock() + .expect("lock recorded NIP-11 requests") + .push((path.clone(), has_nip11_accept)); + + let (status, body) = responses + .get(&path) + .cloned() + .unwrap_or_else(|| (404, "not found".into())); + let reason = if status == 200 { "OK" } else { "Not Found" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let client = RestClient { + http: reqwest::Client::new(), + base_url, + keys: Keys::generate(), + auth_tag_json: None, + }; + (client, requests, server) + } + + #[tokio::test] + async fn relay_self_reads_and_normalizes_standard_root_document() { + let uppercase = "AB".repeat(32); + let responses = HashMap::from([ + ( + "/".to_string(), + (200, serde_json::json!({ "self": uppercase }).to_string()), + ), + ( + "/info".to_string(), + ( + 200, + serde_json::json!({ "self": "cd".repeat(32) }).to_string(), + ), + ), + ]); + let (client, requests, server) = nip11_test_client(responses).await; + + assert_eq!( + client.relay_self().await.expect("fetch relay self"), + Some("ab".repeat(32)) + ); + assert_eq!( + *requests.lock().expect("lock recorded requests"), + vec![("/".to_string(), true)], + "the standard root document should be preferred and request NIP-11 JSON" + ); + server.abort(); + } + + #[tokio::test] + async fn relay_self_falls_back_to_info_alias() { + let responses = HashMap::from([ + ("/".to_string(), (404, "not found".into())), + ( + "/info".to_string(), + ( + 200, + serde_json::json!({ "self": "cd".repeat(32) }).to_string(), + ), + ), + ]); + let (client, requests, server) = nip11_test_client(responses).await; + + assert_eq!( + client.relay_self().await.expect("fetch relay self"), + Some("cd".repeat(32)) + ); + assert_eq!( + *requests.lock().expect("lock recorded requests"), + vec![("/".to_string(), true), ("/info".to_string(), true)] + ); + server.abort(); + } + + #[tokio::test] + async fn relay_self_rejects_malformed_identity_at_both_endpoints() { + let responses = HashMap::from([ + ( + "/".to_string(), + ( + 200, + serde_json::json!({ "self": "not-a-pubkey" }).to_string(), + ), + ), + ( + "/info".to_string(), + (200, serde_json::json!({ "self": 42 }).to_string()), + ), + ]); + let (client, _requests, server) = nip11_test_client(responses).await; + + let error = client + .relay_self() + .await + .expect_err("malformed relay identities must fail closed"); + assert!(error + .to_string() + .contains("failed to fetch a usable NIP-11 document")); + server.abort(); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( @@ -5913,6 +6192,151 @@ mod tests { ); } + /// A rate-limited `OK(id, false, …)` must arm the backoff gate and re-park + /// the refused frame, driven through the real frame dispatcher. + /// + /// This is the buzz-acp side of the relay's rejection-correlation change: + /// a refused EVENT is now acknowledged on its own channel instead of via + /// NOTICE. Reverting either the gate arming or the requeue in the `Ok` arm + /// must fail this test. + #[tokio::test] + async fn rate_limited_ok_arms_gate_and_reparks_refused_observer_frame() { + let (mut client, _server) = test_ws_pair().await; + let (event_tx, _event_rx) = mpsc::channel::>(4); + let (observer_control_tx, _observer_control_rx) = mpsc::channel::(4); + let keys = Keys::generate(); + let mut state = BgState::new(); + + let refused = make_observer_frame(&keys); + let still_pending = make_observer_frame(&keys); + state.track_observer_in_flight(Box::new(refused.clone())); + state.track_observer_in_flight(Box::new(still_pending.clone())); + assert!( + state.check_rate_gate().is_none(), + "gate must start disarmed" + ); + + let frame = json!([ + "OK", + refused.id.to_hex(), + false, + "rate-limited: retry in 5s" + ]); + let should_continue = handle_ws_message( + Message::Text(frame.to_string().into()), + &mut client, + &event_tx, + &observer_control_tx, + &mut state, + &keys, + "wss://relay.test", + "agent-pubkey", + None, + ) + .await; + + assert!(should_continue, "a rate-limited OK must keep the socket"); + assert!( + state.check_rate_gate().is_some(), + "a rate-limited OK must arm the backoff gate, or the harness \ + republishes straight into the same quota" + ); + let parked: Vec<_> = state + .gated_observer_pending + .iter() + .map(|event| event.id) + .collect(); + assert_eq!( + parked, + [refused.id], + "the refused frame must be re-parked for redelivery, not dropped" + ); + let in_flight: Vec<_> = state + .observer_in_flight + .iter() + .map(|event| event.id) + .collect(); + assert_eq!( + in_flight, + [still_pending.id], + "frames still awaiting their own verdict must stay in flight" + ); + } + + #[test] + fn rejected_observer_frame_displaces_oldest_parked_frame_at_capacity() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let refused = make_observer_frame(&keys); + state.track_observer_in_flight(Box::new(refused.clone())); + + let oldest = make_observer_frame(&keys); + state.park_gated_observer_frame(Box::new(oldest.clone())); + let mut survivors = Vec::with_capacity(GATED_OBSERVER_QUEUE_CAP - 1); + for _ in 1..GATED_OBSERVER_QUEUE_CAP { + let event = make_observer_frame(&keys); + survivors.push(event.id); + state.park_gated_observer_frame(Box::new(event)); + } + + state.requeue_rejected_observer_frame(&refused.id.to_hex()); + + let parked: Vec<_> = state + .gated_observer_pending + .iter() + .map(|event| event.id) + .collect(); + assert_eq!(parked.len(), GATED_OBSERVER_QUEUE_CAP); + assert_eq!(parked.first(), Some(&refused.id)); + assert_eq!(&parked[1..], survivors.as_slice()); + assert!(!parked.contains(&oldest.id)); + assert_eq!(state.gated_observer_dropped, 1); + assert!(state.observer_in_flight.is_empty()); + } + + /// A non-rate-limit refusal is terminal: retrying would be refused + /// identically, so the frame is retired rather than re-parked, and the + /// backoff gate stays disarmed. + #[tokio::test] + async fn non_rate_limited_ok_rejection_retires_frame_without_arming_gate() { + let (mut client, _server) = test_ws_pair().await; + let (event_tx, _event_rx) = mpsc::channel::>(4); + let (observer_control_tx, _observer_control_rx) = mpsc::channel::(4); + let keys = Keys::generate(); + let mut state = BgState::new(); + + let refused = make_observer_frame(&keys); + state.track_observer_in_flight(Box::new(refused.clone())); + + let frame = json!(["OK", refused.id.to_hex(), false, "invalid: bad signature"]); + let should_continue = handle_ws_message( + Message::Text(frame.to_string().into()), + &mut client, + &event_tx, + &observer_control_tx, + &mut state, + &keys, + "wss://relay.test", + "agent-pubkey", + None, + ) + .await; + + assert!(should_continue, "a rejected event must not drop the socket"); + assert!( + state.check_rate_gate().is_none(), + "only a rate-limit refusal arms the backoff gate" + ); + assert!( + state.gated_observer_pending.is_empty(), + "a permanently refused frame must not be requeued into a retry loop" + ); + assert!( + state.observer_in_flight.is_empty(), + "a permanently refused frame must be retired from the window" + ); + } + /// Build a signed observer telemetry frame (kind 24200) for gate tests. fn make_observer_frame(keys: &Keys) -> Event { let recipient = Keys::generate(); diff --git a/crates/buzz-acp/src/scope.rs b/crates/buzz-acp/src/scope.rs new file mode 100644 index 00000000000..d32207e5055 --- /dev/null +++ b/crates/buzz-acp/src/scope.rs @@ -0,0 +1,405 @@ +//! Session scoping for ACP. +//! +//! A [`SessionScope`] is the single hashable key that identifies an ACP +//! provider session and its conversational-context boundary. It is derived +//! **once**, when an eligible event is admitted, from the operator +//! [`SessionPolicy`], whether the channel is a DM, and the event's NIP-10 +//! thread tags. Later code must never re-infer scope from the last event in a +//! batch — it carries the resolved scope instead. +//! +//! Policy matrix (see the "Make ACP sessions thread-scoped" ticket): +//! +//! | Surface | Scope | +//! | ----------------------------------- | --------------------------------------- | +//! | New top-level channel mention | `Thread(channel_id, triggering_event)` | +//! | Reply in a channel thread | `Thread(channel_id, canonical_root)` | +//! | Repeated mention in the same thread | reuse that thread scope | +//! | Direct message | `Conversation(channel_id)` | +//! +//! Under [`SessionPolicy::Channel`] (the current default / rollback path) every +//! surface collapses to `Conversation(channel_id)`, preserving today's +//! channel-keyed behavior exactly. + +use nostr::Event; +use uuid::Uuid; + +use crate::queue::parse_thread_tags; + +/// Operator policy controlling how ACP provider sessions are scoped. +/// +/// Selected via `--session-policy` / `BUZZ_ACP_SESSION_POLICY`. Defaults to +/// [`Channel`](SessionPolicy::Channel) so the feature ships dark and can be +/// canaried, then flipped, then rolled back without code changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum SessionPolicy { + /// Legacy behavior: one provider session per channel. Every event in a + /// channel shares a `Conversation(channel_id)` scope. + #[default] + Channel, + /// Thread-scoped: each canonical channel thread gets an isolated provider + /// session. DMs remain conversation-scoped. + Thread, +} + +impl SessionPolicy { + /// Append only the configured session model to the shared base instructions. + /// The resulting base is reused by modern and legacy ACP standing context. + pub(crate) fn append_session_model(self, base_prompt: &str) -> String { + let session_model = match self { + Self::Channel => include_str!("session_model_channel.md"), + Self::Thread => include_str!("session_model_thread.md"), + }; + format!("{}\n\n{}", base_prompt.trim_end(), session_model.trim_end()) + } +} + +impl std::fmt::Display for SessionPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Channel => f.write_str("channel"), + Self::Thread => f.write_str("thread"), + } + } +} + +/// A hashable ACP execution and conversational-context scope. +/// +/// This is the canonical key for provider sessions, queue partitions, in-flight +/// tracking, and context gathering. The channel remains the authorization and +/// collaboration boundary; the scope is the default *execution* boundary. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SessionScope { + /// The whole channel is one session. Used for DMs always, and for every + /// channel event under [`SessionPolicy::Channel`]. + Conversation { channel_id: Uuid }, + /// A single canonical thread within a channel, keyed by its root event id + /// (64-char lowercase hex). + Thread { + channel_id: Uuid, + root_event_id: String, + }, +} + +impl SessionScope { + /// The channel this scope belongs to. Always available — the channel is the + /// authorization boundary regardless of scope variant. + pub fn channel_id(&self) -> Uuid { + match self { + Self::Conversation { channel_id } => *channel_id, + Self::Thread { channel_id, .. } => *channel_id, + } + } + + /// The canonical thread-root event id for a [`Thread`](Self::Thread) scope, + /// or `None` for a conversation scope. + pub fn root_event_id(&self) -> Option<&str> { + match self { + Self::Conversation { .. } => None, + Self::Thread { root_event_id, .. } => Some(root_event_id), + } + } + + /// True when this scope is thread-scoped (not conversation-scoped). + pub fn is_thread(&self) -> bool { + matches!(self, Self::Thread { .. }) + } + + /// Derive the scope for an admitted event. + /// + /// Resolution order: + /// 1. DMs are always [`Conversation`](Self::Conversation) — the ticket keeps + /// direct messages conversation-scoped regardless of policy. + /// 2. Under [`SessionPolicy::Channel`], every channel event is + /// conversation-scoped (legacy / rollback behavior). + /// 3. Under [`SessionPolicy::Thread`], a channel event with a NIP-10 root + /// tag scopes to that canonical root; a top-level mention (no thread + /// tags) opens a new thread rooted at the triggering event id. + /// + /// Thread roots are resolved with [`parse_thread_tags`], i.e. Buzz's shared + /// [`buzz_core::nip10`] canonical-root rules — a malformed marker id is + /// ignored (treated as top-level), and a lone `root` marker with no `reply` + /// is top-level, matching relay ingest. + /// + /// The root id is normalized to lowercase before it becomes the scope key. + /// The shared NIP-10 parser accepts and preserves uppercase ASCII hex + /// (`is_ascii_hexdigit`), but the relay decodes event ids to bytes on + /// ingest, so `AB…` and `ab…` name the *same* thread. Without normalization + /// those equivalent spellings would hash to different `Thread` keys and + /// split one relay thread across two ACP sessions (queue state, provider + /// sessions, affinity, delivery ledgers). `nostr::EventId::to_hex()` is + /// already lowercase, so the top-level path is unaffected. + pub fn derive(policy: SessionPolicy, channel_id: Uuid, is_dm: bool, event: &Event) -> Self { + if is_dm || policy == SessionPolicy::Channel { + return Self::Conversation { channel_id }; + } + + let root_event_id = match parse_thread_tags(event).root_event_id { + Some(root) => root, + None => event.id.to_hex(), + }; + Self::Thread { + channel_id, + root_event_id: root_event_id.to_ascii_lowercase(), + } + } + + /// A compact, log-friendly label for telemetry (e.g. `conversation` or + /// `thread:`), never leaking full ids into high-cardinality fields. + pub fn telemetry_label(&self) -> String { + match self { + Self::Conversation { .. } => "conversation".to_string(), + Self::Thread { root_event_id, .. } => { + let short: String = root_event_id.chars().take(8).collect(); + format!("thread:{short}") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + /// Build a signed event with the given NIP-10 `e`/`p` tags. + fn event_with_tags(tags: Vec>) -> Event { + let keys = Keys::generate(); + let tags: Vec = tags + .into_iter() + .map(|t| nostr::Tag::parse(t).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::Custom(9), "hello") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + fn plain_event() -> Event { + event_with_tags(vec![]) + } + + #[test] + fn session_model_is_appended_once_and_matches_policy() { + let base = include_str!("base_prompt.md"); + assert!(!base.contains("## Session Model")); + for policy in [SessionPolicy::Channel, SessionPolicy::Thread] { + let prompt = policy.append_session_model(base); + assert!(prompt.starts_with(base.trim_end())); + assert_eq!(prompt.matches("## Session Model").count(), 1); + assert!(prompt.ends_with("assume the owning session has it handled.")); + assert!(prompt.contains("DMs stay one conversation")); + assert!(prompt.contains( + "core memory, your workspace on disk, relay access, and channel authorization" + )); + assert!(prompt.contains("leave execution with the owning session")); + match policy { + SessionPolicy::Channel => { + assert!(prompt.contains("one per-channel session")); + assert!(!prompt.contains("each thread gets its own")); + assert!(!prompt.contains("sibling channel thread")); + } + SessionPolicy::Thread => { + assert!(prompt.contains("each thread gets its own")); + assert!(prompt.contains("sibling channel thread")); + assert!(!prompt.contains("one per-channel session")); + } + } + } + } + + #[test] + fn dm_is_always_conversation_scoped_under_thread_policy() { + let ch = Uuid::new_v4(); + // Even a DM with a reply tag stays conversation-scoped. + let root = "a".repeat(64); + let reply = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "b".repeat(64), String::new(), "reply".into()], + ]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, true, &reply); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + } + + #[test] + fn channel_policy_collapses_everything_to_conversation() { + let ch = Uuid::new_v4(); + let root = "a".repeat(64); + let reply = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "b".repeat(64), String::new(), "reply".into()], + ]); + // A threaded reply under Channel policy is still conversation-scoped. + let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &reply); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + // As is a top-level mention. + let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &plain_event()); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + } + + #[test] + fn top_level_mention_opens_thread_rooted_at_trigger() { + let ch = Uuid::new_v4(); + let ev = plain_event(); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn direct_reply_to_root_scopes_to_that_root() { + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + // A single `e` tag carrying only a `root` marker. + let ev = event_with_tags(vec![vec![ + "e".into(), + root.clone(), + String::new(), + "root".into(), + ]]); + // NIP-10: lone `root` with no `reply` is top-level per ingest rules, so + // this yields a top-level scope rooted at the trigger, not `root`. + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn nested_reply_scopes_to_canonical_root_not_parent() { + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + let parent = "d".repeat(64); + let ev = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), parent.clone(), String::new(), "reply".into()], + ]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + // Scope keys on the canonical ROOT, never the immediate parent. + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: root, + } + ); + } + + #[test] + fn repeated_replies_in_same_thread_share_scope() { + let ch = Uuid::new_v4(); + let root = "e".repeat(64); + let mk_reply = || { + event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "f".repeat(64), String::new(), "reply".into()], + ]) + }; + let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply()); + let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply()); + assert_eq!(a, b, "same-root replies must reuse the same thread scope"); + } + + #[test] + fn different_top_level_mentions_get_distinct_scopes() { + let ch = Uuid::new_v4(); + let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event()); + let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event()); + assert_ne!( + a, b, + "two independent top-level mentions must not share a session" + ); + } + + #[test] + fn mixed_case_root_spellings_share_one_thread_scope() { + // The relay decodes event ids to bytes, so `AB…` and `ab…` name the + // same thread. Equivalent-case root tags must resolve to the SAME + // `SessionScope::Thread` key, or thread state would split in two. + let ch = Uuid::new_v4(); + let root_lower = "a1b2c3d4e5f6".repeat(4) + &"0".repeat(16); // 64 hex + assert_eq!(root_lower.len(), 64); + let root_upper = root_lower.to_ascii_uppercase(); + + let mk = |root: &str| { + event_with_tags(vec![ + vec!["e".into(), root.to_string(), String::new(), "root".into()], + vec!["e".into(), "f".repeat(64), String::new(), "reply".into()], + ]) + }; + let lower = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_lower)); + let upper = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_upper)); + assert_eq!( + lower, upper, + "case-equivalent root spellings must share one thread scope" + ); + // And the stored key is normalized to lowercase. + assert_eq!(upper.root_event_id(), Some(root_lower.as_str())); + } + + #[test] + fn malformed_thread_tag_falls_back_to_top_level() { + let ch = Uuid::new_v4(); + // A non-64-hex marker id is ignored by the shared NIP-10 resolver, so + // the event is treated as top-level (rooted at its own id). + let ev = event_with_tags(vec![vec![ + "e".into(), + "not-a-valid-hex-id".into(), + String::new(), + "reply".into(), + ]]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn accessors_and_labels() { + let ch = Uuid::new_v4(); + let conv = SessionScope::Conversation { channel_id: ch }; + assert_eq!(conv.channel_id(), ch); + assert_eq!(conv.root_event_id(), None); + assert!(!conv.is_thread()); + assert_eq!(conv.telemetry_label(), "conversation"); + + let root = "abcdef0123456789".repeat(4); // 64 hex chars + let thread = SessionScope::Thread { + channel_id: ch, + root_event_id: root.clone(), + }; + assert_eq!(thread.channel_id(), ch); + assert_eq!(thread.root_event_id(), Some(root.as_str())); + assert!(thread.is_thread()); + assert_eq!(thread.telemetry_label(), "thread:abcdef01"); + } + + #[test] + fn scope_is_hashable_and_usable_as_map_key() { + use std::collections::HashMap; + let ch = Uuid::new_v4(); + let mut map: HashMap = HashMap::new(); + let s1 = SessionScope::Thread { + channel_id: ch, + root_event_id: "a".repeat(64), + }; + let s2 = SessionScope::Conversation { channel_id: ch }; + *map.entry(s1.clone()).or_insert(0) += 1; + *map.entry(s1.clone()).or_insert(0) += 1; + *map.entry(s2).or_insert(0) += 1; + assert_eq!(map.get(&s1), Some(&2)); + assert_eq!(map.len(), 2); + } +} diff --git a/crates/buzz-acp/src/session_model_channel.md b/crates/buzz-acp/src/session_model_channel.md new file mode 100644 index 00000000000..58f652aa3c2 --- /dev/null +++ b/crates/buzz-acp/src/session_model_channel.md @@ -0,0 +1,5 @@ +## Session Model + +You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Threads within a channel share that channel's session. DMs stay one conversation. Sessions share your core memory, your workspace on disk, relay access, and channel authorization. They do NOT share conversation context, in-progress reasoning, or in-context task state. + +When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. diff --git a/crates/buzz-acp/src/session_model_thread.md b/crates/buzz-acp/src/session_model_thread.md new file mode 100644 index 00000000000..5665520b8d9 --- /dev/null +++ b/crates/buzz-acp/src/session_model_thread.md @@ -0,0 +1,5 @@ +## Session Model + +You are one session of your agent identity — not the only copy. In channels, each thread gets its own independent conversation context, including a new thread rooted at a top-level mention. DMs stay one conversation, not separate sessions per thread. Multiple sessions of the same agent may be active in different channels or different threads in the same channel at the same time. Sessions share your core memory, your workspace on disk, relay access, and channel authorization. They do NOT share conversation context, in-progress reasoning, or in-context task state. + +When a human references work "you" are doing in another channel or a sibling channel thread, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this session, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea46..88225469aa2 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -71,10 +71,11 @@ pub(crate) enum AcpAvailabilityStatus { } use crate::{ - author_allowed, config::Config, event_mentions_agent, filter, - relay::{HarnessRelay, RelayEventPublisher}, + inbound_author_gate::AuthorizedListenerEvent, + relay::{self, HarnessRelay, RelayEventPublisher}, + InboundAuthorGate, OwnerCache, }; // ── Payload ─────────────────────────────────────────────────────────────────── @@ -342,6 +343,10 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::info!("setup-mode: connected and subscribed to membership notifications"); + let rest_client = relay.rest_client(); + let mut author_gate_ctx = + crate::InboundAuthorGate::connect(&rest_client, &pubkey_hex, "setup startup").await; + // Resolve owner for author-gate (same priority as normal mode). let startup_owner = crate::resolve_agent_owner(&config); let owner_cache = crate::OwnerCache::new(startup_owner); @@ -381,7 +386,6 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> } let publisher = relay.event_publisher(); - let rest_client = relay.rest_client(); let channel_info = crate::pool::ChannelInfoResolver::new(channel_info_map, rest_client.clone()); @@ -428,80 +432,115 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let author_hex = buzz_event.event.pubkey.to_hex(); - let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; - let allowed = author_allowed( + let Some(authorized_event) = authorize_setup_listener_event( + &mut author_gate_ctx, + buzz_event, &config.respond_to, &config.respond_to_allowlist, - &author_hex, - is_dm, &owner_cache, + &channel_info, &rest_client, ) - .await; + .await + else { + continue; + }; - // Apply channel/kind filter rules. - let filter_matched = filter::match_event( - &buzz_event.event, - buzz_event.channel_id, + if !nudge_authorized_event( + authorized_event, &rules, &pubkey_hex, - ) - .await - .is_some(); - - // Pure gate: author gate verdict + event-id dedup. - if !should_nudge_for_event( - buzz_event.event.id, - allowed, - filter_matched, &mut nudged_event_ids, - ) { - continue; - } - - // Build and publish the setup nudge. - if let Err(e) = publish_setup_nudge( &publisher, &config.keys, - buzz_event.channel_id, - &buzz_event.event, &payload, ) .await { - tracing::warn!("setup-mode: failed to publish nudge: {e}"); - } else { - tracing::info!( - channel_id = %buzz_event.channel_id, - event_id = %buzz_event.event.id, - "setup-mode: nudge published" - ); + continue; } } Ok(()) } -/// Outcome of the pure per-event gate checks in setup mode. +async fn nudge_authorized_event( + authorized_event: AuthorizedListenerEvent, + rules: &[filter::SubscriptionRule], + pubkey_hex: &str, + nudged_event_ids: &mut HashSet, + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + payload: &SetupPayload, +) -> bool { + let (buzz_event, effective_author) = authorized_event.into_parts(); + + // Apply channel/kind filter rules. + let filter_matched = + filter::match_event(&buzz_event.event, buzz_event.channel_id, rules, pubkey_hex) + .await + .is_some(); + + if !should_nudge_for_event(buzz_event.event.id, filter_matched, nudged_event_ids) { + return false; + } + + // Build and publish the setup nudge. + if let Err(e) = publish_setup_nudge( + publisher, + keys, + buzz_event.channel_id, + &buzz_event.event, + &effective_author, + payload, + ) + .await + { + tracing::warn!("setup-mode: failed to publish nudge: {e}"); + } else { + tracing::info!( + channel_id = %buzz_event.channel_id, + event_id = %buzz_event.event.id, + "setup-mode: nudge published" + ); + } + true +} + +pub(super) async fn authorize_setup_listener_event( + author_gate: &mut InboundAuthorGate, + buzz_event: relay::BuzzEvent, + respond_to: &crate::config::RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &crate::pool::ChannelInfoResolver, + rest_client: &relay::RestClient, +) -> Option { + author_gate + .authorize_listener_event( + buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await +} + +/// Outcome of the synchronous per-event setup checks. /// -/// Callers compute the async gates (`author_allowed`, `filter::match_event`) -/// up-front, then pass the boolean results here. This helper handles -/// everything that is synchronous and stateful: the author gate verdict -/// and event-id dedup. +/// This helper owns only filter matching and event-id deduplication; the +/// production path can call it only through `nudge_authorized_event`, whose +/// input is the gate's private authorized capability. /// /// Returns `true` when the event should produce a nudge. #[must_use] pub(crate) fn should_nudge_for_event( event_id: EventId, - author_allowed: bool, filter_matched: bool, nudged_event_ids: &mut HashSet, ) -> bool { - if !author_allowed { - tracing::debug!("setup-mode: event filtered by author gate"); - return false; - } if !filter_matched { return false; } @@ -591,12 +630,13 @@ async fn handle_setup_membership( /// Build and publish a setup nudge reply to the triggering event. /// /// Threading: flat reply to the thread root if one exists; otherwise reply -/// to the triggering event itself. P-tags the asker. +/// to the triggering event itself. P-tags the verified effective asker. async fn publish_setup_nudge( publisher: &RelayEventPublisher, keys: &nostr::Keys, channel_id: Uuid, triggering_event: &nostr::Event, + recipient_hex: &str, payload: &SetupPayload, ) -> Result<()> { use buzz_sdk::ThreadRef; @@ -621,13 +661,12 @@ async fn publish_setup_nudge( }; let body = payload.nudge_body(); - let author_hex = triggering_event.pubkey.to_hex(); let event_builder = buzz_sdk::build_message( channel_id, &body, thread_ref.as_ref(), - &[&author_hex], // p-tag the asker + &[recipient_hex], // p-tag the verified effective asker false, &[], ) @@ -699,6 +738,89 @@ mod tests { )); } + #[tokio::test] + async fn authorized_workflow_nudge_mentions_effective_owner_not_relay_signer() { + let agent_keys = nostr::Keys::generate(); + let relay_keys = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let channel_id = Uuid::new_v4(); + let event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: crate::author_gate_tests::relay_signed_workflow_dispatch( + &relay_keys, + &workflow_owner, + &agent, + ), + }; + let relay_hex = relay_keys.public_key().to_hex(); + let (rest_client, server) = + crate::author_gate_tests::nip11_server(serde_json::json!({ "self": relay_hex })).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "setup nudge test").await; + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + let channel_info = crate::pool::ChannelInfoResolver::new( + std::collections::HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let authorized = authorize_setup_listener_event( + &mut gate, + event, + &crate::config::RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await + .expect("workflow owner should pass the setup author gate"); + let rules = vec![filter::SubscriptionRule { + name: "workflow".into(), + channels: filter::ChannelScope::All("all".into()), + ..Default::default() + }]; + let (publisher, mut published) = RelayEventPublisher::test_pair(); + let payload = SetupPayload { + agent_name: "Fizz".into(), + agent_pubkey: agent.clone(), + requirements: vec![], + }; + + assert!( + nudge_authorized_event( + authorized, + &rules, + &agent, + &mut HashSet::new(), + &publisher, + &agent_keys, + &payload, + ) + .await + ); + let nudge = published.recv().await.expect("setup nudge published"); + let recipients: Vec<&str> = nudge + .tags + .iter() + .filter_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("p")) + .then(|| values.get(1).map(String::as_str)) + .flatten() + }) + .collect(); + assert!(recipients.contains(&workflow_owner.as_str())); + assert!(!recipients.contains(&relay_hex.as_str())); + server.abort(); + } + #[test] fn nudge_body_names_all_requirements() { let payload = SetupPayload { @@ -988,32 +1110,25 @@ mod tests { // ── should_nudge_for_event gate tests ───────────────────────────────────── // - // These tests exercise the loop-wiring for the two safety-critical guards: - // (a) non-allowlisted author → no nudge, (b) same event-id → exactly one - // nudge. They use the extracted `should_nudge_for_event` helper, which is - // the exact code the live loop calls. + // These tests exercise the loop-adjacent synchronous guards after an event + // has passed the structurally mandatory author capability: (a) unmatched + // filter → no nudge, (b) same event-id → exactly one nudge. fn fake_event_id(byte: u8) -> EventId { EventId::from_byte_array([byte; 32]) } #[test] - fn test_non_allowlisted_author_returns_no_nudge() { - // author_allowed = false → should return false regardless of other args. + fn test_unmatched_filter_returns_no_nudge() { let mut dedup: HashSet = HashSet::new(); let event_id = fake_event_id(0xAA); - let result = should_nudge_for_event( - event_id, false, // author NOT allowed - true, // filter matched — would otherwise nudge - &mut dedup, - ); + let result = should_nudge_for_event(event_id, false, &mut dedup); - assert!(!result, "non-allowlisted author must not produce a nudge"); - // Dedup set must remain empty — no phantom insertion for blocked author. + assert!(!result, "unmatched event must not produce a nudge"); assert!( dedup.is_empty(), - "dedup set must not record event for blocked author" + "dedup set must not record an unmatched event" ); } @@ -1024,19 +1139,11 @@ mod tests { let mut dedup: HashSet = HashSet::new(); let event_id = fake_event_id(0xBB); - let first = should_nudge_for_event( - event_id, true, // allowed - true, // matched - &mut dedup, - ); + let first = should_nudge_for_event(event_id, true, &mut dedup); assert!(first, "first occurrence must be accepted"); // Simulate reconnect replay: same event arrives again. - let second = should_nudge_for_event( - event_id, true, // allowed - true, // matched - &mut dedup, - ); + let second = should_nudge_for_event(event_id, true, &mut dedup); assert!( !second, "replay of the same event-id must be rejected (dedup)" diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 42a7de84f7c..19a3b1d9d48 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -433,10 +433,13 @@ async fn connect_member_services() -> Result<(Db, Arc, Keys)> { async fn connect_db() -> Result { let db_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); - let db = Db::new(&DbConfig { - database_url: db_url, - ..DbConfig::default() - }) + let db = Db::new( + &DbConfig { + database_url: db_url, + ..DbConfig::default() + } + .with_session_timeouts_from_env(), + ) .await?; Ok(db) } diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 66251563cbd..00cd81c6940 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -85,6 +85,15 @@ where let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer) .await? .detach(); + // This dedicated connection intentionally waits for the current migration + // or schema-destruction owner and may then run long DDL. Exempt those two + // phases from runtime lock/statement budgets. Keep the idle-in-transaction + // timeout: a client wedged idle mid-migration is still a lock holder that + // should be reaped. The detached connection is closed below and never + // returns these session settings to the pool. + sqlx::raw_sql("SET lock_timeout = 0; SET statement_timeout = 0") + .execute(&mut lock_conn) + .await?; crate::observability::observe_advisory_lock( crate::observability::LockType::MigrationSchemaSafety, sqlx::query("SELECT pg_advisory_lock($1)") diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 29eef884024..73e5e4fe569 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -454,6 +454,16 @@ pub struct DbConfig { /// than the staleness gate never routes anyway, so a larger budget /// would only misrepresent the config. pub replica_read_max_age_ms: u64, + /// Session `lock_timeout` in milliseconds for writer connections (env + /// `BUZZ_DB_LOCK_TIMEOUT_MS`). `0` disables the timeout. + pub lock_timeout_ms: u64, + /// Session `idle_in_transaction_session_timeout` in milliseconds for + /// writer connections (env `BUZZ_DB_IDLE_TXN_TIMEOUT_MS`). `0` disables. + pub idle_txn_timeout_ms: u64, + /// Session `statement_timeout` in milliseconds for writer connections + /// (env `BUZZ_DB_STATEMENT_TIMEOUT_MS`). `0` disables it and is the + /// default because migrations and backfills may legitimately run long. + pub statement_timeout_ms: u64, } impl Default for DbConfig { @@ -471,10 +481,47 @@ impl Default for DbConfig { max_lifetime_secs: 1800, idle_timeout_secs: 600, replica_read_max_age_ms: 0, + lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS, + idle_txn_timeout_ms: DEFAULT_IDLE_TXN_TIMEOUT_MS, + statement_timeout_ms: 0, } } } +/// Default writer `lock_timeout` in milliseconds. +pub const DEFAULT_LOCK_TIMEOUT_MS: u64 = 5_000; + +/// Default writer `idle_in_transaction_session_timeout` in milliseconds. +pub const DEFAULT_IDLE_TXN_TIMEOUT_MS: u64 = 60_000; + +impl DbConfig { + /// Overlay writer session timeouts from the shared `BUZZ_DB_*_TIMEOUT_MS` + /// environment variables. Missing or invalid values retain the existing + /// configuration; explicit zeroes pass through to disable a timeout. + /// + /// This belongs in `buzz-db` so relay, admin, deletion, and audit writers + /// share one policy. The separately deployed push gateway owns its own + /// database and session policy. + pub fn with_session_timeouts_from_env(mut self) -> Self { + fn parse(key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|value| value.parse::().ok()) + } + + if let Some(value) = parse("BUZZ_DB_LOCK_TIMEOUT_MS") { + self.lock_timeout_ms = value; + } + if let Some(value) = parse("BUZZ_DB_IDLE_TXN_TIMEOUT_MS") { + self.idle_txn_timeout_ms = value; + } + if let Some(value) = parse("BUZZ_DB_STATEMENT_TIMEOUT_MS") { + self.statement_timeout_ms = value; + } + self + } +} + impl Db { /// Creates a new `Db` by connecting a Postgres pool with the given config. /// @@ -486,7 +533,7 @@ impl Db { /// `buzz.created_at_floor` GUC — this is what makes the replica fence /// proof hold for every insert path that goes through this pool. pub async fn new(config: &DbConfig) -> Result { - let pool = Self::connect_pool(config, &config.database_url).await?; + let pool = Self::connect_writer_pool(config).await?; let read_max_connections = config .read_max_connections .unwrap_or(config.max_connections); @@ -511,20 +558,44 @@ impl Db { /// SQLx stores one `after_connect` hook, so the floor guard and transaction /// isolation assertion must remain in this single closure. Registering a /// second hook replaces the first and silently disarms the floor trigger. - async fn connect_pool(config: &DbConfig, url: &str) -> Result { + /// Additional writer pools, including the relay audit pool, must use this + /// constructor so they inherit the timeout, floor-guard, and isolation + /// policy installed by [`Db::new`]. + pub async fn connect_writer_pool(config: &DbConfig) -> Result { + let lock_timeout_ms = config.lock_timeout_ms; + let idle_txn_timeout_ms = config.idle_txn_timeout_ms; + let statement_timeout_ms = config.statement_timeout_ms; let options = PgPoolOptions::new() .max_connections(config.max_connections) .min_connections(config.min_connections) .acquire_timeout(Duration::from_secs(config.acquire_timeout_secs)) .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) - .after_connect(|conn, _meta| { + .after_connect(move |conn, _meta| { Box::pin(async move { // `SET` cannot take bind parameters; `set_config` can. sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) .execute(&mut *conn) .await?; + // `lock_timeout` fails the waiting statement; it does not + // cancel the holder. `idle_in_transaction_session_timeout` + // reaps only holders idling inside an open transaction, + // while actively executing holders are bounded only by + // `statement_timeout` (off by default). Bare values are + // milliseconds. Migration/schema-destruction connections + // reset lock and statement timeouts before their intentional + // long wait (see `with_exclusive_schema_destruction_lock`). + sqlx::query( + "SELECT set_config('lock_timeout', $1, false), \ + set_config('idle_in_transaction_session_timeout', $2, false), \ + set_config('statement_timeout', $3, false)", + ) + .bind(lock_timeout_ms.to_string()) + .bind(idle_txn_timeout_ms.to_string()) + .bind(statement_timeout_ms.to_string()) + .execute(&mut *conn) + .await?; let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") .fetch_one(&mut *conn) .await?; @@ -539,7 +610,7 @@ impl Db { Ok(()) }) }); - Ok(options.connect(url).await?) + Ok(options.connect(&config.database_url).await?) } /// Reader acquire timeout — deliberately far below the writer's diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index ecdc983a4ac..8349fb53874 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::{relay_members, thread}; use buzz_core::CommunityId; -use sqlx::PgPool; +use sqlx::{Connection, PgPool}; use uuid::Uuid; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; @@ -2180,10 +2180,10 @@ async fn created_at_floor_guard_aborts_old_channel_rows_at_commit() { fn writer_pool_safety_hook_is_single_and_composed() { let source = include_str!("mod.rs"); let connect_pool = source - .split("async fn connect_pool") + .split("async fn connect_writer_pool") .nth(1) .and_then(|tail| tail.split("const READER_ACQUIRE_TIMEOUT").next()) - .expect("connect_pool source block"); + .expect("connect_writer_pool source block"); assert_eq!( connect_pool.matches(".after_connect(").count(), 1, @@ -2191,6 +2191,9 @@ fn writer_pool_safety_hook_is_single_and_composed() { ); assert!(connect_pool.contains("buzz.created_at_floor")); assert!(connect_pool.contains("SHOW transaction_isolation")); + assert!(connect_pool.contains("'lock_timeout'")); + assert!(connect_pool.contains("'idle_in_transaction_session_timeout'")); + assert!(connect_pool.contains("'statement_timeout'")); assert!(!connect_pool.contains("arm_floor_guard")); assert!(!connect_pool.contains("_arm_floor_guard")); assert!(!connect_pool.contains("allow(unused_variables)")); @@ -2202,7 +2205,7 @@ fn writer_pool_safety_hook_is_single_and_composed() { .expect("reader pool documentation"); assert!(reader_doc.contains("replica sessions are")); assert!(reader_doc.contains("read-only")); - assert!(!reader_doc.contains("Db::connect_pool")); + assert!(!reader_doc.contains("Db::connect_writer_pool")); } #[tokio::test] @@ -2246,6 +2249,146 @@ async fn writer_pool_rejects_non_read_committed_database_default() { .expect("drop isolation test database"); } +/// Session-timeout environment overrides retain PostgreSQL's `0 = disabled` +/// semantics and ignore invalid values. +#[test] +fn session_timeout_env_overlay_zero_passthrough_and_invalid_fallback() { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK.lock().unwrap(); + let keys = [ + "BUZZ_DB_LOCK_TIMEOUT_MS", + "BUZZ_DB_IDLE_TXN_TIMEOUT_MS", + "BUZZ_DB_STATEMENT_TIMEOUT_MS", + ]; + let previous: Vec<_> = keys.iter().map(std::env::var_os).collect(); + let read = |config: DbConfig| { + ( + config.lock_timeout_ms, + config.idle_txn_timeout_ms, + config.statement_timeout_ms, + ) + }; + + for key in keys { + std::env::remove_var(key); + } + let unset = read(DbConfig::default().with_session_timeouts_from_env()); + + std::env::set_var("BUZZ_DB_LOCK_TIMEOUT_MS", "2000"); + std::env::set_var("BUZZ_DB_IDLE_TXN_TIMEOUT_MS", "30000"); + std::env::set_var("BUZZ_DB_STATEMENT_TIMEOUT_MS", "10000"); + let overridden = read(DbConfig::default().with_session_timeouts_from_env()); + + for key in keys { + std::env::set_var(key, "0"); + } + let zero = read(DbConfig::default().with_session_timeouts_from_env()); + + for key in keys { + std::env::set_var(key, "not-a-number"); + } + let junk = read(DbConfig::default().with_session_timeouts_from_env()); + + for (key, value) in keys.iter().zip(previous) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + + let defaults = (DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_IDLE_TXN_TIMEOUT_MS, 0); + assert_eq!(unset, defaults, "unset env must keep the defaults"); + assert_eq!(overridden, (2000, 30000, 10000)); + assert_eq!(zero, (0, 0, 0), "explicit 0 must disable each timeout"); + assert_eq!(junk, defaults, "junk env must keep the defaults"); +} + +/// The production writer constructor installs all three timeout GUCs, bounds +/// ordinary lock waits, and exempts the intentional migration lock wait. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn session_timeouts_install_through_db_new_and_bound_lock_waits() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, name) = create_scratch_db(&admin, "session_timeouts").await; + seed_pool.close().await; + + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let db = Db::new(&DbConfig { + database_url: scratch_url.clone(), + max_connections: 2, + lock_timeout_ms: 500, + idle_txn_timeout_ms: 60_000, + statement_timeout_ms: 0, + ..DbConfig::default() + }) + .await + .expect("connect Db with session timeouts"); + + let (lock, idle, statement): (String, String, String) = sqlx::query_as( + "SELECT current_setting('lock_timeout'), \ + current_setting('idle_in_transaction_session_timeout'), \ + current_setting('statement_timeout')", + ) + .fetch_one(&db.pool) + .await + .expect("read effective GUCs"); + assert_eq!(lock, "500ms"); + assert_eq!(idle, "1min"); + assert_eq!(statement, "0"); + + let mut holder = db.pool.acquire().await.expect("holder connection"); + sqlx::raw_sql("BEGIN; LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *holder) + .await + .expect("hold relation lock"); + let waited = std::time::Instant::now(); + let mut waiter_txn = db.pool.begin().await.expect("waiter transaction"); + let error = sqlx::query("LOCK TABLE events IN ACCESS SHARE MODE") + .execute(&mut *waiter_txn) + .await + .expect_err("waiter must time out, not park"); + drop(waiter_txn); + let code = match &error { + sqlx::Error::Database(db_error) => db_error.code().map(|code| code.to_string()), + other => panic!("expected database error, got {other:?}"), + }; + assert_eq!(code.as_deref(), Some("55P03")); + assert!(waited.elapsed() < std::time::Duration::from_secs(5)); + + let mut advisory_holder = PgPool::connect(&scratch_url) + .await + .expect("advisory holder pool") + .acquire() + .await + .expect("advisory holder conn") + .detach(); + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(crate::deletion::SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(&mut advisory_holder) + .await + .expect("hold schema advisory lock"); + let release = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + let _ = sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(crate::deletion::SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(&mut advisory_holder) + .await; + let _ = advisory_holder.close().await; + }); + db.migrate() + .await + .expect("migrate must wait out the advisory holder"); + release.await.expect("release task"); + + let _ = sqlx::query("ROLLBACK").execute(&mut *holder).await; + drop(holder); + drop_scratch_db(&admin, db.pool.clone(), &name).await; +} + /// The armed writer pool (`Db::new`) must enforce the floor end-to-end /// through the public insert APIs, and the session GUC must be verifiably /// set on pooled connections. diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index f13b7d507ac..d963a7f261f 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -531,11 +531,14 @@ fn resolve_submit_host(host: Option<&str>, relay_url: Option<&str>) -> Result Result { let database_url = required_env("DATABASE_URL")?; - let db = Db::new(&DbConfig { - database_url, - max_connections: env_parse("BUZZ_DB_POOL_SIZE", 20), - ..DbConfig::default() - }) + let db = Db::new( + &DbConfig { + database_url, + max_connections: env_parse("BUZZ_DB_POOL_SIZE", 20), + ..DbConfig::default() + } + .with_session_timeouts_from_env(), + ) .await?; Ok(store(&db)) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 179c4ce19a2..a4c051c4833 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -55,17 +55,28 @@ pub(crate) async fn enforce_http_admission( } } +/// Values retained from an already-verified bridge authentication event. +#[derive(Debug)] +pub(crate) struct VerifiedBridgeAuth { + pub(crate) pubkey: nostr::PublicKey, + pub(crate) event_id_bytes: [u8; 32], + pub(crate) signed_created_at: Option, +} + +type BridgeAuthResult = Result)>; + /// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode). /// -/// Returns the authenticated public key and an event ID for replay detection. -/// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). +/// Returns the authenticated public key, an event ID for replay detection, and +/// the verified signed auth timestamp. For X-Pubkey dev mode, the event ID is +/// a zero hash and the timestamp is absent. pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, body: Option<&[u8]>, require_auth_token: bool, -) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { +) -> BridgeAuthResult { verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false) } @@ -76,7 +87,7 @@ pub(crate) fn verify_bridge_auth_with_options( body: Option<&[u8]>, require_auth_token: bool, require_payload: bool, -) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { +) -> BridgeAuthResult { // Try NIP-98 first (Authorization: Nostr ) if let Some(auth_str) = headers .get("authorization") @@ -111,7 +122,11 @@ pub(crate) fn verify_bridge_auth_with_options( let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body) .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; - return Ok((pubkey, event_id_bytes)); + return Ok(VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at: Some(event.created_at.as_secs()), + }); } // Dev-mode fallback: X-Pubkey header (only when require_auth_token is false) @@ -120,7 +135,11 @@ pub(crate) fn verify_bridge_auth_with_options( let pubkey = nostr::PublicKey::from_hex(hex_val) .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?; // Zero event ID — no replay detection needed for dev mode - return Ok((pubkey, [0u8; 32])); + return Ok(VerifiedBridgeAuth { + pubkey, + event_id_bytes: [0u8; 32], + signed_created_at: None, + }); } } @@ -723,7 +742,11 @@ pub async fn submit_event( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -736,8 +759,16 @@ pub async fn submit_event( // runs inside the helper. The thin wrapper here owns the single terminal // attribution line so it fires for every outcome, including admission/ // replay/membership failures that previously returned before any log fired. - let outcome = - submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let outcome = submit_event_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &outcome { SubmitOutcome::Ok { accepted, kind, .. } => { @@ -846,6 +877,7 @@ async fn submit_event_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> SubmitOutcome { // Admission and replay checks fire before body parse — a 429 or replay // reject on a malformed body must still be attributed. @@ -888,18 +920,23 @@ async fn submit_event_authed( }; // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); let nip_oa_owner = match super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await { Ok(owner) => owner.or_else(|| { if !state.config.require_relay_membership { - super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag) + super::relay_members::extract_nip_oa_owner( + &pubkey_bytes, + auth_tag, + signed_auth_created_at, + ) } else { None } @@ -994,7 +1031,11 @@ pub async fn query_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -1007,8 +1048,16 @@ pub async fn query_events( // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - query_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = query_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &result { Ok(Json(Value::Array(events))) => { tracing::info!( @@ -1044,17 +1093,19 @@ async fn query_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await?; @@ -1523,7 +1574,11 @@ pub async fn count_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -1536,8 +1591,16 @@ pub async fn count_events( // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - count_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = count_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &result { Ok(Json(value)) => { let count = value.get("count").and_then(Value::as_u64); @@ -1571,17 +1634,19 @@ async fn count_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await?; @@ -2328,8 +2393,11 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -2980,14 +3048,21 @@ mod tests { let tenant_a = fresh_tenant("host-a.example"); let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events"); - let (pubkey, _event_id_bytes) = - verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) - .expect("matching-host NIP-98 event must verify"); + let VerifiedBridgeAuth { + pubkey, + signed_created_at, + .. + } = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) + .expect("matching-host NIP-98 event must verify"); assert_eq!( pubkey, keys.public_key(), "returned pubkey must be the signer's" ); + assert!( + signed_created_at.is_some(), + "verified NIP-98 auth must retain its signed timestamp" + ); } /// Mirror of the query-reconstruction `authorize_moderation_read` performs @@ -3029,7 +3104,7 @@ mod tests { Some("limit=20&status=open"), ); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("query-bearing moderation read must verify against the same query"); assert_eq!(pubkey, keys.public_key()); @@ -3086,7 +3161,7 @@ mod tests { Some("limit=20"), ); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("audit query-bearing read must verify"); assert_eq!(pubkey, keys.public_key()); @@ -3111,7 +3186,7 @@ mod tests { ); assert_eq!(expected_url, "https://host-a.example/moderation/restricted"); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("query-less restricted read must verify against the bare path"); assert_eq!(pubkey, keys.public_key()); diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index a8848af295a..c29df6746bb 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -138,7 +138,11 @@ async fn authenticate( })?; let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = bridge::verify_bridge_auth_with_options( headers, "POST", &expected_url, @@ -152,9 +156,8 @@ async fn authenticate( state, tenant.community(), &pubkey.to_bytes(), - headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()), + relay_members::extract_auth_tag_header(headers), + signed_created_at, ) .await?; diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index ec7af3aac65..94fd7f8758e 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -200,22 +200,21 @@ impl axum::extract::FromRequestParts> for GitAuth { let event: nostr::Event = serde_json::from_str(&event_json) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid auth event").into_response())?; + let signed_auth_created_at = event.created_at.as_secs(); // Relay membership gate (NIP-43). Git cannot carry a standalone // x-auth-tag header through the credential-helper protocol, so agents // attach their NIP-OA attestation to the signed NIP-98 event, matching // the WebSocket NIP-42 flow. let event_auth_tag = crate::handlers::auth::extract_auth_tag_json(&event); - let header_auth_tag = parts - .headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); + let header_auth_tag = crate::api::relay_members::extract_auth_tag_header(&parts.headers); let auth_tag = event_auth_tag.as_deref().or(header_auth_tag); if crate::api::relay_members::enforce_relay_membership( state, tenant.community(), pubkey.as_bytes(), auth_tag, + Some(signed_auth_created_at), ) .await .is_err() @@ -224,7 +223,14 @@ impl axum::extract::FromRequestParts> for GitAuth { return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } - deny_banned_git_principal(&state.db, tenant.community(), &pubkey, auth_tag).await?; + deny_banned_git_principal( + &state.db, + tenant.community(), + &pubkey, + auth_tag, + Some(signed_auth_created_at), + ) + .await?; Ok(GitAuth { pubkey, tenant }) } @@ -246,6 +252,7 @@ async fn deny_banned_git_principal( community: buzz_core::CommunityId, pubkey: &nostr::PublicKey, auth_tag: Option<&str>, + signed_auth_created_at: Option, ) -> Result<(), Response> { let agent = git_restriction_state(db, community, pubkey).await?; @@ -254,7 +261,11 @@ async fn deny_banned_git_principal( let owner = if agent.banned { None } else { - crate::api::relay_members::extract_nip_oa_owner(pubkey.as_bytes(), auth_tag) + crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag, + signed_auth_created_at, + ) }; let owner_state = match owner { Some(owner) => Some(git_restriction_state(db, community, &owner).await?), @@ -3652,7 +3663,7 @@ mod sec005_read_gate_tests { db.ensure_user(community, &member_pk).await.expect("member"); assert!( - deny_banned_git_principal(&db, community, &member.public_key(), None) + deny_banned_git_principal(&db, community, &member.public_key(), None, None) .await .is_ok(), "precondition: an unbanned member passes the git ban gate" @@ -3663,7 +3674,7 @@ mod sec005_read_gate_tests { .expect("ban"); let (status, body) = denial_parts( - deny_banned_git_principal(&db, community, &member.public_key(), None).await, + deny_banned_git_principal(&db, community, &member.public_key(), None, None).await, ) .await; assert_eq!(status, StatusCode::FORBIDDEN); @@ -3686,9 +3697,15 @@ mod sec005_read_gate_tests { .expect("auth tag"); assert!( - deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)) - .await - .is_ok(), + deny_banned_git_principal( + &db, + community, + &agent.public_key(), + Some(&auth_tag), + Some(200), + ) + .await + .is_ok(), "precondition: neither agent nor owner is banned" ); @@ -3698,7 +3715,14 @@ mod sec005_read_gate_tests { .expect("ban owner"); let (status, _) = denial_parts( - deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)).await, + deny_banned_git_principal( + &db, + community, + &agent.public_key(), + Some(&auth_tag), + Some(200), + ) + .await, ) .await; assert_eq!( @@ -3710,7 +3734,7 @@ mod sec005_read_gate_tests { // An unattested request from the same agent key is unaffected: the // cascade must follow a verified owner, not punish every agent. assert!( - deny_banned_git_principal(&db, community, &agent.public_key(), None) + deny_banned_git_principal(&db, community, &agent.public_key(), None, None) .await .is_ok(), "without an attestation there is no owner to inherit from" @@ -3732,7 +3756,8 @@ mod sec005_read_gate_tests { let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()); let (status, body) = denial_parts( - deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None).await, + deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None, None) + .await, ) .await; assert_eq!( diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d09c7fc6119..e3d05165e0d 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -247,7 +247,11 @@ async fn authenticate( })?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_bridge_auth_with_options( headers, "POST", &url, diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 7a9f8816fed..780532ec5d0 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -208,12 +208,13 @@ impl FromRequestParts> for AuthenticatedUpload { // storage and of `require_auth_token` (which governs the REST API, not // media). On open relays (membership disabled) any valid Blossom signer // may upload, matching the WS door's admission policy. - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, + Some(auth_event.created_at.as_secs()), ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; @@ -534,12 +535,13 @@ async fn authenticate_media_read( let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, + Some(auth_event.created_at.as_secs()), ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 204ec360c3f..b7a0458f5a8 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -37,7 +37,10 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { /// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`, /// `git/transport.rs`, and `audio/handler.rs`. pub mod relay_members { - use axum::{http::StatusCode, response::Json}; + use axum::{ + http::{HeaderMap, StatusCode}, + response::Json, + }; use buzz_core::{tenant::CommunityId, TenantContext}; use tracing::{debug, info}; @@ -56,15 +59,30 @@ pub mod relay_members { Denied, } + /// Return the sole NIP-OA credential header, if one was supplied. + /// + /// Repeated security-sensitive headers are ambiguous across HTTP stacks, + /// so they are treated as no credential instead of silently selecting one. + pub fn extract_auth_tag_header(headers: &HeaderMap) -> Option<&str> { + let mut values = headers.get_all("x-auth-tag").iter(); + let (Some(value), None) = (values.next(), values.next()) else { + return None; + }; + value.to_str().ok() + } + /// Check relay membership without committing to an HTTP response shape. /// /// `community` is the server-resolved tenant of the request; membership is /// scoped to it so admitting a pubkey to community A never admits it to B. + /// A NIP-OA credential is usable only when `signed_auth_created_at` came + /// from the already-verified authentication event carrying that request. pub async fn check_relay_membership( state: &AppState, community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Result { if !state.config.require_relay_membership { return Ok(MembershipDecision::OpenRelay); @@ -84,8 +102,16 @@ pub mod relay_members { if let Some(tag_json) = auth_tag_header { let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes) .map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?; + let Some(auth_created_at) = signed_auth_created_at else { + info!(agent = %pubkey_hex, "NIP-OA auth tag has no verified signed auth timestamp"); + return Ok(MembershipDecision::Denied); + }; - match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event( + tag_json, + &agent_pubkey, + auth_created_at, + ) { Ok(owner_pubkey) => { let owner_hex = owner_pubkey.to_hex(); let owner_is_member = state @@ -128,8 +154,17 @@ pub mod relay_members { community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { - match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await { + match check_relay_membership( + state, + community, + pubkey_bytes, + auth_tag_header, + signed_auth_created_at, + ) + .await + { Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), Ok(MembershipDecision::Denied) => Err(( @@ -150,16 +185,22 @@ pub mod relay_members { /// /// Used on open relays (`require_relay_membership = false`) to opportunistically /// extract the owner pubkey for agent→owner backfill. The NIP-OA signature is - /// cryptographically self-proving, so no feature flag is needed — if the tag - /// verifies, the owner relationship is authentic. Returns `None` if the tag - /// is absent or invalid. + /// cryptographically self-proving, so no feature flag is needed. Temporal + /// conditions are evaluated against `signed_auth_created_at`. Returns + /// `None` if the tag, timestamp, or conditions are absent or invalid. pub fn extract_nip_oa_owner( pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Option { let tag_json = auth_tag_header?; + let auth_created_at = signed_auth_created_at?; let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?; - match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event( + tag_json, + &agent_pubkey, + auth_created_at, + ) { Ok(owner) => Some(owner), Err(e) => { info!("extract_nip_oa_owner: invalid auth tag: {e}"); @@ -236,9 +277,22 @@ pub mod relay_members { #[cfg(test)] mod tests { use super::*; + use axum::http::{HeaderMap, HeaderValue}; use buzz_sdk::nip_oa::compute_auth_tag; use nostr::Keys; + #[test] + fn auth_tag_header_must_be_unique() { + let mut headers = HeaderMap::new(); + assert_eq!(extract_auth_tag_header(&headers), None); + + headers.insert("x-auth-tag", HeaderValue::from_static("credential-one")); + assert_eq!(extract_auth_tag_header(&headers), Some("credential-one")); + + headers.append("x-auth-tag", HeaderValue::from_static("credential-two")); + assert_eq!(extract_auth_tag_header(&headers), None); + } + /// Valid NIP-OA auth tag → returns Some(owner_pubkey). #[test] fn valid_nip_oa_returns_owner() { @@ -249,18 +303,62 @@ pub mod relay_members { let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") .expect("compute_auth_tag must succeed"); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&tag_json)); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + Some(&tag_json), + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, Some(owner_keys.public_key())); } + #[test] + fn nip_oa_time_conditions_use_signed_auth_event_time() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + + let expired = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<200") + .expect("sign expired credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&expired), Some(200)), + None + ); + + let future = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at>200") + .expect("sign future credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&future), Some(200)), + None + ); + + let in_window = compute_auth_tag( + &owner_keys, + &agent_pubkey, + "kind=9&created_at>199&created_at<201", + ) + .expect("sign in-window credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), Some(200)), + Some(owner_keys.public_key()) + ); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), None), + None, + "a credential without a verified signed auth timestamp must fail closed" + ); + } + /// No auth tag → returns None. #[test] fn no_auth_tag_returns_none() { let agent_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), None); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + None, + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, None); } @@ -271,7 +369,11 @@ pub mod relay_members { let agent_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some("not valid json")); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + Some("not valid json"), + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, None); } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index f19ac17d4c1..b59fd840c6d 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -75,7 +75,11 @@ async fn authorize_operator_request( _ => path.to_string(), }; let url = format!("{origin}{path_with_query}"); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_bridge_auth_with_options( headers, method, &url, diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index a3d5a6c729e..c7fa09bebd0 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -62,20 +62,22 @@ async fn authorize_workflow_read( let path_with_query = request_path(path, raw_query); let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; bridge::enforce_http_admission(state, &tenant, &pubkey).await?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_created_at, ) .await?; diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index de8f1e14591..06d3a32b43d 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -220,6 +220,7 @@ async fn handle_active_audio_connection( // Extract NIP-OA auth tag before verify_auth_event consumes the event. let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); + let signed_auth_created_at = auth_msg.event.created_at.as_secs(); let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); let auth_ctx = match state @@ -251,6 +252,7 @@ async fn handle_active_audio_connection( tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) .await .is_err() diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 0ed1016ba8e..391c87ab7df 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -14,12 +14,13 @@ use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; use uuid::Uuid; -use buzz_auth::{generate_challenge, AuthContext, LimitType}; +use buzz_auth::{generate_challenge, AuthContext}; use buzz_core::tenant::TenantContext; use nostr::Filter; use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; +use crate::rejection::{enforce_ws_admission, request_rejection_message, RejectionTarget}; use crate::state::{ run_registered_community_connection, AppState, CommunityConnectionControl, CommunityDisconnectReason, @@ -670,7 +671,10 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let permit = match state.handler_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { - conn.send(RelayMessage::notice( + // Correlate to the event id: a bare NOTICE here strands the + // client's pending publish exactly as an over-quota one did. + conn.send(request_rejection_message( + RejectionTarget::Event(event.id), "rate-limited: too many concurrent requests", )); return; @@ -699,7 +703,7 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar Ok(p) => p, Err(_) => { conn.send(request_rejection_message( - Some(&sub_id), + RejectionTarget::Subscription(&sub_id), "rate-limited: too many concurrent requests", )); return; @@ -720,7 +724,8 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let permit = match state.handler_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { - conn.send(RelayMessage::notice( + conn.send(request_rejection_message( + RejectionTarget::Subscription(&sub_id), "rate-limited: too many concurrent requests", )); return; @@ -741,104 +746,142 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar } } -fn request_rejection_message(sub_id: Option<&str>, reason: &str) -> String { - match sub_id { - Some(sub_id) => RelayMessage::closed(sub_id, reason), - None => RelayMessage::notice(reason), - } -} +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + use buzz_auth::AuthMethod; + use nostr::{EventBuilder, Keys, Kind}; -async fn enforce_ws_admission( - msg: &ClientMessage, - conn: &ConnectionState, - state: &AppState, -) -> bool { - let is_event = matches!(msg, ClientMessage::Event(_)); - if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) { - return true; + /// A connection whose outbound frames a test can read back. + /// + /// Lives here, next to `ConnectionState`, so the crate has one place that + /// knows how to build one. Shared with `crate::rejection`'s tests. + pub(crate) fn test_conn_with_auth( + auth: AuthState, + ) -> (Arc, mpsc::Receiver) { + let (send_tx, send_rx) = mpsc::channel(4); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(4); + let conn = ConnectionState { + conn_id: Uuid::new_v4(), + tenant: TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), + auth_state: RwLock::new(auth), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }; + (Arc::new(conn), send_rx) } - let (pubkey, is_agent) = { - let auth = conn.auth_state.read().await; - match &*auth { - AuthState::Authenticated { ctx, .. } => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()), - _ => return true, + /// An authenticated connection — the only state admission quotas apply to. + pub(crate) fn authenticated_state() -> AuthState { + AuthState::Authenticated { + ctx: AuthContext { + pubkey: Keys::generate().public_key(), + scopes: Vec::new(), + channel_ids: None, + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + class: ConnectionClass::Interactive, } - }; - - let limits = &state.auth.config().rate_limits; - let (ws_window_secs, ws_limit) = - crate::admission::ws_admission_budget(limits.human_ws_events_per_sec); - let ws_result = crate::admission::check_principal( - state.admission_rate_limiter.as_ref(), - &conn.tenant, - &pubkey, - LimitType::WsEvents, - ws_window_secs, - ws_limit, - ) - .await; - let sub_id = match msg { - ClientMessage::Req { sub_id, .. } => Some(sub_id.as_str()), - _ => None, - }; - if !send_admission_result(conn, ws_result, sub_id) { - return false; } - if is_event { - let message_limit = if is_agent { - limits.agent_standard_messages_per_min - } else { - limits.human_messages_per_min - }; - let message_result = crate::admission::check_principal( - state.admission_rate_limiter.as_ref(), - &conn.tenant, - &pubkey, - LimitType::Messages, - 60, - message_limit, - ) - .await; - if !send_admission_result(conn, message_result, None) { - return false; + pub(crate) fn read_frame(rx: &mut mpsc::Receiver) -> serde_json::Value { + match rx.try_recv().expect("a frame was sent") { + WsMessage::Text(text) => serde_json::from_str(&text).expect("valid JSON frame"), + other => panic!("unexpected websocket message: {other:?}"), } } - true -} + /// Drives the real `handle_text_message` with every handler permit held, so + /// the EVENT saturation branch is reached through production dispatch rather + /// than by calling its helpers directly. + /// + /// This must go through `handle_text_message`: a test that renders the + /// rejection frame itself stays green when the call site inside the match + /// arm is reverted to a bare `NOTICE`. + #[tokio::test] + async fn saturated_handler_rejects_an_event_on_the_ok_channel() { + let state = crate::state::tests::test_state().await; + // An unauthenticated connection skips the admission quotas, so the + // semaphore is the only gate the frame can trip. + let (conn, mut rx) = test_conn_with_auth(AuthState::Failed); + + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); -fn send_admission_result( - conn: &ConnectionState, - result: Result<(), crate::admission::AdmissionError>, - sub_id: Option<&str>, -) -> bool { - match result { - Ok(()) => true, - Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1); - conn.send(request_rejection_message( - sub_id, - &format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), - )); - false - } - Err(crate::admission::AdmissionError::Unavailable) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1); - conn.send(request_rejection_message( - sub_id, - "rate-limited: shared admission unavailable", - )); - false - } + let event = EventBuilder::new(Kind::TextNote, "hello") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let event_id = event.id.to_hex(); + let raw = serde_json::json!(["EVENT", event]).to_string(); + + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + let frame = read_frame(&mut rx); + assert_eq!( + frame[0], "OK", + "an EVENT turned away for handler saturation must be rejected on the \ + OK channel — a NOTICE carries no event id, so the client's pending \ + publish cannot be settled and the send only times out" + ); + assert_eq!(frame[1], event_id); + assert_eq!(frame[2], false); + assert_eq!(frame[3], "rate-limited: too many concurrent requests"); } -} -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Arc, Mutex}; + /// The REQ arm of the same branch still settles on CLOSED. + #[tokio::test] + async fn saturated_handler_rejects_a_req_on_the_closed_channel() { + let state = crate::state::tests::test_state().await; + let (conn, mut rx) = test_conn_with_auth(AuthState::Failed); + + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); + + let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + let frame = read_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "history-abc"); + } + + /// COUNT refusals follow NIP-45 and close the named query. + #[tokio::test] + async fn saturated_handler_rejects_a_count_on_the_closed_channel() { + let state = crate::state::tests::test_state().await; + let (conn, mut rx) = test_conn_with_auth(AuthState::Failed); + + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); + + let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + let frame = read_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "count-abc"); + assert_eq!(frame[2], "rate-limited: too many concurrent requests"); + } #[derive(Debug, Default)] struct MockSinkState { @@ -933,19 +976,6 @@ mod tests { .collect() } - #[test] - fn req_rejections_are_subscription_scoped() { - let reason = "rate-limited: too many concurrent requests"; - let closed: serde_json::Value = - serde_json::from_str(&request_rejection_message(Some("history-123"), reason)) - .expect("parse CLOSED"); - assert_eq!(closed, serde_json::json!(["CLOSED", "history-123", reason])); - - let notice: serde_json::Value = - serde_json::from_str(&request_rejection_message(None, reason)).expect("parse NOTICE"); - assert_eq!(notice, serde_json::json!(["NOTICE", reason])); - } - #[tokio::test] async fn send_loop_batches_queued_data_frames_into_one_flush() { let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index f31a1f008e6..6bbfe84a660 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -113,6 +113,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // The tag is integrity-protected by the event's Schnorr signature — if // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); + let signed_auth_created_at = event.created_at.as_secs(); // Same reasoning for the connection class: read it here, but do not apply // it until the signature has verified. Requesting a class can only remove @@ -180,6 +181,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if let Some(owner) = crate::api::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) { outcome = match state .db @@ -262,6 +264,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: conn.tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) .await { @@ -289,6 +292,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: crate::api::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) } else { None diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 800433a8498..5feb3f5774b 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -4,6 +4,7 @@ mod admission; mod build_info; +mod rejection; /// REST API route handlers. pub mod api; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d9432589f46..d81602e2019 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -35,6 +35,18 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +async fn connect_audit_pool(config: &DbConfig) -> anyhow::Result { + let audit_config = DbConfig { + read_database_url: None, + max_connections: 5, + min_connections: 1, + ..config.clone() + }; + Db::connect_writer_pool(&audit_config) + .await + .map_err(Into::into) +} + fn relay_keypair_from_config(relay_private_key: Option<&str>) -> anyhow::Result { let hex = relay_private_key.ok_or_else(|| { anyhow::anyhow!( @@ -183,7 +195,8 @@ async fn main() -> anyhow::Result<()> { max_connections: config.db_pool_size, read_max_connections: config.db_read_pool_size, ..DbConfig::default() - }; + } + .with_session_timeouts_from_env(); let db = Db::new(&db_config).await.map_err(|e| { error!("Failed to connect to Postgres: {e}"); anyhow::anyhow!("DB connection failed: {e}") @@ -366,10 +379,7 @@ async fn main() -> anyhow::Result<()> { } let audit = if config.audit_enabled { - let audit_pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .min_connections(1) - .connect(&config.database_url) + let audit_pool = connect_audit_pool(&db_config) .await .map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?; info!("Audit service ready"); @@ -2052,10 +2062,11 @@ mod tests { use uuid::Uuid; use super::{ - buzz_auto_migrate_enabled, dropped_in_memory_keys, idle_timeout_secs, + buzz_auto_migrate_enabled, connect_audit_pool, dropped_in_memory_keys, idle_timeout_secs, refresh_legacy_active_gauge_recency, relay_keypair_from_config, run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; + use buzz_db::DbConfig; use metrics::GaugeFn; use metrics_util::{ debugging::DebugValue, @@ -2087,6 +2098,67 @@ mod tests { assert!(tick_count.load(std::sync::atomic::Ordering::Relaxed) <= 1); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits() { + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); + let pool = connect_audit_pool(&DbConfig { + database_url, + max_connections: 2, + min_connections: 0, + lock_timeout_ms: 500, + idle_txn_timeout_ms: 60_000, + statement_timeout_ms: 0, + ..DbConfig::default() + }) + .await + .expect("connect audit writer pool"); + + let (lock, idle, statement): (String, String, String) = sqlx::query_as( + "SELECT current_setting('lock_timeout'), \ + current_setting('idle_in_transaction_session_timeout'), \ + current_setting('statement_timeout')", + ) + .fetch_one(&pool) + .await + .expect("read effective audit writer GUCs"); + assert_eq!(lock, "500ms"); + assert_eq!(idle, "1min"); + assert_eq!(statement, "0"); + + let lock_key = i64::from_be_bytes( + Uuid::new_v4().as_bytes()[..8] + .try_into() + .expect("eight UUID bytes"), + ); + let mut holder = pool.acquire().await.expect("audit lock holder"); + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("hold audit advisory lock"); + + let started = std::time::Instant::now(); + let mut waiter = pool.acquire().await.expect("audit lock waiter"); + let error = sqlx::query("SELECT pg_advisory_lock($1)") + .bind(lock_key) + .execute(&mut *waiter) + .await + .expect_err("audit advisory-lock waiter must time out"); + let code = match &error { + sqlx::Error::Database(db_error) => db_error.code().map(|code| code.to_string()), + other => panic!("expected database error, got {other:?}"), + }; + assert_eq!(code.as_deref(), Some("55P03")); + assert!(started.elapsed() < Duration::from_secs(5)); + + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("release audit advisory lock"); + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/rejection.rs b/crates/buzz-relay/src/rejection.rs new file mode 100644 index 00000000000..ee926d45a25 --- /dev/null +++ b/crates/buzz-relay/src/rejection.rs @@ -0,0 +1,336 @@ +//! How a rejected client frame is addressed back to the client. +//! +//! NIP-01 gives every request type its own acknowledgement channel, and a +//! rejection is only actionable if it travels on the same one: a REQ or COUNT +//! refusal settles on `CLOSED`, an EVENT on `OK`. Rejecting an EVENT with a bare +//! `NOTICE` leaves a client that tracks pending publishes by event id with +//! nothing to key on, so the send cannot fail — it can only time out. + +use crate::admission::AdmissionError; +use crate::connection::{AuthState, ConnectionState}; +use crate::protocol::{ClientMessage, RelayMessage}; +use crate::state::AppState; +use buzz_auth::LimitType; + +/// What a rejected client frame is correlated back to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RejectionTarget<'a> { + /// A REQ or COUNT names the query it opened. + Subscription(&'a str), + /// An EVENT names the event it submitted. + Event(nostr::EventId), + /// No per-request correlation exists — connection-scoped notice. + Connection, +} + +/// Picks the acknowledgement channel a rejection of `msg` must travel on. +pub(crate) fn rejection_target_for(msg: &ClientMessage) -> RejectionTarget<'_> { + match msg { + ClientMessage::Req { sub_id, .. } | ClientMessage::Count { sub_id, .. } => { + RejectionTarget::Subscription(sub_id.as_str()) + } + ClientMessage::Event(event) => RejectionTarget::Event(event.id), + _ => RejectionTarget::Connection, + } +} + +/// Renders `reason` as the rejection frame `target`'s acknowledgement channel +/// expects. +pub(crate) fn request_rejection_message(target: RejectionTarget<'_>, reason: &str) -> String { + match target { + RejectionTarget::Subscription(sub_id) => RelayMessage::closed(sub_id, reason), + RejectionTarget::Event(event_id) => RelayMessage::ok(&event_id.to_hex(), false, reason), + RejectionTarget::Connection => RelayMessage::notice(reason), + } +} + +/// Applies the WebSocket admission quotas to `msg`, returning whether it may be +/// handled. A rejection is addressed to the frame's own acknowledgement channel. +pub(crate) async fn enforce_ws_admission( + msg: &ClientMessage, + conn: &ConnectionState, + state: &AppState, +) -> bool { + let is_event = matches!(msg, ClientMessage::Event(_)); + if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) { + return true; + } + + let (pubkey, is_agent) = { + let auth = conn.auth_state.read().await; + match &*auth { + AuthState::Authenticated { ctx, .. } => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()), + _ => return true, + } + }; + + let limits = &state.auth.config().rate_limits; + let (ws_window_secs, ws_limit) = + crate::admission::ws_admission_budget(limits.human_ws_events_per_sec); + let ws_result = crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + &conn.tenant, + &pubkey, + LimitType::WsEvents, + ws_window_secs, + ws_limit, + ) + .await; + if !send_admission_result(conn, ws_result, msg) { + return false; + } + + if is_event { + let message_limit = if is_agent { + limits.agent_standard_messages_per_min + } else { + limits.human_messages_per_min + }; + let message_result = crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + &conn.tenant, + &pubkey, + LimitType::Messages, + 60, + message_limit, + ) + .await; + // The per-minute message quota only applies to EVENTs, and its + // rejection must be as correlatable as the burst quota's. + if !send_admission_result(conn, message_result, msg) { + return false; + } + } + + true +} + +/// Forwards an admission verdict to the client, returning whether the frame was +/// admitted. +/// +/// The rejection target is derived from `msg` here rather than supplied by the +/// caller: every quota check in this module must address its rejection to the +/// rejected frame's own acknowledgement channel, so there is deliberately no way +/// for a call site to name a different one. +fn send_admission_result( + conn: &ConnectionState, + result: Result<(), AdmissionError>, + msg: &ClientMessage, +) -> bool { + let target = rejection_target_for(msg); + match result { + Ok(()) => true, + Err(AdmissionError::Exceeded { reset_in_secs }) => { + metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1); + conn.send(request_rejection_message( + target, + &format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), + )); + false + } + Err(AdmissionError::Unavailable) => { + metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1); + conn.send(request_rejection_message( + target, + "rate-limited: shared admission unavailable", + )); + false + } + } +} + +#[cfg(test)] +mod tests { + //! A rejected frame must be answerable on the acknowledgement channel the + //! client is actually waiting on. + //! + //! History: an over-quota EVENT used to be rejected with a bare + //! `["NOTICE", reason]`. A NOTICE carries no event id, and desktop/mobile + //! settle pending publishes only from an `OK` keyed by event id, so the + //! rejection was unaddressable: the send could not fail, it could only time + //! out (25s in Desktop, `PUBLISH_TIMEOUT_MS`) and surface as a message stuck + //! on "Sending…". Startup quota exhaustion made it routine in the first + //! seconds after launch. + //! + //! These tests drive the production rejection path — a real parsed + //! `ClientMessage` through `enforce_ws_admission` and + //! `send_admission_result` — and assert on the frame that reaches the + //! connection's outbound channel. + + use std::sync::Arc; + + use axum::extract::ws::Message as WsMessage; + use nostr::{EventBuilder, Keys, Kind}; + use tokio::sync::mpsc; + + use crate::connection::tests::{authenticated_state, read_frame, test_conn_with_auth}; + use crate::connection::AuthState; + + use super::*; + + fn sent_frame(rx: &mut mpsc::Receiver) -> serde_json::Value { + read_frame(rx) + } + + fn test_conn() -> (Arc, mpsc::Receiver) { + test_conn_with_auth(AuthState::Failed) + } + + /// Parses a real EVENT frame exactly as the recv loop does, so the test is + /// coupled to production parsing and not to a hand-built target. + fn parsed_event_message() -> (ClientMessage, String) { + let event = EventBuilder::new(Kind::TextNote, "hello") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let event_id = event.id.to_hex(); + let frame = serde_json::json!(["EVENT", event]).to_string(); + (ClientMessage::parse(&frame).expect("parse EVENT"), event_id) + } + + /// The regression: an over-quota EVENT must be rejected with + /// `OK(event_id, false, reason)` so the client can settle the exact pending + /// publish it belongs to. A NOTICE here reintroduces the 25s send stall. + #[test] + fn over_quota_event_is_rejected_with_a_correlated_ok() { + let (conn, mut rx) = test_conn(); + let (msg, event_id) = parsed_event_message(); + + let admitted = send_admission_result( + &conn, + Err(AdmissionError::Exceeded { reset_in_secs: 7 }), + &msg, + ); + + assert!(!admitted, "an over-quota frame is not admitted"); + let frame = sent_frame(&mut rx); + assert_eq!( + frame[0], "OK", + "an EVENT rejection must travel on the OK channel — a NOTICE cannot \ + be correlated to a pending publish, so the send hangs until the \ + client's publish timeout instead of failing" + ); + assert_eq!( + frame[1], event_id, + "the OK must name the rejected event id, which is what the client's \ + pending-publish map is keyed by" + ); + assert_eq!(frame[2], false, "and must be an explicit rejection"); + assert_eq!( + frame[3], "rate-limited: quota exceeded; retry in 7s", + "the retry hint must survive so the client can arm its gate" + ); + } + + /// The same correlation is required when admission is unavailable rather + /// than exceeded — both branches strand a send if they emit a NOTICE. + #[test] + fn event_rejected_for_unavailable_admission_is_also_correlated() { + let (conn, mut rx) = test_conn(); + let (msg, event_id) = parsed_event_message(); + + send_admission_result(&conn, Err(AdmissionError::Unavailable), &msg); + + let frame = sent_frame(&mut rx); + assert_eq!(frame[0], "OK"); + assert_eq!(frame[1], event_id); + assert_eq!(frame[2], false); + } + + /// A REQ still settles on CLOSED, which carries the subscription id. This + /// pins the pre-existing behavior the fix must not disturb. + #[test] + fn over_quota_req_still_closes_the_subscription() { + let (conn, mut rx) = test_conn(); + let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); + let msg = ClientMessage::parse(&raw).expect("parse REQ"); + + send_admission_result( + &conn, + Err(AdmissionError::Exceeded { reset_in_secs: 7 }), + &msg, + ); + + let frame = sent_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!( + frame[1], "history-abc", + "a REQ rejection must name the subscription it rejected" + ); + assert_eq!(frame[2], "rate-limited: quota exceeded; retry in 7s"); + } + + /// NIP-45 uses `CLOSED(query_id, reason)` when a relay refuses a COUNT. + #[test] + fn over_quota_count_closes_the_query() { + let (conn, mut rx) = test_conn(); + let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); + let msg = ClientMessage::parse(&raw).expect("parse COUNT"); + + send_admission_result( + &conn, + Err(AdmissionError::Exceeded { reset_in_secs: 7 }), + &msg, + ); + + let frame = sent_frame(&mut rx); + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "count-abc"); + assert_eq!(frame[2], "rate-limited: quota exceeded; retry in 7s"); + } + + /// Drives the real entry point `handle_text_message` calls, so the wiring + /// between `enforce_ws_admission` and the target choice is under test and + /// not just the leaf renderer. + /// + /// The state's Redis is deliberately unreachable, which makes admission + /// return `Unavailable` — a production rejection path that needs no live + /// quota burst to reach. + async fn enforce_against_unreachable_admission(raw: &str) -> serde_json::Value { + let state = crate::state::tests::test_state().await; + let (conn, mut rx) = test_conn_with_auth(authenticated_state()); + let msg = ClientMessage::parse(raw).expect("parse client frame"); + + let admitted = enforce_ws_admission(&msg, &conn, &state).await; + assert!(!admitted, "an unadmitted frame must not be handled"); + sent_frame(&mut rx) + } + + #[tokio::test] + async fn enforce_ws_admission_rejects_an_event_on_the_ok_channel() { + let event = EventBuilder::new(Kind::TextNote, "hello") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let event_id = event.id.to_hex(); + let raw = serde_json::json!(["EVENT", event]).to_string(); + + let frame = enforce_against_unreachable_admission(&raw).await; + + assert_eq!( + frame[0], "OK", + "the admission gate must reject an EVENT on the channel the client's \ + pending publish is keyed by, or the send can only time out" + ); + assert_eq!(frame[1], event_id); + assert_eq!(frame[2], false); + } + + #[tokio::test] + async fn enforce_ws_admission_rejects_a_count_on_the_closed_channel() { + let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); + + let frame = enforce_against_unreachable_admission(&raw).await; + + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "count-abc"); + } + + #[tokio::test] + async fn enforce_ws_admission_rejects_a_req_on_the_closed_channel() { + let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); + + let frame = enforce_against_unreachable_admission(&raw).await; + + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], "history-abc"); + } +} diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index fffc587b603..3c4214694d2 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1435,11 +1435,33 @@ impl AuditShutdownHandle { /// and the post-cancel drain share the same logic. async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::NewAuditEntry) { let t = std::time::Instant::now(); - if let Err(e) = audit.log(entry).await { - metrics::counter!("buzz_audit_log_errors_total").increment(1); - tracing::error!("Audit log failed: {e}"); - } else { - metrics::histogram!("buzz_audit_log_seconds").record(t.elapsed().as_secs_f64()); + let mut retry_delay_ms = 50u64; + let mut retries = 0u64; + loop { + match audit.log(entry.clone()).await { + Ok(_) => { + metrics::histogram!("buzz_audit_log_seconds").record(t.elapsed().as_secs_f64()); + return; + } + Err(buzz_audit::AuditError::Database(sqlx::Error::Database(database_error))) + if database_error.code().as_deref() == Some("55P03") => + { + retries += 1; + metrics::counter!("buzz_audit_log_lock_retries_total").increment(1); + tracing::warn!( + retries, + retry_delay_ms, + "Audit advisory lock timed out; preserving entry for retry" + ); + tokio::time::sleep(std::time::Duration::from_millis(retry_delay_ms)).await; + retry_delay_ms = (retry_delay_ms * 2).min(1_000); + } + Err(error) => { + metrics::counter!("buzz_audit_log_errors_total").increment(1); + tracing::error!("Audit log failed: {error}"); + return; + } + } } } @@ -1453,7 +1475,7 @@ impl std::fmt::Debug for AppState { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::connection::{AuthState, ConnectionState}; use std::collections::HashMap; @@ -1492,7 +1514,10 @@ mod tests { (mgr, conn_id, rx, ctrl_rx, cancel, bp) } - async fn test_state() -> Arc { + /// A relay state whose Redis is deliberately unreachable, so admission + /// checks resolve to `AdmissionError::Unavailable` without any live + /// infrastructure. Shared with `crate::rejection`'s tests. + pub(crate) async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); @@ -1529,6 +1554,131 @@ mod tests { Arc::new(state) } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); + let observer = sqlx::PgPool::connect(&database_url) + .await + .expect("connect observer pool"); + let application_name = format!("audit-retry-test-{}", Uuid::new_v4()); + let hook_application_name = application_name.clone(); + let audit_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(move |conn, _meta| { + let application_name = hook_application_name.clone(); + Box::pin(async move { + sqlx::query( + "SELECT set_config('application_name', $1, false), \ + set_config('lock_timeout', '100', false)", + ) + .bind(application_name) + .execute(&mut *conn) + .await?; + Ok(()) + }) + }) + .connect(&database_url) + .await + .expect("connect audit pool"); + + let community_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("audit-retry-{community_id}.example")) + .execute(&observer) + .await + .expect("insert test community"); + let object_id = format!("audit-retry-object-{}", Uuid::new_v4()); + let entry = buzz_audit::NewAuditEntry { + community_id: CommunityId::from_uuid(community_id), + action: buzz_audit::AuditAction::EventCreated, + actor_pubkey: Some(vec![0xab; 32]), + object_id: Some(object_id.clone()), + detail: serde_json::json!({"test": "lock-timeout-retry"}), + }; + + // Mirrors buzz_audit::service::AUDIT_LOCK_NAMESPACE. + let lock_key = format!("buzz_audit:{community_id}"); + let mut holder = observer.acquire().await.expect("acquire lock holder"); + sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + .expect("hold community audit lock"); + + let audit = Arc::new(AuditService::new(audit_pool)); + let worker = tokio::spawn({ + let audit = Arc::clone(&audit); + async move { log_audit_entry(&audit, entry).await } + }); + + // Observe one timed-out advisory-lock attempt and then a second wait. + // Releasing during the first wait would not prove that the worker + // preserved and retried the original queue entry. + tokio::time::timeout(std::time::Duration::from_secs(3), async { + let mut saw_first_wait = false; + let mut saw_retry_gap = false; + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM pg_stat_activity \ + WHERE application_name = $1 \ + AND query LIKE 'SELECT pg_advisory_lock%' \ + AND wait_event = 'advisory'\ + )", + ) + .bind(&application_name) + .fetch_one(&observer) + .await + .expect("inspect audit lock waiter"); + if waiting { + if saw_retry_gap { + break; + } + saw_first_wait = true; + } else if saw_first_wait { + saw_retry_gap = true; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("audit worker never retried after lock_timeout"); + + sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + .expect("release community audit lock"); + tokio::time::timeout(std::time::Duration::from_secs(3), worker) + .await + .expect("audit worker did not finish after lock release") + .expect("audit worker task panicked"); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM audit_log WHERE community_id = $1 AND object_id = $2", + ) + .bind(community_id) + .bind(&object_id) + .fetch_one(&observer) + .await + .expect("count retried audit rows"); + assert_eq!(rows, 1, "the preserved entry must be appended exactly once"); + + sqlx::query("DELETE FROM audit_log WHERE community_id = $1 AND object_id = $2") + .bind(community_id) + .bind(&object_id) + .execute(&observer) + .await + .expect("remove test audit row"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&observer) + .await + .expect("remove test community"); + } + #[test] fn send_to_resets_grace_counter_on_success() { let (mgr, id, _rx, _ctrl_rx, _cancel, bp) = setup_conn(16); diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 8ce23a2e8ea..4ceb3b39308 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -148,6 +148,39 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec, + rendered_text: &str, + authored_text: &str, + members: &[(String, String)], + author_pubkey_hex: &str, +) -> Result<(), ActionSinkError> { + let rendered_mentions = resolve_mention_pubkeys(rendered_text, members); + let authored_mentions: std::collections::HashSet = + resolve_mention_pubkeys(authored_text, members) + .into_iter() + .collect(); + + for mentioned in rendered_mentions { + if mentioned != author_pubkey_hex { + tags.push( + Tag::parse(["p", &mentioned]) + .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, + ); + } + if authored_mentions.contains(&mentioned) { + tags.push( + Tag::parse(["buzz:workflow-mention", &mentioned]).map_err(|e| { + ActionSinkError::EventBuild(format!("workflow mention tag: {e}")) + })?, + ); + } + } + Ok(()) +} + /// Relay-side action sink — executes workflow side-effects directly. /// /// Holds a **weak** reference to `AppState` to avoid an `Arc` reference cycle: @@ -175,11 +208,13 @@ impl ActionSink for RelayActionSink { community_id: CommunityId, channel_id: &str, text: &str, + authored_text: &str, author_pubkey: &str, reply_to: Option<&str>, ) -> Pin> + Send + '_>> { let channel_id = channel_id.to_owned(); let text = text.to_owned(); + let authored_text = authored_text.to_owned(); let author_pubkey = author_pubkey.to_owned(); let reply_to = reply_to.map(str::to_owned); @@ -257,8 +292,14 @@ impl ActionSink for RelayActionSink { // - `p` tag attributes the message to the workflow owner // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering - // - one `p` tag per `@Name` that resolves to a channel member, - // so mentioned agents are woken (wake is `p`-tag gated) + // - `buzz:workflow-owner` lets harnesses apply the owner's + // inbound-author policy after verifying the relay signature + // - one `p` tag for every resolved mention in the rendered output, + // preserving legacy wake/feed behavior + // - one `buzz:workflow-mention` tag only when the same target was + // named in the workflow owner's stored step template. This is the + // authority-bearing provenance used by ACP; trigger-controlled + // template substitutions cannot create it. let mut tags = vec![ Tag::parse(["p", &author_pubkey_hex]) .map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?, @@ -266,6 +307,8 @@ impl ActionSink for RelayActionSink { .map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?, Tag::parse(["buzz:workflow", "true"]) .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, + Tag::parse(["buzz:workflow-owner", &author_pubkey_hex]) + .map_err(|e| ActionSinkError::EventBuild(format!("workflow owner tag: {e}")))?, ]; // Resolve thread ancestry when this is a threaded reply, so the @@ -312,10 +355,13 @@ impl ActionSink for RelayActionSink { } } - // Resolve `@Name` mentions to channel-member pubkeys and append a - // `p` tag for each (skipping the author, already tagged above). A - // resolution failure must not drop the message, so log and proceed - // with the base tags. + // Resolve `@Name` mentions to channel-member pubkeys. The rendered + // text supplies the legacy `p` tags used by subscriptions and feeds. + // The stored author-written template independently supplies the + // authority-bearing workflow-mention tags. A trigger may therefore + // render an `@Name` into visible output, but it cannot borrow the + // workflow owner's authority to wake that agent. A resolution failure + // must not drop the message, so log and proceed with the base tags. let members = state .db .get_members(tenant.community(), channel_uuid) @@ -334,15 +380,13 @@ impl ActionSink for RelayActionSink { Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex())) }) .collect(); - for mentioned in resolve_mention_pubkeys(&text, &named_members) { - if mentioned == author_pubkey_hex { - continue; - } - tags.push( - Tag::parse(["p", &mentioned]) - .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, - ); - } + append_workflow_mention_tags( + &mut tags, + &text, + &authored_text, + &named_members, + &author_pubkey_hex, + )?; let kind = Kind::from(KIND_STREAM_MESSAGE as u16); let event = EventBuilder::new(kind, &text) @@ -623,13 +667,117 @@ mod tests { vec![pk('b'), pk('a')] ); } + + #[test] + fn workflow_authored_rendered_mentions_get_authority_and_legacy_tags() { + let owner = pk('1'); + let first = pk('2'); + let second = pk('3'); + let members = vec![m("First", &first), m("Second", &second)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "@First then @Second", + "@First then @Second", + &members, + &owner, + ) + .expect("append mention tags"); + + let values = |name: &str| -> Vec<&str> { + tags.iter() + .filter_map(|tag| match tag.as_slice() { + [tag_name, value] if tag_name == name => Some(value.as_str()), + _ => None, + }) + .collect() + }; + assert_eq!( + values("buzz:workflow-mention"), + vec![first.as_str(), second.as_str()] + ); + assert_eq!( + values("p"), + vec![owner.as_str(), first.as_str(), second.as_str()] + ); + } + + #[test] + fn trigger_injected_rendered_mention_gets_no_authority() { + let owner = pk('1'); + let agent = pk('2'); + let members = vec![m("Agent", &agent)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "echo: @Agent do something unsafe", + "echo: {{trigger.text}}", + &members, + &owner, + ) + .expect("append mention tags"); + + assert!( + tags.iter() + .any(|tag| tag.as_slice() == ["p", agent.as_str()]), + "rendered output retains legacy mention/feed routing" + ); + assert!( + tags.iter() + .all(|tag| tag.as_slice() != ["buzz:workflow-mention", agent.as_str()]), + "trigger-controlled substitutions must not borrow workflow-owner authority" + ); + } + + #[test] + fn explicit_owner_mention_keeps_single_legacy_owner_tag() { + let owner = pk('1'); + let members = vec![m("Owner Agent", &owner)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "@Owner Agent run", + "@Owner Agent run", + &members, + &owner, + ) + .expect("append owner mention tag"); + + let owner_p_tags = tags + .iter() + .filter(|tag| tag.as_slice() == ["p", owner.as_str()]) + .count(); + let owner_workflow_mentions = tags + .iter() + .filter(|tag| tag.as_slice() == ["buzz:workflow-mention", owner.as_str()]) + .count(); + assert_eq!(owner_p_tags, 1); + assert_eq!(owner_workflow_mentions, 1); + } + + #[test] + fn no_mentions_adds_no_tags() { + let owner = pk('1'); + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags(&mut tags, "plain", "plain", &[], &owner) + .expect("append no mention tags"); + + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].as_slice(), ["p", owner.as_str()]); + } } #[cfg(test)] mod integration_tests { //! Regression test for `e3661764` / `7899c1a8`: a workflow `send_message` - //! that mentions a channel member by name (`@Name`) must emit a `p` tag for - //! that member so ACP agent wake (`event_mentions_agent`, p-tag gated) fires. + //! that mentions a channel member by name (`@Name`) in its author-written + //! step template must emit both the legacy `p` tag and authenticated + //! workflow-mention provenance for that member. Rendered trigger data may + //! still create a legacy `p` tag, but never authority-bearing provenance. //! //! Postgres-gated like the other DB-backed relay tests. Run with: //! `cargo test -p buzz-relay --lib workflow_sink -- --ignored` @@ -676,9 +824,79 @@ mod integration_tests { Arc::new(state) } + async fn execute_send_message_workflow( + state: &Arc, + community: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + name: &str, + authored_text: &str, + trigger_text: &str, + ) -> String { + let definition = serde_json::json!({ + "name": name, + "trigger": {"on": "message_posted"}, + "steps": [{ + "id": "send", + "action": "send_message", + "text": authored_text, + }], + "enabled": true, + }); + let definition_hash_byte = name.as_bytes().first().copied().unwrap_or_default(); + let workflow_id = state + .db + .create_workflow( + community, + Some(channel_id), + owner_pubkey, + name, + &definition.to_string(), + &[definition_hash_byte; 32], + ) + .await + .expect("create workflow"); + let trigger_ctx = buzz_workflow::executor::TriggerContext { + text: trigger_text.to_owned(), + channel_id: channel_id.to_string(), + ..Default::default() + }; + let trigger_ctx_json = serde_json::to_value(&trigger_ctx).expect("serialize trigger"); + let run_id = state + .db + .create_workflow_run(community, workflow_id, None, Some(&trigger_ctx_json)) + .await + .expect("create workflow run"); + + // Load the definition back from Postgres before execution. This pins the + // authority source to the durable owner-authored template rather than a + // second test-only string passed directly to RelayActionSink. + let stored_workflow = state + .db + .get_workflow(community, workflow_id) + .await + .expect("load stored workflow"); + let stored_definition: buzz_workflow::WorkflowDef = + serde_json::from_value(stored_workflow.definition).expect("parse stored definition"); + let result = buzz_workflow::executor::execute_run( + &state.workflow_engine, + community, + run_id, + &stored_definition, + &trigger_ctx, + ) + .await + .expect("execute workflow"); + + result.step_outputs["send"]["event_id"] + .as_str() + .expect("send_message event id") + .to_owned() + } + #[tokio::test] #[ignore = "requires Postgres"] - async fn workflow_send_message_p_tags_mentioned_member() { + async fn workflow_send_message_binds_authority_to_authored_mentions() { let state = test_state().await; let author = nostr::Keys::generate(); @@ -699,6 +917,12 @@ mod integration_tests { }; // Open channel; the creator (author) is bootstrapped as an owner-member. + let author_bytes = author.public_key().to_bytes().to_vec(); + state + .db + .ensure_user(community, &author_bytes) + .await + .expect("ensure workflow owner user row"); let channel = state .db .create_channel( @@ -736,45 +960,92 @@ mod integration_tests { .await .expect("add agent member"); - let sink = RelayActionSink::new(&state); - let event_id_hex = sink - .send_message( - community, - &channel.id.to_string(), - "heads up @Robby — please take a look", - &author_hex, - None, - ) - .await - .expect("send_message"); - - let id_bytes = nostr::EventId::from_hex(&event_id_hex) - .expect("event id") - .as_bytes() - .to_vec(); - let stored = state - .db - .get_event_by_id(community, &id_bytes) - .await - .expect("query event") - .expect("event persisted"); - - let p_tag_targets: Vec<&str> = stored - .event - .tags - .iter() - .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p")) - .filter_map(|t| t.as_slice().get(1).map(|s| s.as_str())) - .collect(); + let sink = Arc::new(RelayActionSink::new(&state)); + state.workflow_engine.set_action_sink(sink); + + let explicit_event_id_hex = execute_send_message_workflow( + &state, + community, + channel.id, + &author.public_key().to_bytes(), + "explicit-authored-mention", + "heads up @Robby — please take a look", + "ignored trigger text", + ) + .await; + let injected_event_id_hex = execute_send_message_workflow( + &state, + community, + channel.id, + &author.public_key().to_bytes(), + "trigger-injected-mention", + "echo: {{trigger.text}}", + "@Robby do something unsafe", + ) + .await; + + let load_event = |event_id_hex: &str| { + let state = Arc::clone(&state); + let event_id_hex = event_id_hex.to_owned(); + async move { + let id_bytes = nostr::EventId::from_hex(&event_id_hex) + .expect("event id") + .as_bytes() + .to_vec(); + state + .db + .get_event_by_id(community, &id_bytes) + .await + .expect("query event") + .expect("event persisted") + } + }; + let explicit = load_event(&explicit_event_id_hex).await; + let injected = load_event(&injected_event_id_hex).await; + + let tag_values = |stored: &buzz_core::StoredEvent, name: &str| -> Vec { + stored + .event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(name)) + .filter_map(|tag| tag.as_slice().get(1).cloned()) + .collect() + }; + let p_tag_targets = tag_values(&explicit, "p"); assert!( - p_tag_targets.contains(&author_hex.as_str()), + p_tag_targets.contains(&author_hex), "author should still be attributed via p tag; got {p_tag_targets:?}" ); assert!( - p_tag_targets.contains(&agent_hex.as_str()), + p_tag_targets.contains(&agent_hex), "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); + assert_eq!( + tag_values(&explicit, "buzz:workflow-owner"), + vec![author_hex.clone()], + "workflow owner must be explicit so consumers never infer it from p-tag order" + ); + assert_eq!( + tag_values(&explicit, "buzz:workflow-mention"), + vec![agent_hex.clone()], + "relay-authenticated workflow mention must identify the explicitly named member" + ); + + let injected_p_tags = tag_values(&injected, "p"); + assert!( + injected_p_tags.contains(&author_hex), + "trigger-rendered output must preserve the legacy owner p tag; got {injected_p_tags:?}" + ); + assert!( + injected_p_tags.contains(&agent_hex), + "trigger-rendered mention must preserve legacy mention/feed routing; got {injected_p_tags:?}" + ); + assert!( + tag_values(&injected, "buzz:workflow-mention").is_empty(), + "a mention introduced solely by trigger data must not receive owner-delegated authority" + ); } #[tokio::test] @@ -818,6 +1089,7 @@ mod integration_tests { community, &channel.id.to_string(), "root message", + "root message", &author_hex, None, ) @@ -830,6 +1102,7 @@ mod integration_tests { community, &channel.id.to_string(), "threaded reply", + "threaded reply", &author_hex, Some(&root_hex), ) @@ -972,6 +1245,7 @@ mod integration_tests { community, &channel_hex, "workflow reply", + "workflow reply", &author_hex, Some(&parent_hex), ) @@ -1053,6 +1327,7 @@ mod integration_tests { community, &channel_hex, "workflow reply to root-only parent", + "workflow reply to root-only parent", &author_hex, Some(&root_only_parent_hex), ) @@ -1115,6 +1390,7 @@ mod integration_tests { community, &channel.id.to_string(), "orphan reply", + "orphan reply", &author_hex, Some(&unknown), ) diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 2dff81bcf7a..f8a994bd0c5 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -165,22 +165,18 @@ pub fn compute_auth_tag( Ok(tag_json.to_string()) } -/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`. -/// -/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature -/// against the owner pubkey embedded in the tag. -/// -/// Returns the owner's [`PublicKey`] on success. -/// -/// # Errors -/// -/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count, -/// bad hex, self-attestation, or signature verification failure. -pub fn verify_auth_tag( - auth_tag_json: &str, - agent_pubkey: &PublicKey, -) -> Result { - let arr = parse_json_array(auth_tag_json)?; +struct ParsedAuthTag { + owner_pubkey_hex: String, + conditions: String, + sig_hex: String, +} + +/// Parse and validate the canonical wire representation shared by every +/// verification path. Keeping this check in one place prevents the crypto +/// verifier from accepting non-canonical values that the structural parser +/// rejects. +fn parse_auth_tag_fields(json_str: &str) -> Result { + let arr = parse_json_array(json_str)?; if arr.len() != 4 { return Err(SdkError::InvalidInput(format!( @@ -201,17 +197,41 @@ pub fn verify_auth_tag( let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) })?; + if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "owner pubkey must be 64 lowercase hex chars, got {:?}", + owner_pubkey_hex + ))); + } + let conditions = arr[2] .as_str() .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; + validate_conditions(conditions)?; + let sig_hex = arr[3] .as_str() .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; + if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "signature must be 128 lowercase hex chars, got length {}", + sig_hex.len() + ))); + } - let owner_pubkey = PublicKey::from_hex(owner_pubkey_hex) - .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; + Ok(ParsedAuthTag { + owner_pubkey_hex: owner_pubkey_hex.to_owned(), + conditions: conditions.to_owned(), + sig_hex: sig_hex.to_owned(), + }) +} - validate_conditions(conditions)?; +fn verify_parsed_auth_tag( + parsed: &ParsedAuthTag, + agent_pubkey: &PublicKey, +) -> Result { + let owner_pubkey = PublicKey::from_hex(&parsed.owner_pubkey_hex) + .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; if owner_pubkey == *agent_pubkey { return Err(SdkError::InvalidInput( @@ -219,10 +239,9 @@ pub fn verify_auth_tag( )); } - let sig = Signature::from_str(sig_hex) + let sig = Signature::from_str(&parsed.sig_hex) .map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?; - - let preimage = build_preimage(agent_pubkey, conditions); + let preimage = build_preimage(agent_pubkey, &parsed.conditions); let message = hash_preimage(&preimage); let xonly = owner_pubkey.xonly().map_err(|e| { @@ -235,6 +254,71 @@ pub fn verify_auth_tag( Ok(owner_pubkey) } +/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`. +/// +/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature +/// against the owner pubkey embedded in the tag. +/// +/// Returns the owner's [`PublicKey`] on success. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count, +/// bad hex, self-attestation, or signature verification failure. +pub fn verify_auth_tag( + auth_tag_json: &str, + agent_pubkey: &PublicKey, +) -> Result { + let parsed = parse_auth_tag_fields(auth_tag_json)?; + verify_parsed_auth_tag(&parsed, agent_pubkey) +} + +/// Verify a NIP-OA credential for relay admission at a signed auth event. +/// +/// This performs the normal signature and syntax checks, then evaluates every +/// `created_at<` and `created_at>` clause against the signed NIP-42, NIP-98, or +/// equivalent authentication event's `created_at`. Both operators are strict: +/// equality does not satisfy either clause. `kind=` clauses are deliberately +/// not evaluated at connection admission, matching NIP-AA's connection-wide +/// credential semantics. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] when the credential is invalid or the +/// signed authentication event does not satisfy a time condition. +pub fn verify_auth_tag_for_auth_event( + auth_tag_json: &str, + agent_pubkey: &PublicKey, + auth_event_created_at: u64, +) -> Result { + let parsed = parse_auth_tag_fields(auth_tag_json)?; + let owner_pubkey = verify_parsed_auth_tag(&parsed, agent_pubkey)?; + + for clause in parsed.conditions.split('&') { + let satisfied = if let Some(value) = clause.strip_prefix("created_at<") { + let bound = value + .parse::() + .map_err(|e| SdkError::InvalidInput(format!("invalid created_at< bound: {e}")))?; + auth_event_created_at < bound + } else if let Some(value) = clause.strip_prefix("created_at>") { + let bound = value + .parse::() + .map_err(|e| SdkError::InvalidInput(format!("invalid created_at> bound: {e}")))?; + auth_event_created_at > bound + } else { + continue; + }; + + if !satisfied { + return Err(SdkError::InvalidInput(format!( + "auth event created_at {auth_event_created_at} does not satisfy {clause}" + ))); + } + } + + Ok(owner_pubkey) +} + /// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the /// signature. /// @@ -250,52 +334,14 @@ pub fn verify_auth_tag( /// /// Returns [`SdkError::InvalidInput`] for any structural violation. pub fn parse_auth_tag(json_str: &str) -> Result { - let arr = parse_json_array(json_str)?; - - if arr.len() != 4 { - return Err(SdkError::InvalidInput(format!( - "auth tag must have 4 elements, got {}", - arr.len() - ))); - } - - let label = arr[0] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?; - if label != "auth" { - return Err(SdkError::InvalidInput(format!( - "first element must be \"auth\", got \"{label}\"" - ))); - } - - let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { - SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) - })?; - if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) { - return Err(SdkError::InvalidInput(format!( - "owner pubkey must be 64 hex chars, got {:?}", - owner_pubkey_hex - ))); - } - - let conditions = arr[2] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; - - validate_conditions(conditions)?; - - let sig_hex = arr[3] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; - if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) { - return Err(SdkError::InvalidInput(format!( - "signature must be 128 hex chars, got length {}", - sig_hex.len() - ))); - } - - Tag::parse(["auth", owner_pubkey_hex, conditions, sig_hex]) - .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) + let parsed = parse_auth_tag_fields(json_str)?; + Tag::parse([ + "auth", + &parsed.owner_pubkey_hex, + &parsed.conditions, + &parsed.sig_hex, + ]) + .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) } #[cfg(test)] @@ -412,6 +458,32 @@ mod tests { assert!(verify_auth_tag(&wrong_sig, &agent_pubkey).is_err()); } + #[test] + fn test_verify_rejects_noncanonical_hex() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("compute_auth_tag must succeed"); + let mut tag: Value = serde_json::from_str(&tag_json).expect("auth tag is valid JSON"); + + tag[1] = Value::String(owner_keys.public_key().to_hex().to_uppercase()); + assert!( + verify_auth_tag(&tag.to_string(), &agent_pubkey).is_err(), + "uppercase owner pubkeys must not reach the permissive hex decoder" + ); + + let mut tag: Value = serde_json::from_str(&tag_json).expect("auth tag is valid JSON"); + let uppercase_sig = tag[3] + .as_str() + .expect("signature is a string") + .to_uppercase(); + tag[3] = Value::String(uppercase_sig); + assert!( + verify_auth_tag(&tag.to_string(), &agent_pubkey).is_err(), + "uppercase signatures must not reach the permissive hex decoder" + ); + } + /// parse_auth_tag with a well-formed JSON array returns a Tag. #[test] fn test_parse_auth_tag_valid() { @@ -586,6 +658,40 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn auth_event_time_conditions_are_enforced_strictly() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + + let expired = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<1") + .expect("sign expired credential"); + assert!(verify_auth_tag_for_auth_event(&expired, &agent_pubkey, 200).is_err()); + + let not_yet_valid = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at>200") + .expect("sign future credential"); + assert!(verify_auth_tag_for_auth_event(¬_yet_valid, &agent_pubkey, 200).is_err()); + + let failed_second_clause = + compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<201&created_at<200") + .expect("sign credential with two upper bounds"); + assert!( + verify_auth_tag_for_auth_event(&failed_second_clause, &agent_pubkey, 200).is_err(), + "every clause must pass, even when an earlier clause succeeds" + ); + + let in_window = compute_auth_tag( + &owner_keys, + &agent_pubkey, + "kind=9&created_at>199&created_at<201", + ) + .expect("sign in-window credential"); + assert_eq!( + verify_auth_tag_for_auth_event(&in_window, &agent_pubkey, 200) + .expect("in-window credential passes"), + owner_keys.public_key() + ); + } + #[test] fn test_parse_rejects_invalid_conditions() { let bad = diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 079c27a913d..b8c7f4dd809 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -54,7 +54,10 @@ pub trait ActionSink: Send + Sync { /// carries its owning community so a workflow in community B posts into B /// even though the side effect has no inbound connection to bind. /// - `channel_id`: UUID string of the target channel - /// - `text`: message body (must not be empty/whitespace-only) + /// - `text`: rendered message body (must not be empty/whitespace-only) + /// - `authored_text`: the workflow owner's stored, unrendered step template; + /// consumers must use this rather than trigger-controlled rendered output + /// when attaching authority-bearing metadata /// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for /// the `p` attribution tag; the relay keypair signs the event) /// - `reply_to`: when `Some(event_id_hex)`, the message is posted as a @@ -67,6 +70,7 @@ pub trait ActionSink: Send + Sync { community_id: CommunityId, channel_id: &str, text: &str, + authored_text: &str, author_pubkey: &str, reply_to: Option<&str>, ) -> Pin> + Send + '_>>; diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index 5c712dcff7c..90a6a02e020 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -535,7 +535,7 @@ fn resolve_send_message_channel( /// `RequestApproval` returns `StepResult::Suspended` — the caller must /// persist state and stop the execution loop. pub async fn dispatch_action( - step_id: &str, + step: &Step, action: &ActionDef, engine: &WorkflowEngine, community_id: CommunityId, @@ -544,6 +544,8 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; + let step_id = &step.id; + // The workflow engine can outlive the serving request that spawned it. // Revalidate the durable community fence immediately before every external // side effect (message publish, webhook, delay/resume). A storage failure is @@ -622,12 +624,22 @@ pub async fn dispatch_action( "SendMessage → {channel_id}: {text}" ); + let authored_text = match &step.action { + SendMessage { text, .. } => text.as_str(), + _ => { + return Err(WorkflowError::InvalidDefinition( + "SendMessage: resolved action does not match its authored step" + .into(), + )); + } + }; let event_id = engine .action_sink()? .send_message( community_id, &channel_id, text, + authored_text, &owner_pubkey_hex, reply_to, ) @@ -1220,7 +1232,7 @@ async fn execute_steps( let dispatch_result = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), dispatch_action( - &step.id, + step, &resolved_action, engine, community_id, diff --git a/desktop/package.json b/desktop/package.json index 1e93fd76a85..14db248a134 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc && node ./scripts/build-protected-feature-artifacts.mjs", "build:e2e": "tsc && vite build --mode e2e", "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", @@ -16,13 +16,13 @@ "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", "preview": "vite preview", - "tauri": "tauri", + "tauri": "node ./scripts/tauri-command.mjs", "test:e2e": "pnpm build:e2e && playwright test", "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", "test:e2e:release-smoke": "pnpm build:e2e && playwright test --config=playwright.release-smoke.config.ts", "test:e2e:report": "playwright show-report", - "tauri:build": "tauri build" + "tauri:build": "node ./scripts/tauri-command.mjs build" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/desktop/scripts/build-protected-feature-artifacts.mjs b/desktop/scripts/build-protected-feature-artifacts.mjs new file mode 100644 index 00000000000..3de4830ceeb --- /dev/null +++ b/desktop/scripts/build-protected-feature-artifacts.mjs @@ -0,0 +1,151 @@ +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const vitePackageJsonPath = fileURLToPath( + import.meta.resolve("vite/package.json"), +); +const vitePackage = JSON.parse(readFileSync(vitePackageJsonPath, "utf8")); +const viteEntrypoint = path.resolve( + path.dirname(vitePackageJsonPath), + vitePackage.bin.vite, +); + +function buildVariant({ internal, output }) { + const env = { + ...process.env, + // Pin both children explicitly. Deleting the OSS value lets Vite reload + // `=1` from .env.local or a mode-specific env file. + VITE_BUZZ_BESTIE: internal ? "1" : "0", + }; + + const result = spawnSync( + process.execPath, + [viteEntrypoint, "build", "--outDir", output, "--emptyOutDir"], + { + cwd: desktopRoot, + env, + stdio: "inherit", + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `${internal ? "internal" : "OSS"} desktop build failed with status ${result.status}`, + ); + } +} + +function emittedText(root) { + const chunks = []; + const visit = (candidate) => { + const stat = statSync(candidate); + if (stat.isDirectory()) { + for (const child of readdirSync(candidate)) { + visit(path.join(candidate, child)); + } + return; + } + if (/\.(?:css|html|js|json)$/u.test(candidate)) { + chunks.push(readFileSync(candidate, "utf8")); + } + }; + visit(root); + return chunks.join("\n"); +} + +export function assertArtifactContract({ ossOutput, internalOutput }) { + const ossText = emittedText(ossOutput); + const internalText = emittedText(internalOutput); + const protectedContent = /\bbestie\b|chief of staff|builtin:bestie/iu; + const internalManifestMarker = + "Try a personal agent that is always close at hand"; + + if (protectedContent.test(ossText)) { + throw new Error( + "Official OSS desktop artifact contains protected Bestie/Chief content", + ); + } + if (!internalText.includes(internalManifestMarker)) { + throw new Error( + "Protected internal desktop artifact is missing the Bestie manifest", + ); + } +} + +/** Resolve the requested output with the same precedence used by Vite config. */ +export function selectInternalVariant({ processEnv, modeEnv }) { + return (processEnv.VITE_BUZZ_BESTIE ?? modeEnv.VITE_BUZZ_BESTIE) === "1"; +} + +/** Build and inspect both graphs, leaving the requested variant in dist. */ +export function buildArtifactMatrix({ + selectedInternalVariant, + selectedOutput, + alternateOutput, + build = buildVariant, +}) { + // Build the unselected variant outside dist first, then leave the requested + // variant in dist for Vite/Tauri's ordinary packaging contract. + build({ + internal: !selectedInternalVariant, + output: alternateOutput, + }); + build({ + internal: selectedInternalVariant, + output: selectedOutput, + }); + + assertArtifactContract({ + ossOutput: selectedInternalVariant ? alternateOutput : selectedOutput, + internalOutput: selectedInternalVariant ? selectedOutput : alternateOutput, + }); +} + +function main() { + const selectedInternalVariant = selectInternalVariant({ + processEnv: process.env, + modeEnv: loadEnv("production", desktopRoot, ""), + }); + const scratchRoot = mkdtempSync( + path.join(tmpdir(), "buzz-protected-feature-artifacts-"), + ); + const selectedOutput = process.env.BUZZ_PROTECTED_BUILD_OUTPUT + ? path.resolve(process.env.BUZZ_PROTECTED_BUILD_OUTPUT) + : path.join(desktopRoot, "dist"); + const alternateOutput = path.join(scratchRoot, "alternate"); + + try { + buildArtifactMatrix({ + selectedInternalVariant, + selectedOutput, + alternateOutput, + }); + } finally { + rmSync(scratchRoot, { recursive: true, force: true }); + } + + console.log( + `Protected feature artifact matrix passed; dist contains the ${selectedInternalVariant ? "internal" : "OSS"} variant.`, + ); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/desktop/scripts/tauri-command.mjs b/desktop/scripts/tauri-command.mjs new file mode 100644 index 00000000000..dc1d8691e96 --- /dev/null +++ b/desktop/scripts/tauri-command.mjs @@ -0,0 +1,63 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const tauriPackageJsonPath = fileURLToPath( + import.meta.resolve("@tauri-apps/cli/package.json"), +); +const tauriPackage = JSON.parse(readFileSync(tauriPackageJsonPath, "utf8")); +const defaultTauriEntrypoint = path.resolve( + path.dirname(tauriPackageJsonPath), + tauriPackage.bin.tauri, +); + +function runTauri(args, options = {}) { + const entrypoint = + process.env.BUZZ_TAURI_CLI_ENTRYPOINT ?? defaultTauriEntrypoint; + const result = spawnSync(process.execPath, [entrypoint, ...args], { + cwd: desktopRoot, + env: { ...process.env, ...options.env }, + stdio: "inherit", + }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +export function runTauriCommand(args) { + if (args[0] !== "build") return runTauri(args); + + // Tauri runs beforeBuildCommand and then consumes frontendDist. Give the + // entire invocation a private directory so concurrent OSS/internal packages + // cannot replace one another's assets between those two operations. + const invocationRoot = mkdtempSync( + path.join(tmpdir(), "buzz-tauri-package-assets-"), + ); + const frontendDist = path.join(invocationRoot, "dist"); + const outputOverride = JSON.stringify({ build: { frontendDist } }); + + try { + const delimiterIndex = args.indexOf("--"); + const configIndex = delimiterIndex === -1 ? args.length : delimiterIndex; + const tauriArgs = [...args]; + tauriArgs.splice(configIndex, 0, "--config", outputOverride); + return runTauri(tauriArgs, { + env: { BUZZ_PROTECTED_BUILD_OUTPUT: frontendDist }, + }); + } finally { + rmSync(invocationRoot, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + process.exitCode = runTauriCommand(process.argv.slice(2)); +} diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 1611ac917ca..fa268acee61 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -66,6 +66,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: Some("persona-1".to_string()), @@ -130,6 +131,7 @@ fn agent_record() -> ManagedAgentRecord { fn persona_with_model(model: &str) -> AgentDefinition { AgentDefinition { + description: None, id: "persona-1".to_string(), display_name: "Persona".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index db0573acd7c..0b6bf22a6a9 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -506,6 +506,7 @@ mod real_relay_tests { &agent, "Agent Probe", None, + None, Some(&auth_tag), ) .await diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index d129626aba1..1999dcf471a 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -428,29 +428,20 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { ) .expect("sample managed agent record"); - let persona = crate::managed_agents::AgentDefinition { - id: "persona-1".to_string(), - display_name: "Persona".to_string(), - avatar_url: None, - system_prompt: "You are a persona.".to_string(), - runtime: Some("goose".to_string()), - model: Some("persona-model".to_string()), - provider: Some("anthropic".to_string()), - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - env_vars: BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "".to_string(), - updated_at: "".to_string(), - }; + let persona: crate::managed_agents::AgentDefinition = serde_json::from_str( + r#"{ + "id": "persona-1", + "display_name": "Persona", + "system_prompt": "You are a persona.", + "runtime": "goose", + "model": "persona-model", + "provider": "anthropic", + "is_active": true, + "created_at": "", + "updated_at": "" + }"#, + ) + .expect("sample persona"); // agent_model_discovery_config is the single helper get_agent_models // consumes — the stale record bytes must lose to the persona's current diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index bb045b81a24..68f54f58ad6 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -244,8 +244,16 @@ pub async fn update_managed_agent( .avatar_url .clone() .or_else(|| managed_agent_avatar_url(&effective_command)); + let about = crate::managed_agents::record_effective_description(record, &personas); let auth_tag = record.auth_tag.clone(); - Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) + Some(( + agent_keys, + relay_url, + display_name, + avatar_url, + about, + auth_tag, + )) } else { None }; @@ -291,13 +299,14 @@ pub async fn update_managed_agent( // A rename is committed only when profile sync succeeds; otherwise restore // the complete pre-edit record so Desktop and the relay keep one // authoritative name. - if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { + if let Some((agent_keys, relay_url, display_name, avatar_url, about, auth_tag)) = sync_params { if let Err(sync_error) = sync_managed_agent_profile( &state, &relay_url, &agent_keys, &display_name, avatar_url.as_deref(), + about.as_deref(), auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index adfce3c4b6f..7a3629e7f09 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -15,7 +15,7 @@ use crate::{ RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, - relay::{relay_ws_url_with_override, sync_managed_agent_profile}, + relay::relay_ws_url_with_override, util::now_iso, }; @@ -456,7 +456,7 @@ pub async fn create_managed_agent( }; // ── Phase 3: save record (sync lock) ─────────────────────────────────────── - let (agent, resolved_avatar_url) = { + let (agent, resolved_avatar_url, profile_about) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -615,10 +615,10 @@ pub async fn create_managed_agent( input.parallelism, linked_persona.as_ref(), )?; - let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), + description: None, persona_id: requested_persona_id.clone(), team_id, private_key_nsec: private_key_nsec.clone(), @@ -720,9 +720,12 @@ pub async fn create_managed_agent( // before any .await — owner-authored, every agent (Will's ruling: no // is_builtin/persona-membership gate). retain_managed_agent_pending(&app, &state, record); + // Effective owner-authored description for the kind:0 `about`. + let profile_about = crate::managed_agents::record_effective_description(record, &personas); ( summarize_from_disk(&app, record, &runtimes)?, resolved_avatar_url, + profile_about, ) }; @@ -762,20 +765,16 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. - let profile_relay_url = crate::relay::effective_agent_relay_url( - &resolved_relay_url, - &relay_ws_url_with_override(&state), - ); - let mut profile_sync_error = (sync_managed_agent_profile( + let mut profile_sync_error = profile::publish_agent_profile_with_about( &state, - &profile_relay_url, + &resolved_relay_url, &agent_keys, &name, resolved_avatar_url.as_deref(), + profile_about.as_deref(), auth_tag.as_deref(), ) - .await) - .err(); + .await; profile_sync_error = super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await; diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 16a1538c753..193a1fb0344 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -40,6 +40,11 @@ pub(crate) struct ProfileReconcileData { /// backfill to recover the correct avatar from the persona record when the /// relay profile has been corrupted. pub(crate) persona_id: Option, + /// Expected kind:0 `about` — the agent's effective public description + /// (owner-authored when present; see + /// `managed_agents::record_effective_description`). `None` publishes an + /// about-less profile. + pub(crate) about: Option, } /// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no @@ -96,6 +101,7 @@ pub(crate) fn profile_reconcile_data( pubkey: record.pubkey.clone(), agent_command: crate::managed_agents::record_agent_command(record, personas), persona_id: record.persona_id.clone(), + about: crate::managed_agents::record_effective_description(record, personas), } } @@ -254,7 +260,12 @@ pub(crate) async fn reconcile_agent_profile( Some(expected_avatar) }; - if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { + if !profile_needs_sync( + existing.as_ref(), + &data.name, + expected_avatar.as_deref(), + data.about.as_deref(), + ) { return Ok(ProfileReconcileOutcome::Reconciled); } @@ -274,6 +285,7 @@ pub(crate) async fn reconcile_agent_profile( &agent_keys, &data.name, expected_avatar.as_deref(), + data.about.as_deref(), data.auth_tag.as_deref(), ) .await?; @@ -281,23 +293,84 @@ pub(crate) async fn reconcile_agent_profile( } /// Decide whether a published profile is missing or stale relative to the -/// expected name and avatar. A missing profile always needs sync; a present -/// one is stale when either the display name or picture diverges. +/// expected name, avatar, and about. A missing profile always needs sync; a +/// present one is stale when the display name, picture, or about diverges. +/// For about, `None` and the empty string are treated as equal so an +/// about-less profile never triggers a pointless republish loop. pub(super) fn profile_needs_sync( existing: Option<&crate::relay::AgentProfileInfo>, expected_name: &str, expected_avatar: Option<&str>, + expected_about: Option<&str>, ) -> bool { match existing { None => true, Some(info) => { let name_matches = info.display_name.as_deref() == Some(expected_name); let picture_matches = info.picture.as_deref() == expected_avatar; - !name_matches || !picture_matches + let about_matches = info.about.as_deref().unwrap_or("") == expected_about.unwrap_or(""); + !name_matches || !picture_matches || !about_matches } } } +/// Publish a managed agent's kind:0 profile with the authored public +/// description as `about`, resolving the effective +/// relay URL from the record's stored value. Returns the sync error (if any) +/// rather than failing the caller — profile publish is best-effort in the +/// create and snapshot-import flows that share this helper. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn publish_agent_profile_with_about( + state: &AppState, + record_relay_url: &str, + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + about: Option<&str>, + auth_tag: Option<&str>, +) -> Option { + let relay_url = crate::relay::effective_agent_relay_url( + record_relay_url, + &relay_ws_url_with_override(state), + ); + crate::relay::sync_managed_agent_profile( + state, + &relay_url, + agent_keys, + display_name, + avatar_url, + about, + auth_tag, + ) + .await + .err() +} + +/// Publish a fresh persona-backed agent's kind:0 profile, computing the +/// effective public `about` from the persona itself. +/// Shared by flows in files at the size ratchet (snapshot import). +pub(crate) async fn publish_persona_profile( + state: &AppState, + record_relay_url: &str, + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + persona: &crate::managed_agents::AgentDefinition, + auth_tag: Option<&str>, +) -> Option { + let about = crate::managed_agents::effective_agent_description(persona.description.as_deref()); + publish_agent_profile_with_about( + state, + record_relay_url, + agent_keys, + display_name, + avatar_url, + about.as_deref(), + auth_tag, + ) + .await +} + // Async so the blocking body (disk reads/writes + process termination) runs off // the main UI thread via spawn_blocking. State is re-derived from the owned // AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send). diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 0b1d5af51a9..575bb13f9ac 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -9,6 +9,7 @@ fn bare_agent_record( use crate::managed_agents::{BackendKind, RespondTo}; use std::collections::BTreeMap; ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), @@ -73,6 +74,7 @@ fn bare_agent_record( fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { use std::collections::BTreeMap; AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Persona".to_string(), avatar_url: None, @@ -317,15 +319,29 @@ fn created_avatar_uses_command_fallback_without_input_or_persona() { } fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProfileInfo { + profile_with_about(name, picture, None) +} + +fn profile_with_about( + name: Option<&str>, + picture: Option<&str>, + about: Option<&str>, +) -> crate::relay::AgentProfileInfo { crate::relay::AgentProfileInfo { display_name: name.map(str::to_string), picture: picture.map(str::to_string), + about: about.map(str::to_string), } } #[test] fn profile_needs_sync_when_missing() { - assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); + assert!(profile_needs_sync( + None, + "Duncan", + Some("https://x/a.png"), + None + )); } // ── resolve_reconcile_relay: deferred-task relay pinning ──────────────────── @@ -355,7 +371,7 @@ fn unpinned_reconcile_relay_resolves_the_execution_time_workspace() { #[test] fn profile_needs_sync_when_missing_even_without_expected_avatar() { - assert!(profile_needs_sync(None, "Duncan", None)); + assert!(profile_needs_sync(None, "Duncan", None, None)); } #[test] @@ -364,7 +380,8 @@ fn profile_needs_sync_when_name_diverges() { assert!(profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/a.png") + Some("https://x/a.png"), + None )); } @@ -374,7 +391,8 @@ fn profile_needs_sync_when_picture_diverges() { assert!(profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/new.png") + Some("https://x/new.png"), + None )); } @@ -384,14 +402,15 @@ fn profile_in_sync_when_name_and_picture_match() { assert!(!profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/a.png") + Some("https://x/a.png"), + None )); } #[test] fn profile_in_sync_when_both_avatars_absent() { let existing = profile(Some("Duncan"), None); - assert!(!profile_needs_sync(Some(&existing), "Duncan", None)); + assert!(!profile_needs_sync(Some(&existing), "Duncan", None, None)); } #[test] @@ -401,13 +420,50 @@ fn profile_needs_sync_when_existing_name_is_none() { Some(&existing), "Duncan", Some("https://x/a.png"), + None, )); } #[test] fn profile_needs_sync_when_expected_avatar_absent_but_published() { let existing = profile(Some("Duncan"), Some("https://x/a.png")); - assert!(profile_needs_sync(Some(&existing), "Duncan", None)); + assert!(profile_needs_sync(Some(&existing), "Duncan", None, None)); +} + +#[test] +fn profile_needs_sync_when_about_diverges() { + let existing = profile_with_about(Some("Duncan"), None, Some("Old description.")); + assert!(profile_needs_sync( + Some(&existing), + "Duncan", + None, + Some("New description.") + )); +} + +#[test] +fn profile_needs_sync_when_expected_about_absent_but_published() { + let existing = profile_with_about(Some("Duncan"), None, Some("Stale description.")); + assert!(profile_needs_sync(Some(&existing), "Duncan", None, None)); +} + +#[test] +fn profile_in_sync_when_about_matches() { + let existing = profile_with_about(Some("Duncan"), None, Some("A helpful desktop agent.")); + assert!(!profile_needs_sync( + Some(&existing), + "Duncan", + None, + Some("A helpful desktop agent.") + )); +} + +#[test] +fn profile_in_sync_when_about_none_equals_published_empty_string() { + // None vs "" must be treated as equal — otherwise every reconcile of an + // about-less agent would republish forever. + let existing = profile_with_about(Some("Duncan"), None, Some("")); + assert!(!profile_needs_sync(Some(&existing), "Duncan", None, None)); } #[test] diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2b..517e333b293 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -23,14 +23,10 @@ //! uses (global config < persona < agent record) and never leaves Rust. //! It is never logged. -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, State}; - use super::super::export_util::save_bytes_with_dialog; use super::snapshot::{ - memory_entries_from_listing, parse_memory_level, resolve_from_lists, - validate_snapshot_encode_size, + materialize_snapshot_description, memory_entries_from_listing, parse_memory_level, + resolve_from_lists, validate_snapshot_encode_size, }; use crate::{ app_state::AppState, @@ -47,6 +43,9 @@ use crate::{ save_global_agent_config, validate_global_config, }, }; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; /// The Buzz card frame template — Tyler's gold-honeycomb base. Generation /// input only: it never participates in the snapshot manifest, PNG chunk, @@ -553,7 +552,8 @@ pub async fn mint_agent_card( let definitions = load_agent_definitions(&app)?; let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; - + let mut record = record; + materialize_snapshot_description(&mut record, is_definition, &definitions); let global = load_global_agent_config(&app).unwrap_or_default(); let personas = load_personas(&app).unwrap_or_default(); let persona_env = record diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index 91616b225cf..2f19d1256e1 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -13,7 +13,7 @@ use crate::{ util::now_iso, }; -use super::{pending, retain_persona_pending, trim_optional, trim_required}; +use super::{normalize_description, pending, retain_persona_pending, trim_optional, trim_required}; #[tauri::command] pub async fn create_persona( @@ -29,6 +29,7 @@ pub async fn create_persona( // exact string before the ACP harness executes it. let system_prompt = input.system_prompt.clone(); validate_agent_definition_text(&display_name, &system_prompt)?; + let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); @@ -58,6 +59,7 @@ pub async fn create_persona( id: Uuid::new_v4().to_string(), display_name, avatar_url, + description, system_prompt, runtime, model, diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index c7ccb601ff1..3dbf7290acb 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -17,6 +17,7 @@ fn make_agent( runtime_pid: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: pubkey.to_string(), name: "Test Agent".to_string(), persona_id: persona_id.map(str::to_string), diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 882284e8879..d576a246233 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -457,7 +457,9 @@ fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), &persona.display_name, &persona.system_prompt, ) - .map_err(|error| format!("Inbound persona definition is unsafe: {error}")) + .map_err(|error| format!("Inbound persona definition is unsafe: {error}"))?; + crate::managed_agents::validate_agent_description_text(persona.description.as_deref()) + .map_err(|error| format!("Inbound persona definition is unsafe: {error}")) } fn validate_inbound_managed_agent_definition( @@ -690,6 +692,7 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi Some(local) => { local.display_name = inbound.display_name; local.avatar_url = inbound.avatar_url; + local.description = inbound.description; local.system_prompt = inbound.system_prompt; local.runtime = inbound.runtime; local.model = inbound.model; diff --git a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs index a5ca5cd9b5d..390e4850773 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs @@ -28,6 +28,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 3d288f5448f..8af8b15b1fe 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -10,6 +10,7 @@ const UUID: &str = "11111111-2222-3333-4444-555555555555"; // sadscan:disable sq /// IS its UUID id. Carries env_vars + source_team that must survive a patch. fn local_in_app() -> AgentDefinition { AgentDefinition { + description: None, id: UUID.to_string(), display_name: "Local".to_string(), avatar_url: None, @@ -38,6 +39,7 @@ fn local_in_app() -> AgentDefinition { /// slug = Some(d-tag), empty env_vars, source_team None. fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: d_tag.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/a.png".to_string()), @@ -161,6 +163,7 @@ const AGENT_PUBKEY: &str = "agentpubkeyhex00000000000000000000000000000000000000 /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: AGENT_PUBKEY.to_string(), name: "Local Agent".to_string(), persona_id: Some("persona-local".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index a689477f5cf..0c9e8d8c815 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -26,6 +26,38 @@ fn trim_optional(value: Option) -> Option { }) } +/// Validate the raw authored bytes before applying storage normalization. +/// This ordering is security-relevant: prohibited edge characters must be +/// rejected, never made invisible by trimming. +fn normalize_description(value: Option) -> Result, String> { + crate::managed_agents::validate_agent_description_text(value.as_deref())?; + Ok(trim_optional(value)) +} + +#[cfg(test)] +mod description_normalization_tests { + use super::normalize_description; + + #[test] + fn trims_visible_whitespace_and_collapses_blank_to_none() { + assert_eq!( + normalize_description(Some(" A careful agent. ".to_string())).unwrap(), + Some("A careful agent.".to_string()) + ); + assert_eq!( + normalize_description(Some(" ".to_string())).unwrap(), + None + ); + } + + #[test] + fn rejects_prohibited_characters_at_the_edges_before_trimming() { + for value in ["\nA careful agent.", "A careful agent.\n", "\u{feff}Agent"] { + assert!(normalize_description(Some(value.to_string())).is_err()); + } + } +} + mod pending; pub(in crate::commands) use pending::retain_persona_pending; pub(in crate::commands) use pending::retain_persona_pending_at; diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 30e2ec266db..3e4fabbcf5b 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -181,6 +181,9 @@ pub(super) fn prepare_persona_publication_at( &scoped_persona.display_name, &scoped_persona.system_prompt, )?; + crate::managed_agents::validate_agent_description_text( + scoped_persona.description.as_deref(), + )?; } let event = build_persona_event(&scoped_persona)? .custom_created_at(monotonic_created_at( @@ -307,6 +310,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index fa492b338b5..331ec9d0d70 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -146,6 +146,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e63..c996c7ee2ea 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -56,6 +56,25 @@ pub(crate) fn resolve_from_lists<'a>( Err(format!("agent {id:?} not found")) } +/// Materialize persona-owned display metadata onto a cloned instance for +/// portable snapshot construction. Keyless definition records already carry +/// their own description. +pub(crate) fn materialize_snapshot_description( + record: &mut ManagedAgentRecord, + is_definition: bool, + definitions: &[ManagedAgentRecord], +) { + if is_definition { + return; + } + if let Some(persona_id) = record.persona_id.as_deref() { + record.description = definitions + .iter() + .find(|definition| definition.slug.as_deref() == Some(persona_id)) + .and_then(|definition| definition.description.clone()); + } +} + /// Validate that `memory_source_pubkey` is an appropriate source for a /// memory-bearing snapshot export. /// @@ -250,6 +269,7 @@ pub(crate) async fn materialize_snapshot_bytes( let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) .map(|(r, is_def)| (r.clone(), is_def))?; let mut def_record = def_record; + materialize_snapshot_description(&mut def_record, is_definition, &definitions); // A snapshot is a verbatim portable copy of the effective runtime, // provider, and model configuration, not a pointer to the sender's // machine-wide defaults. This does not translate or substitute values diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b7458485069..97d060b9c4b 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), slug: Some(slug.to_string()), name: slug.to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index b3b4fda50ae..db1cfde1ce9 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -21,7 +21,7 @@ use crate::{ load_managed_agents, load_personas, mint_scope_and_check_name, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, - relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, + relay::{effective_agent_relay_url, relay_ws_url_with_override}, util::now_iso, }; @@ -568,12 +568,14 @@ pub async fn confirm_agent_snapshot_import( let now = now_iso(); let persona_id = uuid::Uuid::new_v4().to_string(); - // Build persona from snapshot definition. let persona = AgentDefinition { id: persona_id.clone(), display_name: display_name.clone(), avatar_url: effective_avatar.clone(), + description: crate::managed_agents::effective_agent_description( + snapshot.profile.about.as_deref(), + ), system_prompt: snapshot .definition .system_prompt @@ -603,13 +605,16 @@ pub async fn confirm_agent_snapshot_import( // Enqueue the kind:30175 persona event via the retention path. super::super::pending::retain_persona_pending(&app, &state, &persona); - // Build the managed agent record — no machine-local commands, no // secrets, no lineage from the snapshot. let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, + // Linked definitions remain the sole description authority. Do + // not persist a second instance copy that can go stale after an + // edit or survive a later definition deletion. + description: None, slug: None, persona_id: Some(persona_id.clone()), private_key_nsec: private_key_nsec.clone(), @@ -694,16 +699,16 @@ pub async fn confirm_agent_snapshot_import( // ── Phase 3b: publish kind:0 profile (async, outside lock) ─────────────── let relay_url = effective_agent_relay_url(&record.relay_url, &relay_ws_url_with_override(&state)); - let profile_sync_error = sync_managed_agent_profile( + let profile_sync_error = crate::commands::agents::publish_persona_profile( &state, - &relay_url, + &record.relay_url, &agent_keys, &display_name, effective_avatar.as_deref(), + &persona, auth_tag.as_deref(), ) - .await - .err(); + .await; // ── Phase 4: restore memory (async, outside lock) ───────────────────────── let memory_total = snapshot.memory.entries.len(); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index ad8bf5ee034..74b8c8f1ab2 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; /// persona_id. fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), slug: Some(slug.to_string()), name: slug.to_string(), @@ -93,6 +94,17 @@ fn make_instance(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { } } +#[test] +fn linked_instance_snapshot_materializes_the_definition_description() { + let mut definition = make_definition("reviewer"); + definition.description = Some("Reviews changes.".to_string()); + let mut instance = make_instance("agent-pubkey", "reviewer"); + + materialize_snapshot_description(&mut instance, false, std::slice::from_ref(&definition)); + + assert_eq!(instance.description, definition.description); +} + /// Build a minimal valid AgentSnapshot for import tests. fn make_snapshot( memory_level: MemoryLevel, diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index f9b09b4bbb4..46d0c8a99dc 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -14,7 +14,7 @@ use crate::{ util::now_iso, }; -use super::{pending, retain_persona_pending, trim_optional, trim_required}; +use super::{normalize_description, pending, retain_persona_pending, trim_optional, trim_required}; #[cfg(test)] mod name_propagation_tests; @@ -54,8 +54,72 @@ fn propagate_persona_name_rename( renamed } -/// Profile sync params collected under the store lock for async relay publish. -type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option, Option)>; +#[derive(Debug, PartialEq, Eq)] +struct LinkedProfileUpdate { + /// Whether this update changed bytes in the managed-agent record. + record_changed: bool, + /// Whether this instance needs a complete kind:0 replacement event. + profile_sync_required: bool, + /// Avatar to publish with the complete kind:0 replacement event. + profile_avatar: Option, +} + +/// Apply the persisted portion of a persona identity edit to one linked +/// instance and resolve the avatar for the complete kind:0 replacement. +/// +/// Description-only edits deliberately leave the record unchanged, but still +/// need a non-empty avatar projection for legacy records whose `avatar_url` +/// has not yet been backfilled. The persona avatar is authoritative there; +/// the effective command icon is the final fallback. +fn prepare_linked_profile_update( + record: &mut ManagedAgentRecord, + persona: &AgentDefinition, + renamed: bool, + avatar_changed: bool, + about_changed: bool, +) -> LinkedProfileUpdate { + let mut record_changed = renamed; + if avatar_changed { + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(persona), + record.agent_command_override.as_deref(), + ); + record.avatar_url = persona + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + record_changed = true; + } + + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(persona), + record.agent_command_override.as_deref(), + ); + let profile_avatar = record + .avatar_url + .clone() + .or_else(|| persona.avatar_url.clone()) + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + + LinkedProfileUpdate { + record_changed, + profile_sync_required: record_changed || about_changed, + profile_avatar, + } +} + +/// Profile sync params collected under the store lock for async relay publish: +/// (agent keys, relay url, display name, avatar url, kind:0 about, auth tag). +type ProfileSyncParams = Vec<( + nostr::Keys, + String, + String, + Option, + Option, + Option, +)>; #[tauri::command] pub async fn update_persona( @@ -96,6 +160,7 @@ pub(super) async fn update_persona_with( let display_name = trim_required(&input.display_name, "Display name")?; let system_prompt = input.system_prompt.clone(); validate_agent_definition_text(&display_name, &system_prompt)?; + let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); @@ -116,9 +181,17 @@ pub(super) async fn update_persona_with( let avatar_changed = persona.avatar_url != avatar_url; let name_changed = persona.display_name != display_name; let old_display_name = persona.display_name.clone(); + // The kind:0 `about` is the authored description, so a + // description edit changes what should be published. + let old_about = + crate::managed_agents::effective_agent_description(persona.description.as_deref()); + let new_about = + crate::managed_agents::effective_agent_description(description.as_deref()); + let about_changed = old_about != new_about; persona.display_name = display_name; persona.avatar_url = avatar_url; + persona.description = description; persona.system_prompt = system_prompt; persona.runtime = runtime; persona.model = model; @@ -142,9 +215,12 @@ pub(super) async fn update_persona_with( let retained = retain(&app, &state, &result)?; try_regenerate_nest(&app); - // If the avatar or display_name changed, propagate to linked agent - // records and collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed || name_changed { + // If the avatar, display_name, or effective description changed, + // propagate to linked agent records and collect relay profile sync + // params for the async phase. An about-only change touches no + // record bytes but still republishes each linked kind:0 profile. + let sync_params: ProfileSyncParams = if avatar_changed || name_changed || about_changed + { let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; @@ -169,28 +245,17 @@ pub(super) async fn update_persona_with( if record.persona_id.as_deref() != Some(&result.id) { continue; } - let mut record_changed = renamed.contains(&record.pubkey); - - if avatar_changed { - // Update the persisted avatar so reconciliation on next - // start agrees with what we're about to publish. - // When the persona avatar is cleared, fall back to the - // command-default icon so the record never stores `None` - // (which reconcile_agent_profile treats as "un-migrated"). - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - std::slice::from_ref(&result), - record.agent_command_override.as_deref(), - ); - record.avatar_url = result - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_cmd)); - record_changed = true; - } + let was_renamed = renamed.contains(&record.pubkey); + let update = prepare_linked_profile_update( + record, + &result, + was_renamed, + avatar_changed, + about_changed, + ); - if record_changed { - agents_modified = true; + agents_modified = agents_modified || update.record_changed; + if update.profile_sync_required { if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { let relay_url = crate::relay::effective_agent_relay_url( &record.relay_url, @@ -200,7 +265,8 @@ pub(super) async fn update_persona_with( agent_keys, relay_url, record.name.clone(), - record.avatar_url.clone(), + update.profile_avatar, + new_about.clone(), record.auth_tag.clone(), )); } @@ -231,19 +297,23 @@ pub(super) async fn update_persona_with( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; - // Phase 2: await relay profile sync for linked agents whose avatar or - // display_name was just updated. We await (rather than fire-and-forget) + // Phase 2: await relay profile sync for linked agents whose avatar, + // display_name, or effective description (kind:0 about) was just + // updated. We await (rather than fire-and-forget) // so the frontend cache invalidation that follows the mutation settlement // sees the fresh relay profile. Best-effort — failures are logged, not surfaced. if !profile_sync_params.is_empty() { let state = app.state::(); - for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params { + for (agent_keys, relay_url, display_name, avatar_url, about, auth_tag) in + profile_sync_params + { if let Err(e) = crate::relay::sync_managed_agent_profile( &state, &relay_url, &agent_keys, &display_name, avatar_url.as_deref(), + about.as_deref(), auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index a1117510b27..69ba67f1c53 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -5,6 +5,7 @@ use super::*; fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: format!("pubkey-{name}"), name: name.to_string(), persona_id: Some(persona_id.to_string()), @@ -141,6 +142,52 @@ fn test_rename_only_affects_linked_persona() { ); } +#[test] +fn description_only_update_syncs_without_mutating_record_and_preserves_legacy_persona_avatar() { + let mut record = agent("persona-1", "Paul", Some("Paul")); + record.avatar_url = None; + record.slug = Some("persona-1".to_string()); + let before = record.clone(); + let mut persona = record + .clone() + .to_definition_view() + .expect("test record projects to a definition"); + persona.id = "persona-1".to_string(); + persona.avatar_url = Some("https://example.com/paul.png".to_string()); + + let update = prepare_linked_profile_update(&mut record, &persona, false, false, true); + + assert!(update.profile_sync_required, "about-only edits must sync"); + assert!( + !update.record_changed, + "about-only edits must not write the agent store" + ); + assert_eq!( + record, before, + "description-only edits leave instance bytes untouched" + ); + assert_eq!( + update.profile_avatar.as_deref(), + Some("https://example.com/paul.png"), + "complete kind:0 replacement must not clear a legacy agent avatar" + ); +} + +#[test] +fn unchanged_identity_needs_neither_store_write_nor_profile_sync() { + let mut record = agent("persona-1", "Paul", Some("Paul")); + record.slug = Some("persona-1".to_string()); + let persona = record + .clone() + .to_definition_view() + .expect("test record projects to a definition"); + + let update = prepare_linked_profile_update(&mut record, &persona, false, false, false); + + assert!(!update.record_changed); + assert!(!update.profile_sync_required); +} + #[test] fn test_rename_renames_all_matching_instances_in_one_pass() { // Several instances may carry the definition name (multi-instance deploys diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 0b5ec89048b..cb9083abd5c 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -123,6 +123,9 @@ fn definition_from_snapshot( id: Uuid::new_v4().to_string(), display_name: member.profile.display_name.trim().to_string(), avatar_url: effective_avatar(member), + description: crate::managed_agents::effective_agent_description( + member.profile.about.as_deref(), + ), system_prompt: member.definition.system_prompt.clone().unwrap_or_default(), runtime: member.definition.runtime.clone(), model: member.definition.model.clone(), @@ -560,6 +563,10 @@ pub async fn confirm_team_snapshot_import( pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, + // Linked definitions remain the sole description authority. Do + // not persist a second instance copy that can go stale after an + // edit or survive a later definition deletion. + description: None, slug: None, persona_id: Some(definition.id.clone()), private_key_nsec: private_key_nsec.clone(), @@ -799,12 +806,15 @@ pub async fn confirm_team_snapshot_import( let relay_url = effective_agent_relay_url(&m.record.relay_url, &relay_ws); // Phase 4: profile sync (best-effort). + let profile_about = + crate::managed_agents::effective_agent_description(m.definition.description.as_deref()); let profile_sync_error = sync_managed_agent_profile( &state, &relay_url, &m.agent_keys, &m.display_name, m.effective_avatar.as_deref(), + profile_about.as_deref(), m.auth_tag.as_deref(), ) .await @@ -882,111 +892,10 @@ pub async fn confirm_team_snapshot_import( }) } -/// Inline retention for the managed-agent kind:30177 event — mirrors -/// `commands::personas::snapshot::import::retain_agent_pending`. -fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { - use crate::managed_agents::{ - agent_events::{agent_event_content, build_agent_event}, - persona_events::monotonic_created_at, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let conn = open_retention_db(&scope.db_path)?; - let content = serde_json::to_string(&agent_event_content(record)) - .map_err(|e| format!("failed to serialize agent content: {e}"))?; - let (owner_pubkey, event) = { - let keys = &scope.owner_keys; - let owner_pubkey = keys.public_key().to_hex(); - let existing = - get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; - if existing.as_ref().is_some_and(|row| row.content == content) { - return Ok(()); - } - let event = build_agent_event(record)? - .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign agent event: {e}"))?; - (owner_pubkey, event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: team-snapshot-import retain-agent: {e}"); - } -} +mod relay_io; -/// POST a pre-built signed engram event to the relay, authenticating as the -/// new agent. Mirrors the same helper in `snapshot::import`. -pub(crate) async fn submit_engram_event( - state: &AppState, - agent_keys: &nostr::Keys, - event_json: &[u8], - url: &str, - auth_tag: Option<&str>, -) -> Result<(), String> { - use crate::relay::build_nip98_auth_header_for_keys; - use reqwest::Method; - - crate::egress_guard::assert_no_key_backup_bytes(event_json, "team snapshot engram submit")?; - - // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the - // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the - // wait produces a stale `created_at` that the relay will reject. - crate::relay_admission::wait_for_rate_limit().await; - let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?; - let mut request = state - .http_client - .post(url) - .header("Authorization", auth) - .header("Content-Type", "application/json"); - if let Some(tag) = auth_tag { - request = request.header("x-auth-tag", tag); - } - let response = request - .body(event_json.to_vec()) - .send() - .await - .map_err(|e| crate::relay::classify_request_error(&e))?; - - if !response.status().is_success() { - let msg = crate::relay::relay_error_message(response).await; - return Err(format!("relay rejected engram: {msg}")); - } - - let body = response - .text() - .await - .map_err(|e| format!("failed to read relay response: {e}"))?; - let parsed: serde_json::Value = - serde_json::from_str(&body).map_err(|e| format!("relay response not JSON: {e}"))?; - let accepted = parsed - .get("accepted") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !accepted { - let message = parsed - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - return Err(format!("relay rejected engram: {message}")); - } - Ok(()) -} +use relay_io::retain_agent_pending; +pub(crate) use relay_io::submit_engram_event; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/commands/team_snapshot/relay_io.rs b/desktop/src-tauri/src/commands/team_snapshot/relay_io.rs new file mode 100644 index 00000000000..f0a2e6ce4aa --- /dev/null +++ b/desktop/src-tauri/src/commands/team_snapshot/relay_io.rs @@ -0,0 +1,116 @@ +//! Relay- and retention-side I/O for team-snapshot import. +//! +//! Split out of `commands/team_snapshot.rs` to keep that file under the +//! desktop file-size ratchet. These two helpers are the only places the +//! import path talks to the retention database or posts to the relay +//! directly; the command bodies stay in the parent module. + +use tauri::AppHandle; + +use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; + +/// Inline retention for the managed-agent kind:30177 event — mirrors +/// `commands::personas::snapshot::import::retain_agent_pending`. +pub(super) fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { + use crate::managed_agents::{ + agent_events::{agent_event_content, build_agent_event}, + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + let content = serde_json::to_string(&agent_event_content(record)) + .map_err(|e| format!("failed to serialize agent content: {e}"))?; + let (owner_pubkey, event) = { + let keys = &scope.owner_keys; + let owner_pubkey = keys.public_key().to_hex(); + let existing = + get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(()); + } + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign agent event: {e}"))?; + (owner_pubkey, event) + }; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: team-snapshot-import retain-agent: {e}"); + } +} + +/// POST a pre-built signed engram event to the relay, authenticating as the +/// new agent. Mirrors the same helper in `snapshot::import`. +pub(crate) async fn submit_engram_event( + state: &AppState, + agent_keys: &nostr::Keys, + event_json: &[u8], + url: &str, + auth_tag: Option<&str>, +) -> Result<(), String> { + use crate::relay::build_nip98_auth_header_for_keys; + use reqwest::Method; + + crate::egress_guard::assert_no_key_backup_bytes(event_json, "team snapshot engram submit")?; + + // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the + // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the + // wait produces a stale `created_at` that the relay will reject. + crate::relay_admission::wait_for_rate_limit().await; + let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?; + let mut request = state + .http_client + .post(url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let response = request + .body(event_json.to_vec()) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if !response.status().is_success() { + let msg = crate::relay::relay_error_message(response).await; + return Err(format!("relay rejected engram: {msg}")); + } + + let body = response + .text() + .await + .map_err(|e| format!("failed to read relay response: {e}"))?; + let parsed: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("relay response not JSON: {e}"))?; + let accepted = parsed + .get("accepted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !accepted { + let message = parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + return Err(format!("relay rejected engram: {message}")); + } + Ok(()) +} diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c57de6aee9f..ddc67c9fec5 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -55,6 +55,7 @@ fn snapshot(members: Vec) -> TeamSnapshot { fn team_export_round_trip_preserves_team_and_excludes_member_memory() { let definitions = vec![ AgentDefinition { + description: Some("A careful reviewer.".to_string()), id: "alice".to_string(), display_name: "Alice".to_string(), avatar_url: None, @@ -78,6 +79,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { updated_at: "now".to_string(), }, AgentDefinition { + description: None, id: "bob".to_string(), display_name: "Bob".to_string(), avatar_url: None, @@ -136,6 +138,11 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { assert_eq!(decoded.team.description.as_deref(), Some("Reviews changes")); assert_eq!(decoded.team.instructions.as_deref(), Some("Be thorough.")); assert_eq!(decoded.members.len(), 2); + assert_eq!( + decoded.members[0].profile.about.as_deref(), + Some("A careful reviewer.") + ); + assert_eq!(decoded.members[1].profile.about, None); assert!(decoded.members.iter().all(|member| { member.memory.level == MemoryLevel::None && member.memory.entries.is_empty() })); @@ -144,6 +151,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { #[test] fn team_export_with_instance_and_memory_level_uses_supplied_entries() { let definitions = vec![AgentDefinition { + description: None, id: "alice".to_string(), display_name: "Alice".to_string(), avatar_url: None, @@ -185,6 +193,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { // Build a fake instance record tied to this team+persona. let instance = ManagedAgentRecord { + description: None, pubkey: "a".repeat(64), name: "Alice".to_string(), display_name: None, @@ -301,6 +310,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { #[test] fn team_import_definitions_are_built_for_all_members() { let mut memory_bearing = member("Alice"); + memory_bearing.profile.about = Some(" A careful reviewer. ".to_string()); memory_bearing.memory = AgentSnapshotMemory { level: MemoryLevel::Everything, entries: vec![AgentSnapshotMemoryEntry { @@ -340,6 +350,11 @@ fn team_import_definitions_are_built_for_all_members() { && definition.respond_to_allowlist.is_empty() })); assert_eq!(definitions[0].system_prompt, "Alice prompt"); + assert_eq!( + definitions[0].description.as_deref(), + Some("A careful reviewer.") + ); + assert_eq!(definitions[1].description, None); } #[test] diff --git a/desktop/src-tauri/src/commands/teams/adopt/apply.rs b/desktop/src-tauri/src/commands/teams/adopt/apply.rs index f3e0bc708a4..d52e71aeee1 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/apply.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/apply.rs @@ -437,6 +437,9 @@ fn member_copy( Ok(AgentDefinition { id: Uuid::new_v4().to_string(), display_name: member.display_name.clone(), + // Team catalog members carry no public description; an adopted copy + // starts without one. + description: None, avatar_url: member.avatar_url.clone(), system_prompt: member.system_prompt.clone().unwrap_or_default(), runtime: member.runtime.clone(), diff --git a/desktop/src-tauri/src/commands/teams/adopt/tests.rs b/desktop/src-tauri/src/commands/teams/adopt/tests.rs index bd30cdacc24..2235bd0b2b9 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/tests.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/tests.rs @@ -23,6 +23,7 @@ fn persona(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: prompt.to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/teams/pending/tests.rs b/desktop/src-tauri/src/commands/teams/pending/tests.rs index 941f725c50b..7f4d31a6535 100644 --- a/desktop/src-tauri/src/commands/teams/pending/tests.rs +++ b/desktop/src-tauri/src/commands/teams/pending/tests.rs @@ -14,6 +14,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/teams/sharing/tests.rs b/desktop/src-tauri/src/commands/teams/sharing/tests.rs index 71f841d5803..a6e5a7d2d77 100644 --- a/desktop/src-tauri/src/commands/teams/sharing/tests.rs +++ b/desktop/src-tauri/src/commands/teams/sharing/tests.rs @@ -16,6 +16,7 @@ fn member(id: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: "One".to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs index db58ddafa05..fb0ba17cda8 100644 --- a/desktop/src-tauri/src/egress_guard.rs +++ b/desktop/src-tauri/src/egress_guard.rs @@ -11,7 +11,7 @@ //! | 3 | pre-signed path into the boundary-1 funnel | `relay/submit.rs` | //! | 4 | `submit_signed_event_with_keys` | `relay.rs` | //! | 5 | huddle STT publisher | `huddle/pipeline.rs` | -//! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | +//! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot/relay_io.rs` | //! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | //! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | //! diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 0e718079a30..d0c3600cb70 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -109,6 +109,7 @@ async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() { &format!("agent {NCRYPTSEC}"), None, None, + None, ) .await .unwrap_err(); @@ -262,10 +263,14 @@ fn src_rust_files() -> Vec { /// guard + adding an injection test for the new site. const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Production egress boundaries (see egress_guard.rs table): - ("src/relay.rs", 2, 2), // boundaries 2, 4 - ("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel) - ("src/huddle/pipeline.rs", 1, 1), // boundary 5 - ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 + ("src/relay.rs", 2, 2), // boundaries 2, 4 + ("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel) + ("src/huddle/pipeline.rs", 1, 1), // boundary 5 + // Boundary 6 is split across the module: the caller builds the URL, + // `submit_engram_event` (with the guard) lives in the sibling file the + // file-size ratchet forced it into. + ("src/commands/team_snapshot.rs", 1, 0), + ("src/commands/team_snapshot/relay_io.rs", 0, 1), ("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) // Test-only fixtures — no production egress, no guard: diff --git a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs index 8d370285739..5fcf66a4588 100644 --- a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs +++ b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs @@ -14,6 +14,7 @@ fn member(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: prompt.to_string(), runtime: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_description.rs b/desktop/src-tauri/src/managed_agents/agent_description.rs new file mode 100644 index 00000000000..af0a406404e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_description.rs @@ -0,0 +1,154 @@ +//! Effective public agent description — the Rust twin of +//! `desktop/src/features/agents/lib/agentDescription.ts`. +//! +//! The desktop publishes an agent's effective description as the `about` +//! field of its kind:0 profile event. Only the owner-authored +//! `AgentDefinition.description` publishes; a blank description publishes an +//! empty `about`, exactly as before the field existed. + +use super::{AgentDefinition, ManagedAgentRecord}; + +/// The description to publish for an agent: the authored `description`, +/// trimmed, when non-empty; otherwise `None`. +/// +/// TS twin: `effectiveAgentDescription` in `lib/agentDescription.ts`. +pub(crate) fn effective_agent_description(description: Option<&str>) -> Option { + let authored = description.map(str::trim).unwrap_or(""); + if authored.is_empty() { + return None; + } + Some(authored.to_string()) +} + +/// Effective description for a managed-agent record's kind:0 profile. +/// +/// A persona-linked instance publishes its linked definition's authored +/// description — the definition is the authority for identity metadata, +/// matching how the card face resolves it. A missing linked definition yields +/// no description rather than reviving a stale instance copy. Only a +/// definition-less instance falls back to its own record field. +pub(crate) fn record_effective_description( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> Option { + if let Some(persona_id) = record.persona_id.as_deref() { + return personas + .iter() + .find(|persona| persona.id == persona_id) + .and_then(|persona| effective_agent_description(persona.description.as_deref())); + } + effective_agent_description(record.description.as_deref()) +} + +// Tests mirror `lib/agentDescription.test.mjs` case-for-case so the Rust +// publish path and the TS display path cannot drift silently. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authored_description_wins() { + assert_eq!( + effective_agent_description(Some("Reviews desktop PRs.")).as_deref(), + Some("Reviews desktop PRs.") + ); + } + + #[test] + fn authored_description_is_trimmed() { + assert_eq!( + effective_agent_description(Some(" Reviews desktop PRs. ")).as_deref(), + Some("Reviews desktop PRs.") + ); + } + + #[test] + fn blank_and_none_descriptions_yield_none() { + assert_eq!(effective_agent_description(None), None); + assert_eq!(effective_agent_description(Some("")), None); + assert_eq!(effective_agent_description(Some(" ")), None); + } + + fn record_with(description: Option<&str>, persona_id: Option<&str>) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("sample record"); + record.description = description.map(str::to_string); + record.persona_id = persona_id.map(str::to_string); + record + } + + fn persona_with(id: &str, description: Option<&str>) -> AgentDefinition { + let mut persona: AgentDefinition = serde_json::from_str( + r#"{ + "id": "placeholder", + "display_name": "Helper", + "system_prompt": "You help.", + "is_builtin": false, + "is_active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z" + }"#, + ) + .expect("sample persona"); + persona.id = id.to_string(); + persona.description = description.map(str::to_string); + persona + } + + #[test] + fn linked_record_publishes_the_definition_description() { + let record = record_with(Some("record-level"), Some("p1")); + let personas = vec![persona_with("p1", Some("Definition description."))]; + assert_eq!( + record_effective_description(&record, &personas).as_deref(), + Some("Definition description.") + ); + } + + #[test] + fn linked_record_with_blank_definition_description_publishes_none() { + let record = record_with(Some("record-level"), Some("p1")); + let personas = vec![persona_with("p1", None)]; + assert_eq!(record_effective_description(&record, &personas), None); + } + + #[test] + fn definition_less_record_falls_back_to_its_own_description() { + let record = record_with(Some("Record description."), None); + assert_eq!( + record_effective_description(&record, &[]).as_deref(), + Some("Record description.") + ); + } + + #[test] + fn dangling_persona_link_does_not_revive_a_stale_record_description() { + let record = record_with(Some("Stale imported description."), Some("missing")); + assert_eq!(record_effective_description(&record, &[]), None); + } + + #[test] + fn no_description_anywhere_yields_none() { + let record = record_with(None, None); + assert_eq!(record_effective_description(&record, &[]), None); + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 6b831fc2f1b..d6fbdebfbd7 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -164,6 +164,7 @@ mod tests { fn sample_agent() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agentpubkeyhex".to_string(), name: "Test Agent".to_string(), persona_id: Some("persona-1".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 4b734ce1591..abe48e49fa8 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -226,7 +226,7 @@ pub fn build_snapshot( .display_name .clone() .unwrap_or_else(|| record.name.clone()), - about: None, // kind:0 `about` not yet surfaced in ManagedAgentRecord + about: super::effective_agent_description(record.description.as_deref()), avatar_data_url, avatar_url: avatar_url_ref, }; @@ -419,6 +419,8 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> .unwrap_or_default(), ) .map_err(|error| format!("Snapshot definition is unsafe: {error}"))?; + super::validate_agent_description_text(snapshot.profile.about.as_deref()) + .map_err(|error| format!("Snapshot description is unsafe: {error}"))?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index a01e6e75323..6929ab05529 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -366,6 +366,7 @@ mod tests { /// pubkey/nsec pair matters here. fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey, name: "Locked Test".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b39de8cbf6c..202ad318750 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; /// relevant to snapshot export are filled; the rest use defaults. fn minimal_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "deadbeef".to_string(), name: "Test Agent".to_string(), display_name: Some("Test Agent Display".to_string()), @@ -601,9 +602,14 @@ fn definition_fields_present_in_snapshot() { #[test] fn profile_fields_present_in_snapshot() { - let record = minimal_record(); + let mut record = minimal_record(); + record.description = Some(" A careful test agent. ".to_string()); let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + assert_eq!( + snapshot.profile.about.as_deref(), + Some("A careful test agent.") + ); // No bytes → should fall back to avatar_url assert_eq!( snapshot.profile.avatar_url.as_deref(), @@ -612,6 +618,16 @@ fn profile_fields_present_in_snapshot() { assert!(snapshot.profile.avatar_data_url.is_none()); } +#[test] +fn snapshot_rejects_unsafe_or_overlong_description() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.profile.about = Some("unsafe\u{200b}description".to_string()); + assert!(validate_snapshot(&snapshot).is_err()); + + snapshot.profile.about = Some("a".repeat(281)); + assert!(validate_snapshot(&snapshot).is_err()); +} + #[test] fn avatar_inlined_when_under_size_limit() { let record = minimal_record(); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 0254ded59cf..aa3fe1b35c6 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -65,6 +65,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { fn test_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "test".to_string(), name: "Test Agent".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/definition_validation.rs b/desktop/src-tauri/src/managed_agents/definition_validation.rs index e063eb85cd8..17e75d7bdac 100644 --- a/desktop/src-tauri/src/managed_agents/definition_validation.rs +++ b/desktop/src-tauri/src/managed_agents/definition_validation.rs @@ -10,6 +10,8 @@ use std::sync::LazyLock; const MAX_DISPLAY_NAME_CHARS: usize = 128; const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024; +/// Cap for the optional public agent description. +pub(crate) const MAX_AGENT_DESCRIPTION_CHARS: usize = 280; const EMOJI_VARIATION_SELECTOR: char = '\u{FE0F}'; const ZERO_WIDTH_JOINER: char = '\u{200D}'; @@ -41,6 +43,23 @@ pub(crate) fn validate_agent_definition_text( validate_visible_text(system_prompt, "Agent instructions", true) } +/// Validate an optional public agent description: max 280 characters and the +/// same visible-text policy as the other definition fields (invisible, bidi, +/// and control characters are rejected, not stripped). `None` and the empty +/// string are both valid — the description is optional. +pub(crate) fn validate_agent_description_text(description: Option<&str>) -> Result<(), String> { + let Some(description) = description else { + return Ok(()); + }; + let description_chars = description.chars().count(); + if description_chars > MAX_AGENT_DESCRIPTION_CHARS { + return Err(format!( + "Description is too long ({description_chars} characters, max {MAX_AGENT_DESCRIPTION_CHARS})" + )); + } + validate_visible_text(description, "Description", false) +} + /// Validate the human-reviewed definition text carried by a managed agent. /// /// Definition-linked agents resolve their executable prompt through the @@ -243,6 +262,37 @@ mod tests { assert!(validate_agent_definition_text("Reviewer", &"a".repeat(64 * 1024 + 1)).is_err()); } + #[test] + fn description_accepts_none_empty_and_plain_text() { + assert!(validate_agent_description_text(None).is_ok()); + assert!(validate_agent_description_text(Some("")).is_ok()); + assert!(validate_agent_description_text(Some("Buttercup, a software engineer 🐝")).is_ok()); + assert!( + validate_agent_description_text(Some(&"a".repeat(MAX_AGENT_DESCRIPTION_CHARS))).is_ok() + ); + } + + #[test] + fn description_rejects_over_280_chars() { + assert!(validate_agent_description_text(Some( + &"a".repeat(MAX_AGENT_DESCRIPTION_CHARS + 1) + )) + .is_err()); + } + + #[test] + fn description_rejects_invisible_bidi_and_control_characters() { + for character in ['\u{200B}', '\u{202E}', '\u{2066}', '\0', '\r', '\u{0007}'] { + for description in [ + format!("A helpful{character}agent"), + format!("{character}A helpful agent"), + format!("A helpful agent{character}"), + ] { + assert!(validate_agent_description_text(Some(&description)).is_err()); + } + } + } + #[test] fn definition_less_managed_agent_validates_its_own_name_and_prompt() { assert!(validate_managed_agent_definition_text( diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index e997a7dd710..10637f23296 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -173,6 +173,7 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent id: id.to_string(), display_name: id.to_string(), avatar_url: None, + description: None, system_prompt: String::new(), runtime: runtime.map(str::to_string), model: None, @@ -1327,457 +1328,4 @@ fn test_install_shell_from_some_returns_path() { ); } -// ── Registry lifecycle (C1) ─────────────────────────────────────────────────── -// -// These tests verify the "warm → spawn resolves → delete → spawn errors" lifecycle -// that Paul's C1 ruling requires. They call the production resolution functions -// directly so they would red if warm_harness_registry_from_dir, save/delete -// transactional refresh, or try_record_agent_command were reverted. - -/// After warm_harness_registry_from_dir, a record with a matching custom runtime -/// id resolves to the custom command — NOT the buzz-agent default. -/// -/// This test would fail if warm_harness_registry_from_dir is not called before -/// try_record_agent_command, or if try_record_agent_command ignores the registry. -#[test] -fn registry_warm_then_try_record_resolves_custom_id() { - use crate::managed_agents::custom_harnesses::{ - registry_test_lock, warm_harness_registry_from_dir, - }; - use std::fs; - use tempfile::tempdir; - - let _lock = registry_test_lock(); - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("my-custom-cli.json"), - r#"{"id":"my-custom-cli","label":"My CLI","command":"my-custom-bin"}"#, - ) - .unwrap(); - - warm_harness_registry_from_dir(Some(dir.path())); - - let record = record_with(Some("my-custom-cli"), None, None); - let result = try_record_agent_command(&record, &[]); - assert_eq!( - result, - Ok("my-custom-bin".to_string()), - "warm registry must make custom id resolvable at spawn time" - ); -} - -/// After deleting a custom harness and re-warming the registry, a record that -/// still references the deleted id must produce a DANGLING_HARNESS_ID error — -/// NOT silently fall back to buzz-agent. -/// -/// This test would fail if save/delete commands do not call -/// warm_harness_registry_from_dir transactionally, or if try_record_agent_command -/// silently falls back to default_agent_command() for dangling ids. -#[test] -fn registry_delete_then_try_record_returns_dangling_error() { - use crate::managed_agents::custom_harnesses::{ - registry_test_lock, warm_harness_registry_from_dir, - }; - use std::fs; - use tempfile::tempdir; - - let _lock = registry_test_lock(); - let dir = tempdir().unwrap(); - let path = dir.path().join("soon-gone.json"); - fs::write( - &path, - r#"{"id":"soon-gone","label":"Gone","command":"soon-gone-bin"}"#, - ) - .unwrap(); - warm_harness_registry_from_dir(Some(dir.path())); - - // Verify it resolves before delete. - let record = record_with(Some("soon-gone"), None, None); - assert!( - try_record_agent_command(&record, &[]).is_ok(), - "must resolve before delete" - ); - - // Simulate delete + re-warm (as delete_custom_harness does). - fs::remove_file(&path).unwrap(); - warm_harness_registry_from_dir(Some(dir.path())); - - // Now must produce a typed error. - let result = try_record_agent_command(&record, &[]); - assert!( - result.is_err(), - "deleted id must produce Err after re-warm, got Ok({:?})", - result.ok() - ); - assert!( - result.unwrap_err().contains("DANGLING_HARNESS_ID"), - "error must contain DANGLING_HARNESS_ID" - ); -} - -/// After saving (writing) a harness JSON and re-warming, the record resolves -/// to the new definition — simulating an immediate-save-then-start flow. -#[test] -fn registry_save_immediate_start_resolves_new_command() { - use crate::managed_agents::custom_harnesses::{ - registry_test_lock, warm_harness_registry_from_dir, - }; - use std::fs; - use tempfile::tempdir; - - let _lock = registry_test_lock(); - let dir = tempdir().unwrap(); - - // Before save: must not resolve. - warm_harness_registry_from_dir(Some(dir.path())); - let record = record_with(Some("fast-harness"), None, None); - assert!( - try_record_agent_command(&record, &[]).is_err(), - "must not resolve before save" - ); - - // Simulate save + transactional re-warm. - fs::write( - dir.path().join("fast-harness.json"), - r#"{"id":"fast-harness","label":"Fast","command":"fast-bin"}"#, - ) - .unwrap(); - warm_harness_registry_from_dir(Some(dir.path())); - - let result = try_record_agent_command(&record, &[]); - assert_eq!( - result, - Ok("fast-bin".to_string()), - "immediate save+start must resolve without a discover_acp_providers round-trip" - ); -} - -/// Editing a custom harness (renaming id + updating command) and re-warming the -/// registry makes both the old id a dangling reference and the new id resolvable. -#[test] -fn registry_edit_with_id_rename_old_dangling_new_resolved() { - use crate::managed_agents::custom_harnesses::{ - registry_test_lock, warm_harness_registry_from_dir, - }; - use std::fs; - use tempfile::tempdir; - - // Serialize against all other tests that write to the global registry. - let _lock = registry_test_lock(); - let dir = tempdir().unwrap(); - - // Create original. - fs::write( - dir.path().join("original-id.json"), - r#"{"id":"original-id","label":"Orig","command":"orig-bin"}"#, - ) - .unwrap(); - warm_harness_registry_from_dir(Some(dir.path())); - - let old_record = record_with(Some("original-id"), None, None); - assert!( - try_record_agent_command(&old_record, &[]).is_ok(), - "original id must resolve" - ); - - // Simulate rename: write new file, remove old file (same as save_custom_harness - // with original_id set), then re-warm. - fs::write( - dir.path().join("renamed-id.json"), - r#"{"id":"renamed-id","label":"Renamed","command":"new-bin"}"#, - ) - .unwrap(); - fs::remove_file(dir.path().join("original-id.json")).unwrap(); - warm_harness_registry_from_dir(Some(dir.path())); - - // Old id must now be dangling. - let result = try_record_agent_command(&old_record, &[]); - assert!( - result.is_err() && result.unwrap_err().contains("DANGLING_HARNESS_ID"), - "original id must be dangling after rename" - ); - - // New id must resolve. - let new_record = record_with(Some("renamed-id"), None, None); - assert_eq!( - try_record_agent_command(&new_record, &[]), - Ok("new-bin".to_string()), - "new id must resolve after rename" - ); -} - -// ── Dangling-harness sentinel → user-facing surfaces ───────────────────────── - -/// The internal `DANGLING_HARNESS_ID:` sentinel round-trips through -/// `dangling_harness_id` and is never confused with other error strings. -#[test] -fn dangling_harness_id_extracts_id_and_rejects_other_errors() { - use super::{dangling_harness_id, DANGLING_HARNESS_PREFIX}; - assert_eq!( - dangling_harness_id(&format!("{DANGLING_HARNESS_PREFIX}my-harness")), - Some("my-harness") - ); - assert_eq!(dangling_harness_id("some other error"), None); - assert_eq!(dangling_harness_id(""), None); - // Prefix must anchor at the start — a wrapped sentinel is not a sentinel. - assert_eq!( - dangling_harness_id("cannot spawn: DANGLING_HARNESS_ID:x"), - None - ); -} - -/// Spawn surfaces the sentinel as an actionable sentence naming the id; -/// non-sentinel errors pass through untouched. -#[test] -fn user_facing_harness_error_converts_sentinel_to_sentence() { - use super::{user_facing_harness_error, DANGLING_HARNESS_PREFIX}; - let msg = user_facing_harness_error(&format!("{DANGLING_HARNESS_PREFIX}foo")); - assert!( - msg.contains("\"foo\"") && msg.contains("deleted"), - "sentence must name the missing harness, got: {msg}" - ); - assert!( - !msg.contains(DANGLING_HARNESS_PREFIX), - "raw sentinel must never reach the user, got: {msg}" - ); - assert_eq!( - user_facing_harness_error("plain failure"), - "plain failure", - "non-sentinel errors pass through" - ); -} - -/// Composed coherence test (delete → summary display → spawn sentence): after -/// a harness is deleted, the single resolver errors with the sentinel, the -/// summary path renders the *missing id* (not a silent buzz-agent fallback), -/// and the spawn path renders the actionable sentence — both halves tell the -/// same story from the same error. -#[test] -fn deleted_harness_summary_display_and_spawn_sentence_agree() { - use crate::managed_agents::custom_harnesses::{ - delete_and_warm, registry_test_lock, save_and_warm, - }; - use crate::managed_agents::{resolve_effective_harness_descriptor, GlobalAgentConfig}; - - let _lock = registry_test_lock(); - let dir = tempfile::tempdir().unwrap(); - - // Save a custom harness and pin a record to it. - let def = crate::managed_agents::custom_harnesses::HarnessDefinition { - id: "doomed".to_string(), - label: "Doomed".to_string(), - command: "doomed-bin".to_string(), - args: vec![], - env: Default::default(), - install_instructions_url: String::new(), - install_hint: String::new(), - }; - save_and_warm(dir.path(), &def, None).unwrap(); - let record = record_with(Some("doomed"), None, None); - let global = GlobalAgentConfig::default(); - assert!( - resolve_effective_harness_descriptor(&record, &[], &global).is_ok(), - "must resolve before delete" - ); - - // Delete it — the shared resolver used by BOTH spawn and summary errors. - delete_and_warm(dir.path(), "doomed").unwrap(); - let err = resolve_effective_harness_descriptor(&record, &[], &global).unwrap_err(); - - // Summary half: renders the missing id, never the default command. - let id = super::dangling_harness_id(&err).expect("resolver must emit the typed sentinel"); - let display = super::dangling_harness_display(id); - assert_eq!(display, "harness (deleted): doomed"); - assert!(!display.contains(&default_agent_command())); - - // Spawn half: renders a sentence naming the same id, no raw sentinel. - let sentence = super::user_facing_harness_error(&err); - assert!(sentence.contains("\"doomed\"") && sentence.contains("deleted")); - assert!(!sentence.contains(super::DANGLING_HARNESS_PREFIX)); -} - -// ── I2: custom catalog entry carries definition_env for the edit round-trip ─── - -/// A custom harness definition that includes env vars must surface those vars -/// in the `definition_env` field of the resulting `AcpRuntimeCatalogEntry`. -/// -/// This proves the edit-form round-trip: the backend carries env into the -/// catalog, the frontend reads it back when opening the edit form, and Save -/// therefore preserves existing env vars rather than silently erasing them. -#[test] -fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() { - use crate::managed_agents::custom_harnesses::registry_test_lock; - use crate::managed_agents::discovery::discover_acp_runtimes_from; - use std::{collections::BTreeMap, fs}; - use tempfile::tempdir; - - // Discovery's auth probes read/warm the process-global PATH and - // login-shell-PATH caches, and its final step publishes to the global - // harness registry — hold both guards so parallel tests (e.g. the - // PATH-swapping resolution tests) can't observe or absorb torn state. - // Lock order for tests that need both: path lock first, then registry. - let _path_guard = crate::managed_agents::lock_path_mutex(); - let _lock = registry_test_lock(); - let dir = tempdir().unwrap(); - // Write a custom definition with two env vars. - fs::write( - dir.path().join("env-harness.json"), - r#"{ - "id": "env-harness", - "label": "Env Harness", - "command": "env-harness-bin", - "args": [], - "env": { "CURSOR_ACP": "1", "MY_TOKEN": "abc" } - }"#, - ) - .unwrap(); - - let entries = discover_acp_runtimes_from(Some(dir.path()), true); - let entry = entries - .iter() - .find(|e| e.id == "env-harness") - .expect("custom entry must appear in catalog"); - - let expected: BTreeMap = [ - ("CURSOR_ACP".to_string(), "1".to_string()), - ("MY_TOKEN".to_string(), "abc".to_string()), - ] - .into_iter() - .collect(); - - assert_eq!( - entry.definition_env, expected, - "catalog entry must carry definition env vars so the edit form can read them back" - ); -} - -/// A builtin catalog entry must have an empty `definition_env` — their env -/// is handled via the `KnownAcpRuntime` metadata path, not user-editable JSON. -#[test] -fn builtin_catalog_entry_has_empty_definition_env() { - use crate::managed_agents::custom_harnesses::registry_test_lock; - use crate::managed_agents::discovery::discover_acp_runtimes_from; - - // Same guards as above: discovery probes PATH-dependent caches and - // publishes to the global registry. - let _path_guard = crate::managed_agents::lock_path_mutex(); - let _lock = registry_test_lock(); - let entries = discover_acp_runtimes_from(None, true); - // Find any builtin entry (e.g. "goose" or "claude"). - let builtin = entries - .iter() - .find(|e| e.source == crate::managed_agents::HarnessSource::Builtin) - .expect("at least one builtin must exist"); - - assert!( - builtin.definition_env.is_empty(), - "builtin entry must not carry definition_env, got: {:?}", - builtin.definition_env - ); -} - -// ── Discovery publish via the PRODUCTION call path (stale-snapshot regression) ─ -// -// These drive `discover_acp_runtimes_from` itself and land a save/delete in -// the window between its directory scan and its registry publish (via the -// `pre_publish_test_hook` seam). They red if discovery's final line reverts -// to publishing its pre-probe `loaded_defs` snapshot — the original bug — -// unlike the `custom_harnesses` seam tests, which pin only the fresh-read -// contract of `warm_harness_registry_locked`. - -/// RAII guard: installs the pre-publish hook, clears it on drop (even on -/// panic) so a failing test cannot poison later ones. -struct PrePublishHookGuard; - -impl PrePublishHookGuard { - fn install(hook: Box) -> Self { - super::pre_publish_test_hook::set(Some(hook)); - PrePublishHookGuard - } -} - -impl Drop for PrePublishHookGuard { - fn drop(&mut self) { - super::pre_publish_test_hook::set(None); - } -} - -fn harness_def( - id: &str, - label: &str, - command: &str, -) -> crate::managed_agents::custom_harnesses::HarnessDefinition { - crate::managed_agents::custom_harnesses::HarnessDefinition { - id: id.to_string(), - label: label.to_string(), - command: command.to_string(), - args: vec![], - env: Default::default(), - install_instructions_url: String::new(), - install_hint: String::new(), - } -} -/// A `save_and_warm` landing mid-discovery (after the scan, before the -/// publish) must survive discovery's registry publish — through the real -/// `discover_acp_runtimes_from` path. -#[test] -fn discovery_publish_path_survives_mid_flight_save() { - use crate::managed_agents::custom_harnesses::{ - lookup_loaded_harness_by_id, registry_test_lock, save_and_warm, - }; - use crate::managed_agents::discovery::discover_acp_runtimes_from; - - // Path lock first, then registry — discovery's probes touch the global - // PATH caches (see the definition_env tests above for the full rationale). - let _path_guard = crate::managed_agents::lock_path_mutex(); - let _lock = registry_test_lock(); - let dir = tempfile::tempdir().unwrap(); - - // Discovery scans the dir while it is EMPTY; the save lands in the - // pre-publish window. A stale-snapshot publish would clobber it. - let hook_dir = dir.path().to_path_buf(); - let _guard = PrePublishHookGuard::install(Box::new(move || { - let def = harness_def("mid-flight-save", "Mid Flight", "mid-flight-bin"); - save_and_warm(&hook_dir, &def, None).unwrap(); - assert!(lookup_loaded_harness_by_id("mid-flight-save").is_some()); - })); - - let _entries = discover_acp_runtimes_from(Some(dir.path()), true); - - assert!( - lookup_loaded_harness_by_id("mid-flight-save").is_some(), - "discovery's publish must re-read the directory — a stale-snapshot \ - publish clobbers a save that landed mid-discovery" - ); -} -/// A `delete_and_warm` landing mid-discovery must stay gone after discovery's -/// publish — a stale snapshot (taken while the file existed) would resurrect it. -#[test] -fn discovery_publish_path_drops_mid_flight_delete() { - use crate::managed_agents::custom_harnesses::{ - delete_and_warm, lookup_loaded_harness_by_id, registry_test_lock, save_and_warm, - }; - use crate::managed_agents::discovery::discover_acp_runtimes_from; - - // Path lock first, then registry (same rationale as the sibling test). - let _path_guard = crate::managed_agents::lock_path_mutex(); - let _lock = registry_test_lock(); - let dir = tempfile::tempdir().unwrap(); - - // File exists at scan time — discovery's snapshot would contain it. - let def = harness_def("mid-flight-delete", "Mid Flight Del", "mid-flight-del-bin"); - save_and_warm(dir.path(), &def, None).unwrap(); - - let hook_dir = dir.path().to_path_buf(); - let _guard = PrePublishHookGuard::install(Box::new(move || { - delete_and_warm(&hook_dir, "mid-flight-delete").unwrap(); - assert!(lookup_loaded_harness_by_id("mid-flight-delete").is_none()); - })); - - let _entries = discover_acp_runtimes_from(Some(dir.path()), true); - - assert!( - lookup_loaded_harness_by_id("mid-flight-delete").is_none(), - "discovery's publish must not resurrect a harness deleted mid-discovery" - ); -} +mod harness_registry; diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/harness_registry.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/harness_registry.rs new file mode 100644 index 00000000000..35e3c4f4ba9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/harness_registry.rs @@ -0,0 +1,458 @@ +use super::record_with; +use crate::managed_agents::discovery::{default_agent_command, try_record_agent_command}; + +// ── Registry lifecycle (C1) ─────────────────────────────────────────────────── +// +// These tests verify the "warm → spawn resolves → delete → spawn errors" lifecycle +// that Paul's C1 ruling requires. They call the production resolution functions +// directly so they would red if warm_harness_registry_from_dir, save/delete +// transactional refresh, or try_record_agent_command were reverted. + +/// After warm_harness_registry_from_dir, a record with a matching custom runtime +/// id resolves to the custom command — NOT the buzz-agent default. +/// +/// This test would fail if warm_harness_registry_from_dir is not called before +/// try_record_agent_command, or if try_record_agent_command ignores the registry. +#[test] +fn registry_warm_then_try_record_resolves_custom_id() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("my-custom-cli.json"), + r#"{"id":"my-custom-cli","label":"My CLI","command":"my-custom-bin"}"#, + ) + .unwrap(); + + warm_harness_registry_from_dir(Some(dir.path())); + + let record = record_with(Some("my-custom-cli"), None, None); + let result = try_record_agent_command(&record, &[]); + assert_eq!( + result, + Ok("my-custom-bin".to_string()), + "warm registry must make custom id resolvable at spawn time" + ); +} + +/// After deleting a custom harness and re-warming the registry, a record that +/// still references the deleted id must produce a DANGLING_HARNESS_ID error — +/// NOT silently fall back to buzz-agent. +/// +/// This test would fail if save/delete commands do not call +/// warm_harness_registry_from_dir transactionally, or if try_record_agent_command +/// silently falls back to default_agent_command() for dangling ids. +#[test] +fn registry_delete_then_try_record_returns_dangling_error() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + let path = dir.path().join("soon-gone.json"); + fs::write( + &path, + r#"{"id":"soon-gone","label":"Gone","command":"soon-gone-bin"}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + // Verify it resolves before delete. + let record = record_with(Some("soon-gone"), None, None); + assert!( + try_record_agent_command(&record, &[]).is_ok(), + "must resolve before delete" + ); + + // Simulate delete + re-warm (as delete_custom_harness does). + fs::remove_file(&path).unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + // Now must produce a typed error. + let result = try_record_agent_command(&record, &[]); + assert!( + result.is_err(), + "deleted id must produce Err after re-warm, got Ok({:?})", + result.ok() + ); + assert!( + result.unwrap_err().contains("DANGLING_HARNESS_ID"), + "error must contain DANGLING_HARNESS_ID" + ); +} + +/// After saving (writing) a harness JSON and re-warming, the record resolves +/// to the new definition — simulating an immediate-save-then-start flow. +#[test] +fn registry_save_immediate_start_resolves_new_command() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + + // Before save: must not resolve. + warm_harness_registry_from_dir(Some(dir.path())); + let record = record_with(Some("fast-harness"), None, None); + assert!( + try_record_agent_command(&record, &[]).is_err(), + "must not resolve before save" + ); + + // Simulate save + transactional re-warm. + fs::write( + dir.path().join("fast-harness.json"), + r#"{"id":"fast-harness","label":"Fast","command":"fast-bin"}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let result = try_record_agent_command(&record, &[]); + assert_eq!( + result, + Ok("fast-bin".to_string()), + "immediate save+start must resolve without a discover_acp_providers round-trip" + ); +} + +/// Editing a custom harness (renaming id + updating command) and re-warming the +/// registry makes both the old id a dangling reference and the new id resolvable. +#[test] +fn registry_edit_with_id_rename_old_dangling_new_resolved() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, warm_harness_registry_from_dir, + }; + use std::fs; + use tempfile::tempdir; + + // Serialize against all other tests that write to the global registry. + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + + // Create original. + fs::write( + dir.path().join("original-id.json"), + r#"{"id":"original-id","label":"Orig","command":"orig-bin"}"#, + ) + .unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + let old_record = record_with(Some("original-id"), None, None); + assert!( + try_record_agent_command(&old_record, &[]).is_ok(), + "original id must resolve" + ); + + // Simulate rename: write new file, remove old file (same as save_custom_harness + // with original_id set), then re-warm. + fs::write( + dir.path().join("renamed-id.json"), + r#"{"id":"renamed-id","label":"Renamed","command":"new-bin"}"#, + ) + .unwrap(); + fs::remove_file(dir.path().join("original-id.json")).unwrap(); + warm_harness_registry_from_dir(Some(dir.path())); + + // Old id must now be dangling. + let result = try_record_agent_command(&old_record, &[]); + assert!( + result.is_err() && result.unwrap_err().contains("DANGLING_HARNESS_ID"), + "original id must be dangling after rename" + ); + + // New id must resolve. + let new_record = record_with(Some("renamed-id"), None, None); + assert_eq!( + try_record_agent_command(&new_record, &[]), + Ok("new-bin".to_string()), + "new id must resolve after rename" + ); +} + +// ── Dangling-harness sentinel → user-facing surfaces ───────────────────────── + +/// The internal `DANGLING_HARNESS_ID:` sentinel round-trips through +/// `dangling_harness_id` and is never confused with other error strings. +#[test] +fn dangling_harness_id_extracts_id_and_rejects_other_errors() { + use super::super::{dangling_harness_id, DANGLING_HARNESS_PREFIX}; + assert_eq!( + dangling_harness_id(&format!("{DANGLING_HARNESS_PREFIX}my-harness")), + Some("my-harness") + ); + assert_eq!(dangling_harness_id("some other error"), None); + assert_eq!(dangling_harness_id(""), None); + // Prefix must anchor at the start — a wrapped sentinel is not a sentinel. + assert_eq!( + dangling_harness_id("cannot spawn: DANGLING_HARNESS_ID:x"), + None + ); +} + +/// Spawn surfaces the sentinel as an actionable sentence naming the id; +/// non-sentinel errors pass through untouched. +#[test] +fn user_facing_harness_error_converts_sentinel_to_sentence() { + use super::super::{user_facing_harness_error, DANGLING_HARNESS_PREFIX}; + let msg = user_facing_harness_error(&format!("{DANGLING_HARNESS_PREFIX}foo")); + assert!( + msg.contains("\"foo\"") && msg.contains("deleted"), + "sentence must name the missing harness, got: {msg}" + ); + assert!( + !msg.contains(DANGLING_HARNESS_PREFIX), + "raw sentinel must never reach the user, got: {msg}" + ); + assert_eq!( + user_facing_harness_error("plain failure"), + "plain failure", + "non-sentinel errors pass through" + ); +} + +/// Composed coherence test (delete → summary display → spawn sentence): after +/// a harness is deleted, the single resolver errors with the sentinel, the +/// summary path renders the *missing id* (not a silent buzz-agent fallback), +/// and the spawn path renders the actionable sentence — both halves tell the +/// same story from the same error. +#[test] +fn deleted_harness_summary_display_and_spawn_sentence_agree() { + use crate::managed_agents::custom_harnesses::{ + delete_and_warm, registry_test_lock, save_and_warm, + }; + use crate::managed_agents::{resolve_effective_harness_descriptor, GlobalAgentConfig}; + + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // Save a custom harness and pin a record to it. + let def = crate::managed_agents::custom_harnesses::HarnessDefinition { + id: "doomed".to_string(), + label: "Doomed".to_string(), + command: "doomed-bin".to_string(), + args: vec![], + env: Default::default(), + install_instructions_url: String::new(), + install_hint: String::new(), + }; + save_and_warm(dir.path(), &def, None).unwrap(); + let record = record_with(Some("doomed"), None, None); + let global = GlobalAgentConfig::default(); + assert!( + resolve_effective_harness_descriptor(&record, &[], &global).is_ok(), + "must resolve before delete" + ); + + // Delete it — the shared resolver used by BOTH spawn and summary errors. + delete_and_warm(dir.path(), "doomed").unwrap(); + let err = resolve_effective_harness_descriptor(&record, &[], &global).unwrap_err(); + + // Summary half: renders the missing id, never the default command. + let id = + super::super::dangling_harness_id(&err).expect("resolver must emit the typed sentinel"); + let display = super::super::dangling_harness_display(id); + assert_eq!(display, "harness (deleted): doomed"); + assert!(!display.contains(&default_agent_command())); + + // Spawn half: renders a sentence naming the same id, no raw sentinel. + let sentence = super::super::user_facing_harness_error(&err); + assert!(sentence.contains("\"doomed\"") && sentence.contains("deleted")); + assert!(!sentence.contains(super::super::DANGLING_HARNESS_PREFIX)); +} + +// ── I2: custom catalog entry carries definition_env for the edit round-trip ─── + +/// A custom harness definition that includes env vars must surface those vars +/// in the `definition_env` field of the resulting `AcpRuntimeCatalogEntry`. +/// +/// This proves the edit-form round-trip: the backend carries env into the +/// catalog, the frontend reads it back when opening the edit form, and Save +/// therefore preserves existing env vars rather than silently erasing them. +#[test] +fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + use std::{collections::BTreeMap, fs}; + use tempfile::tempdir; + + // Discovery's auth probes read/warm the process-global PATH and + // login-shell-PATH caches, and its final step publishes to the global + // harness registry — hold both guards so parallel tests (e.g. the + // PATH-swapping resolution tests) can't observe or absorb torn state. + // Lock order for tests that need both: path lock first, then registry. + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let dir = tempdir().unwrap(); + // Write a custom definition with two env vars. + fs::write( + dir.path().join("env-harness.json"), + r#"{ + "id": "env-harness", + "label": "Env Harness", + "command": "env-harness-bin", + "args": [], + "env": { "CURSOR_ACP": "1", "MY_TOKEN": "abc" } + }"#, + ) + .unwrap(); + + let entries = discover_acp_runtimes_from(Some(dir.path()), true); + let entry = entries + .iter() + .find(|e| e.id == "env-harness") + .expect("custom entry must appear in catalog"); + + let expected: BTreeMap = [ + ("CURSOR_ACP".to_string(), "1".to_string()), + ("MY_TOKEN".to_string(), "abc".to_string()), + ] + .into_iter() + .collect(); + + assert_eq!( + entry.definition_env, expected, + "catalog entry must carry definition env vars so the edit form can read them back" + ); +} + +/// A builtin catalog entry must have an empty `definition_env` — their env +/// is handled via the `KnownAcpRuntime` metadata path, not user-editable JSON. +#[test] +fn builtin_catalog_entry_has_empty_definition_env() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + + // Same guards as above: discovery probes PATH-dependent caches and + // publishes to the global registry. + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let entries = discover_acp_runtimes_from(None, true); + // Find any builtin entry (e.g. "goose" or "claude"). + let builtin = entries + .iter() + .find(|e| e.source == crate::managed_agents::HarnessSource::Builtin) + .expect("at least one builtin must exist"); + + assert!( + builtin.definition_env.is_empty(), + "builtin entry must not carry definition_env, got: {:?}", + builtin.definition_env + ); +} + +// ── Discovery publish via the PRODUCTION call path (stale-snapshot regression) ─ +// +// These drive `discover_acp_runtimes_from` itself and land a save/delete in +// the window between its directory scan and its registry publish (via the +// `pre_publish_test_hook` seam). They red if discovery's final line reverts +// to publishing its pre-probe `loaded_defs` snapshot — the original bug — +// unlike the `custom_harnesses` seam tests, which pin only the fresh-read +// contract of `warm_harness_registry_locked`. + +/// RAII guard: installs the pre-publish hook, clears it on drop (even on +/// panic) so a failing test cannot poison later ones. +struct PrePublishHookGuard; + +impl PrePublishHookGuard { + fn install(hook: Box) -> Self { + super::super::pre_publish_test_hook::set(Some(hook)); + PrePublishHookGuard + } +} + +impl Drop for PrePublishHookGuard { + fn drop(&mut self) { + super::super::pre_publish_test_hook::set(None); + } +} + +fn harness_def( + id: &str, + label: &str, + command: &str, +) -> crate::managed_agents::custom_harnesses::HarnessDefinition { + crate::managed_agents::custom_harnesses::HarnessDefinition { + id: id.to_string(), + label: label.to_string(), + command: command.to_string(), + args: vec![], + env: Default::default(), + install_instructions_url: String::new(), + install_hint: String::new(), + } +} +/// A `save_and_warm` landing mid-discovery (after the scan, before the +/// publish) must survive discovery's registry publish — through the real +/// `discover_acp_runtimes_from` path. +#[test] +fn discovery_publish_path_survives_mid_flight_save() { + use crate::managed_agents::custom_harnesses::{ + lookup_loaded_harness_by_id, registry_test_lock, save_and_warm, + }; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + + // Path lock first, then registry — discovery's probes touch the global + // PATH caches (see the definition_env tests above for the full rationale). + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // Discovery scans the dir while it is EMPTY; the save lands in the + // pre-publish window. A stale-snapshot publish would clobber it. + let hook_dir = dir.path().to_path_buf(); + let _guard = PrePublishHookGuard::install(Box::new(move || { + let def = harness_def("mid-flight-save", "Mid Flight", "mid-flight-bin"); + save_and_warm(&hook_dir, &def, None).unwrap(); + assert!(lookup_loaded_harness_by_id("mid-flight-save").is_some()); + })); + + let _entries = discover_acp_runtimes_from(Some(dir.path()), true); + + assert!( + lookup_loaded_harness_by_id("mid-flight-save").is_some(), + "discovery's publish must re-read the directory — a stale-snapshot \ + publish clobbers a save that landed mid-discovery" + ); +} +/// A `delete_and_warm` landing mid-discovery must stay gone after discovery's +/// publish — a stale snapshot (taken while the file existed) would resurrect it. +#[test] +fn discovery_publish_path_drops_mid_flight_delete() { + use crate::managed_agents::custom_harnesses::{ + delete_and_warm, lookup_loaded_harness_by_id, registry_test_lock, save_and_warm, + }; + use crate::managed_agents::discovery::discover_acp_runtimes_from; + + // Path lock first, then registry (same rationale as the sibling test). + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // File exists at scan time — discovery's snapshot would contain it. + let def = harness_def("mid-flight-delete", "Mid Flight Del", "mid-flight-del-bin"); + save_and_warm(dir.path(), &def, None).unwrap(); + + let hook_dir = dir.path().to_path_buf(); + let _guard = PrePublishHookGuard::install(Box::new(move || { + delete_and_warm(&hook_dir, "mid-flight-delete").unwrap(); + assert!(lookup_loaded_harness_by_id("mid-flight-delete").is_none()); + })); + + let _entries = discover_acp_runtimes_from(Some(dir.path()), true); + + assert!( + lookup_loaded_harness_by_id("mid-flight-delete").is_none(), + "discovery's publish must not resurrect a harness deleted mid-discovery" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 76bed798d7e..1541ebb49c8 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -8,6 +8,7 @@ fn definition( prompt: &str, ) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Definition".to_string(), avatar_url: None, @@ -40,6 +41,7 @@ fn record( ) -> ManagedAgentRecord { use crate::managed_agents::{BackendKind, RespondTo}; ManagedAgentRecord { + description: None, pubkey: "agent-pk".to_string(), name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index cb2cc8f2546..f28774c0e8f 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -299,6 +299,7 @@ fn default_global_config_serializes_all_fields() { fn bare_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: None, @@ -363,6 +364,7 @@ fn bare_record() -> ManagedAgentRecord { fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Persona".to_string(), avatar_url: None, @@ -625,6 +627,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { record.persona_id = Some("p1".to_string()); let persona = AgentDefinition { + description: None, id: "p1".to_string(), display_name: "Goose persona".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 9910894819a..fdfd24939db 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -10,6 +10,8 @@ pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_ac pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; +mod agent_description; +pub(crate) use agent_description::{effective_agent_description, record_effective_description}; mod backend; mod backend_migration; pub(crate) mod claude_config; @@ -63,7 +65,8 @@ pub use backend::*; pub use backend_migration::*; pub use community_scope::*; pub(crate) use definition_validation::{ - validate_agent_definition_text, validate_managed_agent_definition_text, validate_visible_text, + validate_agent_definition_text, validate_agent_description_text, + validate_managed_agent_definition_text, validate_visible_text, }; pub use discovery::*; pub use env_vars::*; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index feea91e627a..35834be5ddc 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -11,6 +11,7 @@ const TEST_RELAY: &str = "ws://example.com:3000"; fn make_persona(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: display_name.to_string(), avatar_url: None, @@ -37,6 +38,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), name: name.to_string(), persona_id: persona_id.map(|s| s.to_string()), diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index f1098d7e6e3..cfe474fa90a 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -64,6 +64,7 @@ mod tests { fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), name: "r".to_string(), persona_id: None, @@ -132,6 +133,7 @@ mod tests { ) -> crate::managed_agents::types::AgentDefinition { use crate::managed_agents::types::AgentDefinition; AgentDefinition { + description: None, id: id.to_string(), display_name: String::new(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 173673de787..ec725dc3808 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -100,6 +100,14 @@ pub struct PersonaEventContent { pub respond_to_allowlist: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub parallelism: Option, + /// Optional short, PUBLIC description (max 280 chars). Appended after the + /// pre-existing fields so records without one serialize byte-identically + /// to the pre-description era — existing content bytes and event ids are + /// unchanged. EXCLUDED from [`persona_content_hash`]: description is + /// display metadata, not spawn-relevant config, so a description-only edit + /// must not badge linked instances as needing a restart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Derive the d-tag (persona slug) from a `AgentDefinition`. @@ -237,6 +245,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result String { use sha2::{Digest, Sha256}; - let json = serde_json::to_vec(content).unwrap_or_default(); + let hashed = PersonaEventContent { + description: None, + ..content.clone() + }; + let json = serde_json::to_vec(&hashed).unwrap_or_default(); let digest = Sha256::digest(&json); hex::encode(digest) } @@ -579,6 +597,7 @@ pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { respond_to: record.respond_to.clone(), respond_to_allowlist: record.respond_to_allowlist.clone(), parallelism: record.parallelism, + description: record.description.clone(), } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 6a4270fc4ca..fd3b82841de 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -5,6 +5,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// state right after creation, before any snapshot apply. pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".repeat(64), name: "agent".into(), persona_id: Some("test-persona".into()), @@ -147,6 +148,7 @@ fn preview_passes_through_unchanged_when_persona_missing() { pub(super) fn sample_persona() -> AgentDefinition { AgentDefinition { + description: None, id: "test-persona".to_string(), display_name: "Test Persona".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -322,6 +324,7 @@ fn content_matches_nip_ap_vector() { const VECTOR: &str = r#"{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}"#; let content = PersonaEventContent { + description: None, display_name: "Test Agent".to_string(), system_prompt: Some("You are a test assistant.".to_string()), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -375,6 +378,7 @@ fn content_matches_nip_ap_vector() { // signed content, so a second implementer following the spec computes // the same NIP-01 id. let record = AgentDefinition { + description: None, id: "test-agent".to_string(), display_name: "Test Agent".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -407,6 +411,7 @@ fn content_matches_nip_ap_vector() { #[test] fn round_trip_minimal_persona() { let record = AgentDefinition { + description: None, id: "minimal".to_string(), display_name: "Minimal".to_string(), avatar_url: None, @@ -505,6 +510,7 @@ fn behavioral_defaults_survive_record_round_trip() { #[test] fn quad_absent_definition_hash_stable_across_activation() { let record = AgentDefinition { + description: None, id: "quad-absent".to_string(), display_name: "Test".to_string(), avatar_url: None, @@ -550,6 +556,7 @@ fn quad_absent_definition_hash_stable_across_activation() { /// way `persona_from_event` maps fields, without needing a signed event. fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDefinition { AgentDefinition { + description: content.description, id: "staged".to_string(), display_name: content.display_name, avatar_url: content.avatar_url, @@ -577,6 +584,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef #[test] fn persona_content_hash_is_deterministic() { let content = PersonaEventContent { + description: None, display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), @@ -597,6 +605,7 @@ fn persona_content_hash_is_deterministic() { #[test] fn persona_content_hash_changes_on_edit() { let content1 = PersonaEventContent { + description: None, display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), @@ -616,6 +625,42 @@ fn persona_content_hash_changes_on_edit() { ); } +/// `description` is public display metadata, deliberately excluded from +/// `persona_content_hash`: two contents differing only in description must +/// hash identically, so a description-only edit never flips the +/// "restart required" drift badge on linked instances. +#[test] +fn description_change_does_not_change_content_hash() { + let without = PersonaEventContent { + description: None, + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: Some("Hello".to_string()), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + }; + let mut with = without.clone(); + with.description = Some("A friendly test agent.".to_string()); + assert_eq!( + persona_content_hash(&without), + persona_content_hash(&with), + "description must not participate in the content hash" + ); + + let mut edited = with.clone(); + edited.description = Some("A different description.".to_string()); + assert_eq!( + persona_content_hash(&with), + persona_content_hash(&edited), + "description-only edits must not change the content hash" + ); +} + // ── PersonaSnapshot.runtime ─────────────────────────────────────────────── /// (b) The snapshot carries the persona's runtime VERBATIM — including None, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 3c8a40231d4..094d0a1a478 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -124,6 +124,7 @@ fn built_in_persona_records(now: &str) -> Vec { id: persona.id.to_string(), display_name: persona.display_name.to_string(), avatar_url: persona.avatar_url.map(|s| s.to_string()), + description: None, system_prompt: persona.system_prompt.to_string(), runtime: persona.runtime.map(|s| s.to_string()), model: persona.model.map(|s| s.to_string()), diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 1fd8c3bccff..a52f6aa3b19 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -8,6 +8,7 @@ use crate::managed_agents::AgentDefinition; fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/record_views.rs b/desktop/src-tauri/src/managed_agents/record_views.rs index f1eb28cad6e..88935d6d914 100644 --- a/desktop/src-tauri/src/managed_agents/record_views.rs +++ b/desktop/src-tauri/src/managed_agents/record_views.rs @@ -59,6 +59,7 @@ impl AgentDefinition { respond_to: RespondTo::default(), respond_to_allowlist: Vec::new(), display_name: Some(self.display_name), + description: self.description, slug: Some(self.id), runtime: self.runtime, name_pool: self.name_pool, @@ -93,6 +94,7 @@ impl ManagedAgentRecord { .clone() .unwrap_or_else(|| self.name.clone()), avatar_url: self.avatar_url.clone(), + description: self.description.clone(), system_prompt: self.system_prompt.clone().unwrap_or_default(), runtime: self.runtime.clone(), model: self.model.clone(), diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index a225f492d33..881ac99237a 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -454,6 +454,10 @@ pub async fn restore_managed_agents_on_launch( pubkey: record.pubkey.clone(), agent_command: effective_command, persona_id: record.persona_id.clone(), + about: crate::managed_agents::record_effective_description( + record, + &reconcile_personas, + ), }, )) }) diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 11ec5824b7a..08dfaa5fc8c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -36,6 +36,7 @@ pub(super) fn fixture( auth_tag: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".into(), name: "n".into(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 24fad1461c5..b0c93289709 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -265,7 +265,6 @@ fn build_env_rejects_empty_allowlist_in_allowlist_mode() { } // ── persona fixture helpers ───────────────────────────────────────── - fn persona_with_provider( id: &str, prompt: &str, @@ -273,6 +272,7 @@ fn persona_with_provider( provider: Option<&str>, ) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { + description: None, id: id.to_string(), display_name: id.to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 60036dd85aa..e3bea0df4d4 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -42,6 +42,7 @@ fn snap(record: &ManagedAgentRecord) -> serde_json::Value { fn record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".repeat(64), name: "agent".into(), persona_id: None, @@ -106,6 +107,7 @@ fn record() -> ManagedAgentRecord { fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.into(), display_name: id.into(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs index e0ae5fc37aa..8f9d68245de 100644 --- a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs @@ -7,6 +7,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: Some("goose".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index aafd31d37c1..9c941338856 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -254,6 +254,7 @@ mod tests { /// Build a minimal `ManagedAgentRecord` for use as a team member. fn agent_record(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: format!("{name}-pubkey"), name: name.to_string(), display_name: Some(format!("{name} Display")), diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 4937af83cb3..8fc199527d3 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -167,6 +167,7 @@ fn validate_team_deletion_rejects_built_ins() { fn managed_agent(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: name.to_string(), name: name.to_string(), persona_id: None, @@ -458,6 +459,7 @@ fn catalog_copy(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: String::new(), runtime: None, @@ -697,6 +699,7 @@ fn catalog_persona(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 7698df465ad..dfe8db9e239 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -17,6 +17,11 @@ pub struct AgentDefinition { pub id: String, pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars), shown on the + /// agent's card/profile and carried on the public kind:30175 persona + /// event. EXCLUDED from `persona_content_hash` (no restart badge). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, pub system_prompt: String, /// Preferred ACP runtime ID (e.g., 'goose', 'claude', 'codex'). Determines which agent binary /// Buzz spawns. When deploying from this persona, this runtime is pre-selected in the UI. @@ -302,6 +307,13 @@ pub struct ManagedAgentRecord { /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, + /// Optional short, PUBLIC agent description. Keyless definition records + /// carry the authored value; persona-linked instances leave it absent and + /// resolve through their definition so a second copy cannot drift. + /// Display metadata only (never spawn-relevant, never part of the persona + /// content hash). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, /// Stable definition slug — the former `AgentDefinition.id`. Key-less /// records (definitions not yet instantiated) publish kind:30175 at /// `d_tag = slug`, preserving the pre-merge event coordinates. `None` for diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 3e1afff2561..a7b379ac838 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -76,6 +76,9 @@ pub fn apply_persona_behavior( pub struct CreatePersonaRequest { pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars). + #[serde(default)] + pub description: Option, pub system_prompt: String, #[serde(default)] pub runtime: Option, @@ -103,6 +106,10 @@ pub struct UpdatePersonaRequest { pub id: String, pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars). The dialog always + /// sends the current value, so absent and empty both clear it. + #[serde(default)] + pub description: Option, pub system_prompt: String, #[serde(default)] pub runtime: Option, @@ -269,6 +276,7 @@ mod tests { fn record_without_quad() -> AgentDefinition { AgentDefinition { + description: None, id: "p-1".to_string(), display_name: "Test".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 4567d12b705..4d2ee3280ad 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -487,6 +487,7 @@ fn sample_agent_record() -> ManagedAgentRecord { fn sample_persona() -> AgentDefinition { AgentDefinition { + description: None, id: "custom:helper".to_string(), display_name: "Helper".to_string(), avatar_url: Some("https://example.com/a.png".to_string()), diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 5bc8a6e432c..2573ce2d566 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -25,6 +25,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati }, ]; let definition = crate::managed_agents::AgentDefinition { + description: None, id: "builtin:fizz".to_string(), display_name: "Fizz".to_string(), avatar_url: Some(old_fizz.to_string()), diff --git a/desktop/src-tauri/src/persona_catalog.rs b/desktop/src-tauri/src/persona_catalog.rs index 5d1717d67c3..c04afb64b4c 100644 --- a/desktop/src-tauri/src/persona_catalog.rs +++ b/desktop/src-tauri/src/persona_catalog.rs @@ -16,7 +16,8 @@ use std::sync::LazyLock; use tauri::State; use crate::{ - app_state::AppState, managed_agents::validate_agent_definition_text, + app_state::AppState, + managed_agents::{validate_agent_definition_text, validate_agent_description_text}, native_relay_client::NativeRelayClient, }; @@ -47,6 +48,8 @@ pub(crate) struct PersonaCatalogPublication { struct CatalogAgentProjection { display_name: String, avatar_url: Option, + /// Optional public description (max 280 chars, visible-text policy). + description: Option, system_prompt: String, runtime: Option, model: Option, @@ -223,6 +226,16 @@ fn parse_agent(content: &str) -> Option { .unwrap_or_default() .to_string(); validate_agent_definition_text(&display_name, &system_prompt).ok()?; + // Untrusted boundary: a description that fails the shared 280-char + + // visible-text policy rejects the whole entry rather than being stripped, + // matching how the other definition fields are handled. + let raw_description = match object.get("description") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => return None, + }; + validate_agent_description_text(raw_description.as_deref()).ok()?; + let description = raw_description.filter(|value| !value.trim().is_empty()); let respond_to = match object.get("respond_to").and_then(Value::as_str) { Some("allowlist") => Some("owner-only".to_string()), @@ -252,6 +265,7 @@ fn parse_agent(content: &str) -> Option { .and_then(Value::as_str) .filter(|value| safe_avatar(value)) .map(ToOwned::to_owned), + description, system_prompt, runtime: optional_string(object.get("runtime")), model: optional_string(object.get("model")), diff --git a/desktop/src-tauri/src/persona_catalog_tests.rs b/desktop/src-tauri/src/persona_catalog_tests.rs index d3175ef9807..64cb1ce2114 100644 --- a/desktop/src-tauri/src/persona_catalog_tests.rs +++ b/desktop/src-tauri/src/persona_catalog_tests.rs @@ -127,6 +127,31 @@ fn parser_rejects_malformed_and_invisible_definition_text() { ] { assert!(parse_agent(&content).is_none()); } + // A description that violates the shared visible-text policy or the + // 280-char cap rejects the whole entry — never silently stripped. + for bad_description in [ + "hidden\u{200b}text".to_string(), + "description\n".to_string(), + "a".repeat(281), + ] { + let mut content = valid_content("Reviewer"); + content["description"] = json!(bad_description); + assert!(parse_agent(&content.to_string()).is_none()); + } + for malformed_description in [json!(7), json!([]), json!({})] { + let mut content = valid_content("Reviewer"); + content["description"] = malformed_description; + assert!(parse_agent(&content.to_string()).is_none()); + } + let mut content = valid_content("Reviewer"); + content["description"] = json!("A careful reviewer."); + assert_eq!( + parse_agent(&content.to_string()) + .unwrap() + .description + .as_deref(), + Some("A careful reviewer.") + ); let visible = parse_agent( &json!({ "display_name": "Reviewer 🐝", @@ -204,6 +229,7 @@ fn serialized_catalog_matches_the_typescript_contract() { agent: CatalogAgentProjection { display_name: "Ada".into(), avatar_url: Some("https://example.com/a.png".into()), + description: Some("A kind agent.".into()), system_prompt: "be kind".into(), runtime: Some("acp".into()), model: Some("m1".into()), @@ -222,6 +248,7 @@ fn serialized_catalog_matches_the_typescript_contract() { "agent": { "displayName": "Ada", "avatarUrl": "https://example.com/a.png", + "description": "A kind agent.", "systemPrompt": "be kind", "runtime": "acp", "model": "m1", diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f408ef2afda..676b9656ff2 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -477,9 +477,10 @@ fn build_profile_event( agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, + about: Option<&str>, auth_tag_json: Option<&str>, ) -> Result { - let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; + let builder = crate::events::build_profile(Some(display_name), None, avatar_url, about, None)?; let builder = if let Some(tag_json) = auth_tag_json { // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding. @@ -511,18 +512,22 @@ fn build_profile_event( /// Sync a managed agent's kind:0 profile event to the relay using NIP-98 auth. /// /// The agent signs its own profile event and the NIP-98 HTTP-auth event, so no -/// API token is required. +/// API token is required. `about` carries the agent's authored public +/// description (see `managed_agents::record_effective_description`); the +/// relay treats kind:0 +/// fields as absolute, so passing `None` clears any previously published about. pub async fn sync_managed_agent_profile( state: &AppState, relay_url: &str, agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, + about: Option<&str>, auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { crate::relay_admission::wait_for_rate_limit().await; // Build a signed kind:0 profile event (with optional NIP-OA auth tag). - let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; + let event = build_profile_event(agent_keys, display_name, avatar_url, about, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; @@ -563,8 +568,9 @@ pub async fn sync_managed_agent_profile( /// backend — always the active workspace relay — so the query targets the host /// the profile is actually published to. /// -/// Returns the parsed profile content (display_name, picture) if a kind:0 event -/// exists for the given pubkey, or `None` if no profile is published. +/// Returns the parsed profile content (display_name, picture, about) if a +/// kind:0 event exists for the given pubkey, or `None` if no profile is +/// published. pub async fn query_agent_profile( state: &AppState, relay_url: &str, @@ -595,6 +601,10 @@ pub async fn query_agent_profile( .get("picture") .and_then(|v| v.as_str()) .map(str::to_string), + about: content + .get("about") + .and_then(|v| v.as_str()) + .map(str::to_string), })) } @@ -603,6 +613,8 @@ pub async fn query_agent_profile( pub struct AgentProfileInfo { pub display_name: Option, pub picture: Option, + /// Published public description (kind:0 `about`). + pub about: Option, } // ── Signed-event submission ───────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs index 4ae39249328..0fcbc891b79 100644 --- a/desktop/src-tauri/src/relay/tests.rs +++ b/desktop/src-tauri/src/relay/tests.rs @@ -569,7 +569,7 @@ fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { fn profile_event_with_valid_auth_tag() { let agent_keys = nostr::Keys::generate(); let tag_json = make_valid_auth_tag(&agent_keys); - let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) + let event = build_profile_event(&agent_keys, "TestBot", None, None, Some(&tag_json)) .expect("should succeed with a valid auth tag"); // Exactly one "auth" tag must be present. @@ -587,7 +587,7 @@ fn profile_event_with_valid_auth_tag() { #[test] fn profile_event_without_auth_tag() { let agent_keys = nostr::Keys::generate(); - let event = build_profile_event(&agent_keys, "TestBot", None, None) + let event = build_profile_event(&agent_keys, "TestBot", None, None, None) .expect("should succeed without an auth tag"); // No "auth" tags should be present. @@ -601,12 +601,41 @@ fn profile_event_without_auth_tag() { assert_eq!(event.kind, nostr::Kind::Metadata); } +#[test] +fn profile_event_includes_about_when_description_present() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event( + &agent_keys, + "TestBot", + None, + Some("A meticulous code reviewer."), + None, + ) + .expect("should succeed with an about"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("kind:0 content is JSON"); + assert_eq!( + content.get("about").and_then(|v| v.as_str()), + Some("A meticulous code reviewer.") + ); +} + +#[test] +fn profile_event_omits_about_when_absent() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event(&agent_keys, "TestBot", None, None, None) + .expect("should succeed without an about"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("kind:0 content is JSON"); + assert!(content.get("about").is_none()); +} + #[test] fn profile_event_rejects_invalid_auth_tag() { let agent_keys = nostr::Keys::generate(); // Structurally valid JSON array but with a bogus signature — verification must fail. let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); - let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); + let result = build_profile_event(&agent_keys, "TestBot", None, None, Some(&bad_json)); assert!(result.is_err(), "should reject an invalid auth tag"); assert!( result.unwrap_err().contains("verification failed"), diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164db..14d23a342a9 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -236,7 +236,38 @@ with a TypeScript lookup table or an id comparison in a component. mid-conversation effort control without a plan ruling. The archived live-effort machinery lives on `archive/claude-config-gaps-live-effort` for reference only. -12. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** +15. **The persona `description` is public display metadata.** It is optional, + capped at 280 characters, and validated through the shared visible-text + policy (`validate_agent_description_text` in `definition_validation.rs`) + on the raw authored bytes at create/update, snapshot import, publication, + inbound sync, and the untrusted catalog parser — rejected, never stripped. + It is deliberately EXCLUDED from `persona_content_hash` + (`description_change_does_not_change_content_hash`), so a description-only + edit never flips the restart badge on linked instances. Only the AUTHORED + description exists — there is deliberately no derived/generated fallback; + a blank description publishes an empty kind:0 `about`, exactly as before + the field existed. Agent and team snapshots carry the authored description + in the member profile's `about` and validate it before import. The trim/empty + resolution exists twice and must stay in + sync (port changes in the same PR): `lib/agentDescription.ts` + (`effectiveAgentDescription`) feeds display surfaces, and its Rust twin + (`managed_agents/agent_description.rs`, `effective_agent_description` / + `record_effective_description`) feeds the publish path, where + `profile_needs_sync` compares `about` (None == empty) so description edits + reconcile instead of being clobbered. Persona-linked instances do not own a + second description copy; snapshot export materializes the definition value + only into the portable snapshot, and a dangling link resolves no description + rather than reviving stale instance metadata. The agents-page card face shows the + authored description as its second line, falling back to the model label + when none exists (`UnifiedAgentsSection.tsx` composes it; + `AgentIdentityCard` takes a presentational `subtitle`). The community catalog + shows the same authored description before consent: a clamped two-line list + subtitle for scanning and the full safely wrapped value in persona detail. + The dialog field + lives in `ui/AgentDescriptionField.tsx` (`AgentIdentityFields`), not + inline in the over-1000-line dialogs. + +16. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** The compiled owner-only capability applies when Desktop starts or deploys a managed agent. Independently operated relay agents with NIP-OA ownership remain eligible in every build when their verified owner's signed @@ -250,7 +281,7 @@ with a TypeScript lookup table or an id comparison in a component. refresh only local persona/team/managed-agent caches; they must never invalidate the remote relay directory. -15. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. +17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. ## The tests that enforce this @@ -289,6 +320,11 @@ with a TypeScript lookup table or an id comparison in a component. acceptance coverage for readiness, failure states, defaults, session-draft restoration, zero-write Skip, Next save failure/retry, navigation, and successful-empty vs failed optional-model discovery. +- `desktop/tests/e2e/agents.spec.ts` — community catalog descriptions remain + visible in the list and full detail before Add agent, including long + unbroken Unicode text without horizontal overflow. +- `lib/agentDescription.test.mjs` — authored-description resolution: trim, + blank/missing → null. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. diff --git a/desktop/src/features/agents/lib/agentDescription.test.mjs b/desktop/src/features/agents/lib/agentDescription.test.mjs new file mode 100644 index 00000000000..52e9d65a53a --- /dev/null +++ b/desktop/src/features/agents/lib/agentDescription.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentDescriptionCharacterCount, + clampAgentDescription, + effectiveAgentDescription, +} from "./agentDescription.ts"; + +test("description character count matches Rust Unicode scalar counting", () => { + assert.equal(agentDescriptionCharacterCount("a🐝é"), 3); + assert.equal(agentDescriptionCharacterCount("🐝".repeat(280)), 280); +}); + +test("description clamp preserves a useful prefix for over-cap pastes", () => { + assert.equal(clampAgentDescription("a".repeat(300)), "a".repeat(280)); + assert.equal( + clampAgentDescription(`${"a".repeat(279)}🐝extra`), + `${"a".repeat(279)}🐝`, + ); +}); + +test("an authored description wins", () => { + assert.equal( + effectiveAgentDescription({ description: "Reviews desktop PRs." }), + "Reviews desktop PRs.", + ); +}); + +test("an authored description is trimmed", () => { + assert.equal( + effectiveAgentDescription({ description: " Reviews desktop PRs. " }), + "Reviews desktop PRs.", + ); +}); + +test("blank, whitespace-only, and missing descriptions yield null", () => { + assert.equal(effectiveAgentDescription({ description: "" }), null); + assert.equal(effectiveAgentDescription({ description: " " }), null); + assert.equal(effectiveAgentDescription({ description: null }), null); + assert.equal(effectiveAgentDescription({}), null); +}); diff --git a/desktop/src/features/agents/lib/agentDescription.ts b/desktop/src/features/agents/lib/agentDescription.ts new file mode 100644 index 00000000000..7a1b8ae2c0c --- /dev/null +++ b/desktop/src/features/agents/lib/agentDescription.ts @@ -0,0 +1,29 @@ +import type { AgentPersona } from "@/shared/api/types"; + +/** Hard cap on a public agent description, mirroring the Rust validator. */ +export const MAX_AGENT_DESCRIPTION_CHARS = 280; + +/** Count Unicode scalar values, matching Rust's `str::chars().count()`. */ +export function agentDescriptionCharacterCount(value: string): number { + return Array.from(value).length; +} + +/** Clamp pasted/inserted text to the Rust description cap by Unicode scalar. */ +export function clampAgentDescription(value: string): string { + return Array.from(value).slice(0, MAX_AGENT_DESCRIPTION_CHARS).join(""); +} + +/** + * The description to display for a persona: the authored `description`, + * trimmed, when non-empty; otherwise `null`. + * + * Rust twin: `effective_agent_description` in + * `managed_agents/agent_description.rs`, which resolves the same value on + * the kind:0 `about` publish path — keep both in sync. + */ +export function effectiveAgentDescription( + persona: Partial>, +): string | null { + const authored = persona.description?.trim() ?? ""; + return authored.length > 0 ? authored : null; +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 63a357e4487..928920f9a32 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -10,6 +10,8 @@ export type CatalogPersonaShareLevel = "not-shared" | "none"; type CatalogAgentProjection = { displayName: string; avatarUrl: string | null; + /** Optional public description (validated server-side; max 280 chars). */ + description: string | null; systemPrompt: string; runtime: string | null; model: string | null; @@ -69,6 +71,7 @@ function publicationToPersona( `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, displayName: publication.agent.displayName, avatarUrl: publication.agent.avatarUrl, + description: publication.agent.description ?? null, systemPrompt: publication.agent.systemPrompt, runtime: publication.agent.runtime, model: publication.agent.model, diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 26c509dad59..9af8392a14f 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -189,6 +189,7 @@ export function AddTeamToChannelDialog({ avatarUrl={persona.avatarUrl} className="h-5 w-5 text-2xs" label={persona.displayName} + shape="squircle" /> {persona.displayName} diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index e7a68211dcd..2856f56e789 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -3,7 +3,6 @@ import Picker from "@emoji-mart/react"; import * as React from "react"; import { Link2, Pencil, Plus, UploadCloud } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; - import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { @@ -44,7 +43,6 @@ import { type EmojiMartEmoji, isAvatarFileDrag, } from "./AgentCreationPreview.utils"; - export function AgentCreationPreview({ assetLabel = "avatar", avatarUrl, @@ -127,12 +125,10 @@ export function AgentCreationPreview({ }, processImage, }); - useEmojiMartStyles( emojiPickerContainerRef, isAvatarMenuOpen && activeTab === "emoji", ); - // Emoji Mart mounts its search input inside a shadow root. Wait for it // before focusing so the surrounding Radix popover cannot win the race. React.useEffect(() => { @@ -728,7 +724,7 @@ export function AgentCreationPreview({ ? isCompact ? "rounded-2xl" : "rounded-[2rem]" - : "rounded-full", + : "rounded-[30%]", )} role="img" style={{ backgroundColor: emojiAvatarPreview.color }} @@ -754,6 +750,7 @@ export function AgentCreationPreview({ ) : ( (null); const [avatarUrl, setAvatarUrl] = React.useState(""); @@ -205,6 +207,7 @@ export function AgentDefinitionDialog({ } setDisplayName(initialValues.displayName); + setDescriptionDraft(initialValues.description ?? ""); setAvatarUrl(initialValues.avatarUrl ?? ""); setSystemPrompt(initialValues.systemPrompt); setRuntime(initialValues.runtime ?? ""); @@ -357,6 +360,8 @@ export function AgentDefinitionDialog({ : undefined; const baseInput = { displayName: displayName.trim(), + // Empty string → null happens in the API wrapper (normalizeDescription). + description: descriptionDraft, avatarUrl: avatarUrl.trim() || undefined, systemPrompt: systemPrompt, runtime: runtimeForSubmit, @@ -759,33 +764,13 @@ export function AgentDefinitionDialog({ />
-
- -
- setDisplayName(event.target.value)} - placeholder="Fizz" - value={displayName} - /> -
-
+
+ {description ? ( +

+ {description} +

+ ) : null} +
diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 695504429dc..1796adaa6b7 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -295,6 +295,7 @@ export function TeamDialog({ avatarUrl={persona.avatarUrl} className="h-6 w-6 text-2xs" label={persona.displayName} + shape="squircle" /> {persona.displayName} {persona.isBuiltIn ? ( diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index 8e4b02c9e8d..45931d143aa 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -120,7 +120,7 @@ function TeamAvatarRow({ if (visiblePersonas.length === 0 && overflowCount === 0) { return (
-
+
@@ -135,19 +135,14 @@ function TeamAvatarRow({ role="img" > {visiblePersonas.map((persona, index) => ( - + ))} {overflowCount > 0 ? (
0 ? "-ml-5" : ""} style={{ zIndex: stackItemCount }} > - + +{overflowCount}
@@ -159,44 +154,40 @@ function TeamAvatarRow({ function TeamAvatarItem({ index, - isFollowedByAnother, persona, }: { index: number; - isFollowedByAnother: boolean; persona: AgentPersona; }) { const avatarUrl = persona.avatarUrl?.trim() ?? null; return (
0 ? "-ml-5" : ""}`} + className={`relative h-14 w-14 before:absolute before:-inset-0.5 before:rounded-[calc(30%+2px)] before:bg-card before:content-[''] ${index > 0 ? "-ml-5" : ""}`} data-team-member-avatar="avatar" style={{ zIndex: index + 1, - ...(isFollowedByAnother && { - mask: "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", - WebkitMask: - "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", - }), }} > - {avatarUrl ? ( - - ) : ( - - )} +
+ {avatarUrl ? ( + + ) : ( + + )} +
); } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index e20ec94689f..4c3792e1893 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -6,6 +6,7 @@ import { resolveAgentCardAvatarUrl, } from "@/features/agents/lib/agentCardAvatar"; import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; +import { effectiveAgentDescription } from "@/features/agents/lib/agentDescription"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; @@ -279,12 +280,16 @@ function AgentPersonaCard({ onStartPersona: (persona: AgentPersona) => void; }) { const title = persona.displayName; - const modelLabel = resolveAgentCardModelLabel({ - agent, - personaModel: persona.model, - provider: persona.provider, - defaultModel, - }); + // Card face second line: the authored description when one exists; + // otherwise fall back to the model label as before. + const subtitle = + effectiveAgentDescription(persona) ?? + resolveAgentCardModelLabel({ + agent, + personaModel: persona.model, + provider: persona.provider, + defaultModel, + }); const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent @@ -338,7 +343,7 @@ function AgentPersonaCard({ avatarUrl={avatarUrl} dataTestId={`persona-agent-row-${persona.id}`} label={title} - modelLabel={modelLabel} + subtitle={subtitle} onClick={() => { // The card's main click always opens the PERSONA target, never an // explicit pubkey. A pubkey target is durable in the panel, so a pick @@ -423,12 +428,16 @@ function StandaloneAgentCard({ avatarUrl={profileQuery.data?.avatarUrl} dataTestId={`managed-agent-${agent.pubkey}`} label={title} - modelLabel={resolveAgentCardModelLabel({ - agent, - personaModel: null, - provider: agent.provider, - defaultModel, - })} + subtitle={ + // Definition-less instance: no authored description exists, so fall + // back to the model label. + resolveAgentCardModelLabel({ + agent, + personaModel: null, + provider: agent.provider, + defaultModel, + }) + } onClick={() => { onOpenAgentProfile( agent.pubkey, diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 181e4febf5c..470dd1b894e 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -101,7 +101,10 @@ export function UserMessageBubble({ {isCompactPreview ? null : item.authorPubkey && openProfilePanel ? ( @@ -123,6 +127,7 @@ export function UserMessageBubble({ avatarUrl={authorProfile?.avatarUrl ?? null} className="order-last ml-2 mt-1 size-7 shrink-0 text-xs" displayName={authorLabel} + shape={authorProfile?.isAgent ? "squircle" : "circle"} size="sm" /> )} diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index b786bf5573d..aab59803ddb 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -75,6 +75,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => id: "persona-1", displayName: "Solo", avatarUrl: "avatar://solo", + description: "Reviews desktop changes.", systemPrompt: "Be direct.", runtime: "provider-a", model: "model-a", @@ -88,6 +89,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => assert.deepEqual(state.initialValues, { displayName: "Solo copy", avatarUrl: "avatar://solo", + description: "Reviews desktop changes.", systemPrompt: "Be direct.", runtime: "provider-a", model: "model-a", @@ -128,6 +130,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { id: "persona-2", displayName: "Kit", avatarUrl: null, + description: "Finds unusual solutions.", systemPrompt: "Keep it weird.", runtime: null, model: null, @@ -145,6 +148,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { id: "persona-2", displayName: "Kit", avatarUrl: "", + description: "Finds unusual solutions.", systemPrompt: "Keep it weird.", runtime: undefined, model: undefined, diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index e09e647b9f4..a686dbd6827 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -63,6 +63,7 @@ export function duplicatePersonaDialogState( initialValues: { displayName: `${persona.displayName} copy`, avatarUrl: persona.avatarUrl ?? "", + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, @@ -121,6 +122,7 @@ export function editPersonaDialogState( id: persona.id, displayName: persona.displayName, avatarUrl: persona.avatarUrl ?? "", + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 4fc6547cb48..b9a924f4181 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -343,6 +343,7 @@ export function usePersonaActions() { updatedPersona = await createPersonaMutation.mutateAsync({ displayName: persona.displayName, avatarUrl: persona.avatarUrl ?? undefined, + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/features/channels/lib/dmParticipantDisplay.ts b/desktop/src/features/channels/lib/dmParticipantDisplay.ts index 25f8bd89931..2dd9cfda23b 100644 --- a/desktop/src/features/channels/lib/dmParticipantDisplay.ts +++ b/desktop/src/features/channels/lib/dmParticipantDisplay.ts @@ -14,6 +14,7 @@ export type DmParticipantDisplay = { export type DirectMessageIntroParticipant = { avatarUrl: string | null; displayName: string; + isAgent?: boolean; pubkey: string; }; @@ -95,6 +96,7 @@ export function buildDirectMessageIntro({ profiles, pubkey: participant.pubkey, }), + ...(profile?.isAgent === true ? { isAgent: true } : {}), pubkey: participant.pubkey, }; }); diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index e27953d6460..d9ad164bcdd 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -38,6 +38,7 @@ function AgentRow({ className="h-9 w-9 shrink-0 text-xs" iconClassName="h-5 w-5" label={persona.displayName} + shape="squircle" /> {persona.displayName} diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 326866cf63e..fa4c3fe842c 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -161,6 +161,7 @@ export function AddChannelBotTeamsSection({ avatarUrl={persona.avatarUrl} className="h-4 w-4 text-3xs bg-secondary-foreground/20 text-secondary-foreground" label={persona.displayName} + shape="squircle" testId="team-tooltip-persona-avatar" /> diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index c1933f14bb7..603d15dfba0 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -422,6 +422,7 @@ export function AgentSessionThreadPanel({ avatarUrl={agentProfile?.avatarUrl ?? null} className="size-9" label={agentLabel} + shape="squircle" testId="agent-session-agent-avatar" />
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index cfa84f02e3c..d43b621c24b 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -191,6 +191,7 @@ export function BotActivityComposerAction({ isInline ? "!h-4.5 !w-4.5 text-3xs" : "shrink-0", )} displayName={agent.name} + shape="squircle" fallbackDelayMs={isInline ? 0 : undefined} key={agent.pubkey} size="xs" @@ -259,6 +260,7 @@ export function BotActivityComposerAction({ avatarUrl={agentAvatarUrl(agent)} className="shrink-0" displayName={agent.name} + shape="squircle" size="sm" /> {agent.name} diff --git a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx index a9662bbf047..34c226a3973 100644 --- a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx @@ -56,6 +56,7 @@ export function ChannelMemberAvatarStack({ className="!h-8 !w-8 border-2 border-background text-2xs" displayName={label} fallbackDelayMs={0} + shape={profile?.isAgent ? "squircle" : "circle"} /> ); diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index dd370e8c615..90fb5bfaa9b 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -206,6 +206,7 @@ export function ChannelMemberInviteCard({ @@ -297,6 +298,7 @@ export function ChannelMemberInviteCard({

diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 44e4d891dc1..3ccabdf7536 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -153,6 +153,7 @@ export function ChannelScreenHeader({ ) : activeDmParticipant ? ( @@ -163,6 +164,7 @@ export function ChannelScreenHeader({ geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY} iconClassName="h-4 w-4" label={activeChannelTitle} + shape={activeDmParticipant.isAgent ? "squircle" : "circle"} size={DM_HEADER_AVATAR_SIZE} status={activeDmPresenceStatus ?? "offline"} statusTestId="chat-presence-badge" @@ -177,6 +179,7 @@ export function ChannelScreenHeader({ geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY} iconClassName="h-4 w-4" label={activeChannelTitle} + shape="circle" size={DM_HEADER_AVATAR_SIZE} status={activeDmPresenceStatus ?? "offline"} statusTestId="chat-presence-badge" @@ -222,23 +225,23 @@ function DmHeaderParticipantStack({ pubkey={participant.pubkey} triggerAriaLabel={`Open profile for ${participant.displayName}`} triggerElement="span" + role={participant.isAgent ? "bot" : undefined} > 0 ? "-ml-2" : ""} data-testid="chat-header-dm-avatar-stack-participant" - style={{ - zIndex: index + 1, - ...(index < stackItemCount - 1 && { - mask: "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)", - WebkitMask: - "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)", - }), - }} + style={{ zIndex: index + 1 }} > diff --git a/desktop/src/features/channels/ui/MembersSidebarAddMemberRows.tsx b/desktop/src/features/channels/ui/MembersSidebarAddMemberRows.tsx index 2d448b25cc2..b111a65c0a9 100644 --- a/desktop/src/features/channels/ui/MembersSidebarAddMemberRows.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarAddMemberRows.tsx @@ -63,6 +63,7 @@ export function AddMemberSearchResultRow({ avatarUrl={user.avatarUrl} className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none" displayName={formatAddCandidateName(user)} + shape={user.isAgent ? "squircle" : "circle"} size="sm" />

diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 2a98a8e5e15..55ca53e867b 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -173,6 +173,7 @@ export function MembersSidebarMemberCard({ className="h-8 w-8 text-xs shadow-none" iconClassName="h-4 w-4" label={memberAvatarLabel} + shape={memberIsBot ? "squircle" : "circle"} /> {presenceStatus ? ( {name} diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 06b19d61679..29338ff5672 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -140,6 +140,7 @@ function RelayMemberRow({ avatarUrl={profile?.avatarUrl ?? null} className="h-9 w-9 text-xs shadow-none" label={displayName} + shape={profile?.isAgent === true ? "squircle" : "circle"} />
diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 8cbbdff2b4e..f27a43c724b 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -45,6 +45,7 @@ export function ForumPostCard({ preferResolvedSelfLabel: true, }); const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null; + const authorIsAgent = profiles?.[post.pubkey.toLowerCase()]?.isAgent === true; const { mentionNames, mentionPubkeysByName } = resolveMentionProps( post.tags, profiles, @@ -83,14 +84,19 @@ export function ForumPostCard({
{/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */}
e.stopPropagation()} role="presentation"> - + diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 745464a7d36..19d8ae13e8b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -658,6 +658,7 @@ function ProfileHero({ } badgeBox={PROFILE_HERO_PRESENCE_BADGE.shell} className="h-20 w-20" + cornerRadius={isBot ? 24 : undefined} curve={STATUS_DOT_MASK_CURVE} cutout={PROFILE_HERO_PRESENCE_BADGE.cutout} size={80} @@ -668,6 +669,7 @@ function ProfileHero({ iconClassName="h-8 w-8" label={displayName} plain + shape={isBot ? "squircle" : "circle"} testId="user-profile-avatar" /> diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index f82bac1c336..d19a5fa19d4 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -381,6 +381,7 @@ function UserProfilePopoverBody({ className="h-10 w-10" iconClassName="h-5 w-5" label={displayName} + shape={isBotProfile ? "squircle" : "circle"} size={40} status={presenceStatus ?? "offline"} statusTestId="user-profile-popover-presence-badge" diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 74130447270..54073bac5dd 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -68,7 +68,10 @@ export function IssueAssigneeFacepile({ const label = labelForPubkey(pubkey, profiles); return ( @@ -76,6 +79,7 @@ export function IssueAssigneeFacepile({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} size="xs" /> @@ -232,6 +236,7 @@ export function IssueAssigneesRow({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} size="xs" /> ); @@ -241,7 +246,10 @@ export function IssueAssigneesRow({ {canUnassign ? ( @@ -361,6 +374,7 @@ export function IssueAssigneesRow({ accent={candidate.isAgent} avatarUrl={candidate.avatarUrl} displayName={label} + shape={candidate.isAgent ? "squircle" : "circle"} size="xs" /> diff --git a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx index c145f5f9d7e..ad970db118e 100644 --- a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx +++ b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx @@ -40,6 +40,7 @@ export function ProjectAuthorIdentity({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="xs" testId={testId ? `${testId}-avatar` : undefined} @@ -61,6 +62,7 @@ export function ProjectAuthorIdentity({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="sm" /> diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index a1a48e89e47..0d859dd9da0 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -85,7 +85,10 @@ export function ProjectPeopleStack({ diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx index 8acfd90b129..46610e6d6ef 100644 --- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx +++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx @@ -43,8 +43,9 @@ export function ProjectEntityFacepile({ > @@ -57,14 +58,18 @@ export function ProjectEntityFacepile({ triggerElement="span" > diff --git a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx index 75566530754..3157721a157 100644 --- a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx +++ b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx @@ -120,6 +120,11 @@ export function ProjectIssueCommentTimeline({ } className="relative z-10 bg-background ring-1 ring-border/70" displayName={authorLabel} + shape={ + profiles?.[normalizePubkey(comment.author)]?.isAgent + ? "squircle" + : "circle" + } size="xs" />
diff --git a/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx b/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx index 1bbc4e346e9..9ee60c1736c 100644 --- a/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx +++ b/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx @@ -45,6 +45,7 @@ export function ProfileIdentityButton({ avatarUrl={avatarUrl} className={avatarClassName} displayName={label} + shape={isAgent ? "squircle" : "circle"} size={avatarSize} /> {showLabel ? ( diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index 89c192777af..de6159afbb0 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -862,6 +862,9 @@ export function RepositoryFilesPanel({ accent={latestCommitProfile?.isAgent === true} avatarUrl={latestCommitProfile?.avatarUrl ?? null} displayName={latestCommitAuthorLabel} + shape={ + latestCommitProfile?.isAgent ? "squircle" : "circle" + } size="sm" />

diff --git a/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx b/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx index 9fde63b21d0..7b2a4284396 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx @@ -50,6 +50,7 @@ function RepositoryOwnerReference({ accent={ownerIsAgent} avatarUrl={ownerAvatarUrl ?? null} displayName={ownerName} + shape={ownerIsAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="xs" testId="repository-owner-avatar" diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index 7bdee36a894..cda0e2354ef 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -405,13 +405,17 @@ function ActivityCard({ @@ -422,6 +426,7 @@ function ActivityCard({ avatarUrl={profile?.avatarUrl ?? null} className="relative z-10 shrink-0" displayName={actorLabel} + shape={profile?.isAgent ? "squircle" : "circle"} size={compact ? "xs" : "md"} /> )} diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 52153aad109..eebe6af46b7 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -679,6 +679,7 @@ export function ProjectsAgentPromptPage({ avatarUrl={avatarUrlFor(selectedAgent.pubkey)} className="shrink-0" displayName={selectedAgent.name} + shape="squircle" size="xs" /> ) : null} @@ -705,6 +706,7 @@ export function ProjectsAgentPromptPage({ avatarUrl={avatarUrlFor(candidate.pubkey)} className="mr-2 shrink-0" displayName={candidate.name} + shape="squircle" size="xs" /> diff --git a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx index edc7077c5bb..5ba34da99b8 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx @@ -58,7 +58,10 @@ function OverviewPerson({ @@ -66,6 +69,7 @@ function OverviewPerson({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} size="sm" /> diff --git a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx index a79a705a5b3..fa5f205fabb 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx @@ -261,6 +261,7 @@ export function PullRequestReviewersRow({ accent={candidate.isAgent} avatarUrl={candidate.avatarUrl} displayName={label} + shape={candidate.isAgent ? "squircle" : "circle"} size="xs" /> diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index 8d600f09471..dd84b5c31fb 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -66,7 +66,11 @@ export function AgentActivityCard({ className="relative flex shrink-0 rounded-xl pt-1 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" type="button" > - + diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 142d725e97a..96f25a6ede4 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -71,13 +71,18 @@ function ReplyParentContext({ : null; const parentAvatarUrl = cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; + const parentIsAgent = cachedProfile?.isAgent === true; const parentSnippet = parentNote ? noteSnippet(parentNote.content) : null; return (

{parentNote ? (
- + @@ -170,6 +176,7 @@ export function NoteCard({ avatarUrl={avatarUrl} className="!h-9 !w-9 shrink-0" displayName={displayName} + shape={isAgent ? "squircle" : "circle"} /> {isAgent ? ( @@ -299,6 +306,7 @@ export function NoteCard({ avatarUrl={currentUserAvatarUrl} className="!h-8 !w-8 shrink-0" displayName={currentUserDisplayName} + shape={currentUserProfile?.isAgent ? "squircle" : "circle"} /> {currentUserDisplayName} diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index b39babb351f..7a17e1b966e 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -416,6 +416,9 @@ export function PulseView({ currentPubkey }: PulseViewProps) { avatarUrl={currentProfile?.avatarUrl ?? null} className="!h-7 !w-7 shrink-0" displayName={currentDisplayName} + shape={ + currentProfile?.isAgent === true ? "squircle" : "circle" + } /> {currentDisplayName} diff --git a/desktop/src/features/reminders/ui/RemindersPanel.tsx b/desktop/src/features/reminders/ui/RemindersPanel.tsx index 46b6b89e13d..b3720ae043b 100644 --- a/desktop/src/features/reminders/ui/RemindersPanel.tsx +++ b/desktop/src/features/reminders/ui/RemindersPanel.tsx @@ -37,6 +37,7 @@ export type ReminderSource = { avatarUrl: string | null; channel: Channel | null; channelLabel: string; + isAgent?: boolean; }; export function useReminderSources(reminders: readonly Reminder[]) { @@ -80,6 +81,9 @@ export function useReminderSources(reminders: readonly Reminder[]) { channelLabel: channel ? resolveChannelDisplayLabel(channel, currentPubkey, profiles) : UNKNOWN_CHANNEL_LABEL, + ...(profiles?.[normalizePubkey(target.authorPubkey)]?.isAgent === true + ? { isAgent: true } + : {}), }); } return map; @@ -194,6 +198,7 @@ function ReminderRow({ avatarUrl={source.avatarUrl} className="h-4 w-4 shrink-0" displayName={source.authorLabel} + shape={source.isAgent ? "squircle" : "circle"} size="xs" /> @@ -444,6 +449,7 @@ export function ReminderDetailPane({ avatarUrl={source.avatarUrl} className="h-6 w-6" displayName={source.authorLabel} + shape={source.isAgent ? "squircle" : "circle"} size="sm" /> diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx index 6eeb33801ea..cc373ca7fb6 100644 --- a/desktop/src/features/search/ui/SearchResultItem.tsx +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -233,6 +233,8 @@ export function MessageResultBody({ }); const avatarUrl = resultProfiles?.[hit.pubkey.toLowerCase()]?.avatarUrl ?? null; + const authorIsAgent = + resultProfiles?.[hit.pubkey.toLowerCase()]?.isAgent === true; return (
@@ -245,6 +247,7 @@ export function MessageResultBody({ {authorLabel} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 70365497bcc..68e879a3033 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -1,6 +1,5 @@ import { Search } from "lucide-react"; import * as React from "react"; - import { resolveUserLabel } from "@/features/profile/lib/identity"; import { getMinimumSearchQueryLength } from "@/features/search/hooks"; import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators"; @@ -30,7 +29,6 @@ import { } from "@/shared/ui/mentionChip"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; - type TopbarSearchProps = { channelLabels?: Record; channels: Channel[]; @@ -48,7 +46,6 @@ type TopbarSearchProps = { scopeFocusRequest?: number; variant?: "bar" | "icon"; }; - const MAX_SEARCH_SUGGESTIONS = 4; const SEARCH_RESULT_LIMIT = 40; const SEARCH_SECTION_TITLE_CLASS = @@ -61,23 +58,18 @@ const SEARCH_RESULT_SECTION_ORDER = [ "messages", "actions", ] as const; - type SearchResultSectionKey = (typeof SEARCH_RESULT_SECTION_ORDER)[number]; - type SearchResultSection = { key: SearchResultSectionKey; results: SearchResult[]; title: string; }; - type SearchHitContextLabel = { channelLabel: string | null; text: string; }; - function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; - if (diff < 60) { return "just now"; } @@ -736,6 +728,12 @@ export function TopbarSearch({ pubkey: result.hit.pubkey, preferResolvedSelfLabel: true, })} + shape={ + resultProfiles?.[result.hit.pubkey.toLowerCase()]?.isAgent === + true + ? "squircle" + : "circle" + } size="md" /> ) : result.kind === "user" ? ( @@ -743,6 +741,7 @@ export function TopbarSearch({ avatarUrl={result.user.avatarUrl} className="h-7 w-7" displayName={userDisplayName ?? result.user.pubkey} + shape={result.user.isAgent ? "squircle" : "circle"} size="sm" /> ) : ( diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 2f9b2c36a1a..d242faf6189 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -7,11 +7,11 @@ import { canManageCommunityMembers, shouldWarnMissingMembershipSnapshot, } from "@/shared/api/relayMembers"; -import { getFeature } from "@/shared/features/manifest"; import { + getFeature, resolveEnabled, useFeatureSnapshot, -} from "@/shared/features/useFeatureEnabled"; +} from "@/shared/features"; import { topChromeBackdrop } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { @@ -137,7 +137,10 @@ export function SettingsView({ // stable and renders unconditionally (fail-open). if (s.featureGate) { const feature = getFeature(s.featureGate); - if (feature && !resolveEnabled(s.featureGate, featureState)) { + if ( + feature && + !resolveEnabled(s.featureGate, featureState, feature.defaultEnabled) + ) { return false; } } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 50c9a80f6b1..9c7f3cf1661 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -418,6 +418,7 @@ export function AppSidebar({ accessibleLabel: participant.label, avatarUrl: participant.avatarUrl, channelId, + isAgent: participant.isAgent, label: dmChannelLabels[channelId] ?? participant.label, }, ]; diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index e6e24ce27b1..34fdba0bdfe 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -78,11 +78,13 @@ function RowActionButton({ } function ThreadPreviewRow({ + isAgent, item, onMarkRead, onOpen, onRemindLater, }: { + isAgent: boolean; item: InboxItem; onMarkRead: () => void; onOpen: () => void; @@ -104,6 +106,7 @@ function ThreadPreviewRow({ avatarUrl={item.avatarUrl} className="h-9 w-9 shrink-0" displayName={item.senderLabel} + shape={isAgent ? "squircle" : "circle"} size="md" />
@@ -168,6 +171,7 @@ function WorkingAgentRow({ avatarUrl={avatarUrl} className="h-9 w-9 shrink-0" displayName={name} + shape="squircle" size="md" />
@@ -424,6 +428,10 @@ export function ChannelActivityPopover({ {activityItems.length > 0 ? activityItems.map((item) => ( handleMarkRead(item)} diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index fc1b1ee1941..581f2e59868 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -7,6 +7,7 @@ export type UnreadDmPreview = { avatarUrl: string | null; channelId: string; label: string; + isAgent?: boolean; }; export function canPreviewUnreadDm( @@ -111,6 +112,7 @@ export function MoreUnreadButton({ avatarUrl={preview.avatarUrl} className="ring-2 ring-primary" displayName={preview.label} + shape={preview.isAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="xs" testId={`sidebar-unread-dm-avatar-${preview.channelId}`} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index a5ad12fb336..6fca32350aa 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -153,6 +153,7 @@ function ChannelWorkingBadge({ export type SidebarDmParticipant = { avatarUrl: string | null; label: string; + isAgent?: boolean; pubkey: string; }; @@ -197,6 +198,7 @@ function DmChannelIcon({ geometry={DM_AVATAR_STATUS_GEOMETRY} iconClassName="h-3.5 w-3.5" label={primaryParticipant.label} + shape={primaryParticipant.isAgent ? "squircle" : "circle"} size={DM_AVATAR_SIZE} status={presenceStatus} statusTestId={`channel-presence-${channelName}`} diff --git a/desktop/src/features/sidebar/useDmSidebarMetadata.ts b/desktop/src/features/sidebar/useDmSidebarMetadata.ts index 52f613f7d07..b3bfce56707 100644 --- a/desktop/src/features/sidebar/useDmSidebarMetadata.ts +++ b/desktop/src/features/sidebar/useDmSidebarMetadata.ts @@ -130,6 +130,10 @@ export function useDmSidebarMetadata({ profiles: dmProfiles, pubkey: participant.pubkey, }), + ...(dmProfiles?.[participant.pubkey.toLowerCase()]?.isAgent === + true + ? { isAgent: true } + : {}), pubkey: participant.pubkey, })), ]; diff --git a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx index bcf0b209ffa..9d6bdddc005 100644 --- a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx +++ b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx @@ -342,6 +342,7 @@ function AuthorOption({ diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 8ac97402aa0..01705261d2e 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -632,6 +632,7 @@ export const WorkflowFormBuilder = React.forwardRef< diff --git a/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx index 6639d85b0ff..55211bc3bce 100644 --- a/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx +++ b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx @@ -444,6 +444,7 @@ function MessageOption({ ) : null} diff --git a/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx index 50eb5ef6726..c6a917048f7 100644 --- a/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx +++ b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx @@ -6,11 +6,13 @@ import { splitWorkflowAuthorDescription } from "./workflowTriggerDescription"; export function WorkflowRichTriggerDescription({ avatarUrl, description, + isAgent, label, loading, }: { avatarUrl?: string | null; description: string; + isAgent?: boolean; label?: string | null; loading?: boolean; }) { @@ -41,6 +43,7 @@ export function WorkflowRichTriggerDescription({ className="h-4 w-4" displayName={label} fallbackDelayMs={0} + shape={isAgent ? "squircle" : "circle"} size="xs" testId="workflow-trigger-author-avatar" /> diff --git a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx index 78f6e30606d..2d63d7c89f3 100644 --- a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx +++ b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx @@ -77,10 +77,12 @@ function ExclusionStrike() { function AuthorConditionSummary({ avatarUrl, excluded, + isAgent, label, }: { avatarUrl: string | null; excluded: boolean; + isAgent?: boolean; label: string; }) { return ( @@ -91,6 +93,7 @@ function AuthorConditionSummary({ className="h-6 w-6" displayName={label} fallbackDelayMs={0} + shape={isAgent ? "squircle" : "circle"} size="xs" /> {excluded ? : null} @@ -326,6 +329,7 @@ export function WorkflowTriggerConditions({ ) : messageSummary ? ( diff --git a/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts b/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts index 320ba0f835d..b042aa6354d 100644 --- a/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts +++ b/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts @@ -10,6 +10,7 @@ const FULL_HEX_PUBKEY = /^[0-9a-f]{64}$/i; export type WorkflowAuthorPresentation = { avatarUrl: string | null; description: string; + isAgent: boolean; label: string | null; loading: boolean; pubkey: string | null; @@ -50,6 +51,7 @@ export function useWorkflowAuthorPresentation( authorLabel: label ?? undefined, authorLoading: loading, }), + isAgent: profile?.isAgent === true, label, loading, pubkey, diff --git a/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts b/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts index a2ad5f90ddb..a0996ddd554 100644 --- a/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts +++ b/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts @@ -44,6 +44,7 @@ export function useWorkflowListAuthorPresentations( workflowId, { avatarUrl: profile?.avatarUrl ?? null, + isAgent: profile?.isAgent === true, label: loading ? null : resolveUserLabel({ diff --git a/desktop/src/protectedFeatures/buildProtectedFeatureArtifacts.test.mjs b/desktop/src/protectedFeatures/buildProtectedFeatureArtifacts.test.mjs new file mode 100644 index 00000000000..ae330a6889e --- /dev/null +++ b/desktop/src/protectedFeatures/buildProtectedFeatureArtifacts.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { loadEnv } from "vite"; + +import { + buildArtifactMatrix, + selectInternalVariant, +} from "../../scripts/build-protected-feature-artifacts.mjs"; + +const INTERNAL_MARKER = "Try a personal agent that is always close at hand"; + +function fakeBuilder(calls) { + return ({ internal, output }) => { + calls.push(internal); + rmSync(output, { recursive: true, force: true }); + mkdirSync(output, { recursive: true }); + writeFileSync( + path.join(output, "index.js"), + internal ? INTERNAL_MARKER : "public desktop artifact", + ); + }; +} + +describe("protected feature production artifact selection", () => { + it("honors env-file selection while process overrides retain the requested dist", () => { + const root = mkdtempSync(path.join(tmpdir(), "buzz-protected-build-test-")); + const envRoot = path.join(root, "env"); + mkdirSync(envRoot); + writeFileSync(path.join(envRoot, ".env.local"), "VITE_BUZZ_BESTIE=1\n"); + + try { + const modeEnv = loadEnv("production", envRoot, ""); + const internalOutput = path.join(root, "internal-dist"); + const internalAlternate = path.join(root, "internal-alternate"); + const internalCalls = []; + const fileSelectedInternal = selectInternalVariant({ + processEnv: {}, + modeEnv, + }); + + assert.equal(fileSelectedInternal, true); + buildArtifactMatrix({ + selectedInternalVariant: fileSelectedInternal, + selectedOutput: internalOutput, + alternateOutput: internalAlternate, + build: fakeBuilder(internalCalls), + }); + assert.deepEqual(internalCalls, [false, true]); + assert.match( + readFileSync(path.join(internalOutput, "index.js"), "utf8"), + /personal agent/u, + ); + assert.doesNotMatch( + readFileSync(path.join(internalAlternate, "index.js"), "utf8"), + /personal agent/u, + ); + + const ossOutput = path.join(root, "oss-dist"); + const ossAlternate = path.join(root, "oss-alternate"); + const ossCalls = []; + const processSelectedOss = selectInternalVariant({ + processEnv: { VITE_BUZZ_BESTIE: "0" }, + modeEnv, + }); + + assert.equal(processSelectedOss, false); + buildArtifactMatrix({ + selectedInternalVariant: processSelectedOss, + selectedOutput: ossOutput, + alternateOutput: ossAlternate, + build: fakeBuilder(ossCalls), + }); + assert.deepEqual(ossCalls, [true, false]); + assert.doesNotMatch( + readFileSync(path.join(ossOutput, "index.js"), "utf8"), + /personal agent/u, + ); + assert.match( + readFileSync(path.join(ossAlternate, "index.js"), "utf8"), + /personal agent/u, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/desktop/src/protectedFeatures/internal.ts b/desktop/src/protectedFeatures/internal.ts new file mode 100644 index 00000000000..7f9f6b551e8 --- /dev/null +++ b/desktop/src/protectedFeatures/internal.ts @@ -0,0 +1,11 @@ +import type { FeatureDefinition } from "@/shared/features/types"; + +/** Definitions available only in the protected internal application build. */ +export const protectedFeatureDefinitions: FeatureDefinition[] = [ + { + id: "bestie", + name: "Bestie", + description: "Try a personal agent that is always close at hand", + platforms: ["desktop"], + }, +]; diff --git a/desktop/src/protectedFeatures/protectedFeatures.test.mjs b/desktop/src/protectedFeatures/protectedFeatures.test.mjs new file mode 100644 index 00000000000..20a6d469faa --- /dev/null +++ b/desktop/src/protectedFeatures/protectedFeatures.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { resolveEnabled } from "../shared/features/resolveEnabled.ts"; +import { protectedFeatureDefinitions as internalDefinitions } from "./internal.ts"; +import { protectedFeatureDefinitions as publicDefinitions } from "./public.ts"; + +describe("protected feature build variants", () => { + it("keeps protected definitions out of the OSS module", () => { + assert.deepEqual(publicDefinitions, []); + }); + + it("adds Bestie as a default-off experiment only through the internal module", () => { + assert.deepEqual( + internalDefinitions.map((feature) => feature.id), + ["bestie"], + ); + const bestie = internalDefinitions[0]; + assert.ok(bestie); + assert.equal(resolveEnabled(bestie.id, {}, bestie.defaultEnabled), false); + }); +}); diff --git a/desktop/src/protectedFeatures/public.ts b/desktop/src/protectedFeatures/public.ts new file mode 100644 index 00000000000..90c1e596242 --- /dev/null +++ b/desktop/src/protectedFeatures/public.ts @@ -0,0 +1,7 @@ +import type { FeatureDefinition } from "@/shared/features/types"; + +/** + * Protected feature definitions compiled into the official OSS application. + * Keep this module free of protected product names, metadata, and imports. + */ +export const protectedFeatureDefinitions: FeatureDefinition[] = []; diff --git a/desktop/src/protectedFeatures/tauriCommand.test.mjs b/desktop/src/protectedFeatures/tauriCommand.test.mjs new file mode 100644 index 00000000000..e3e1532af45 --- /dev/null +++ b/desktop/src/protectedFeatures/tauriCommand.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const wrapper = path.join(desktopRoot, "scripts/tauri-command.mjs"); +const fakeCli = path.join(tmpdir(), `buzz-fake-tauri-${process.pid}.mjs`); + +writeFileSync( + fakeCli, + `import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +const configIndex = args.lastIndexOf("--config"); +const override = JSON.parse(args[configIndex + 1]); +const output = override.build.frontendDist; +mkdirSync(output, { recursive: true }); +writeFileSync(path.join(output, "variant.txt"), process.env.VITE_BUZZ_BESTIE); +await new Promise((resolve) => setTimeout(resolve, 100)); +const observed = readFileSync(path.join(output, "variant.txt"), "utf8"); +writeFileSync( + process.env.BUZZ_TEST_RESULT, + JSON.stringify({ args, output, observed }), +); +`, +); + +function packageVariant(variant, result, runnerArguments = []) { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [wrapper, "build", ...runnerArguments], + { + cwd: desktopRoot, + env: { + ...process.env, + BUZZ_TAURI_CLI_ENTRYPOINT: fakeCli, + BUZZ_TEST_RESULT: result, + VITE_BUZZ_BESTIE: variant, + }, + stdio: "inherit", + }, + ); + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`wrapper exited ${code}`)), + ); + }); +} + +test("opposite Tauri package variants own private frontend artifacts", async () => { + const resultRoot = path.join(tmpdir(), `buzz-tauri-results-${process.pid}`); + mkdirSync(resultRoot, { recursive: true }); + const ossResult = path.join(resultRoot, "oss.json"); + const internalResult = path.join(resultRoot, "internal.json"); + + await Promise.all([ + packageVariant("0", ossResult), + packageVariant("1", internalResult), + ]); + + const oss = JSON.parse(readFileSync(ossResult, "utf8")); + const internal = JSON.parse(readFileSync(internalResult, "utf8")); + assert.equal(oss.observed, "0"); + assert.equal(internal.observed, "1"); + assert.notEqual(oss.output, internal.output); +}); + +test("private config precedes Cargo runner arguments", async () => { + const result = path.join( + tmpdir(), + `buzz-tauri-runner-arguments-${process.pid}.json`, + ); + await packageVariant("0", result, [ + "--config", + '{"bundle":{"active":false}}', + "--", + "--locked", + ]); + + const invocation = JSON.parse(readFileSync(result, "utf8")); + const delimiterIndex = invocation.args.indexOf("--"); + const privateConfigIndex = invocation.args.lastIndexOf("--config"); + assert.ok(privateConfigIndex < delimiterIndex); + assert.equal(invocation.args[delimiterIndex + 1], "--locked"); + assert.equal( + JSON.parse(invocation.args[privateConfigIndex + 1]).build.frontendDist, + invocation.output, + ); +}); diff --git a/desktop/src/shared/api/personaTypes.ts b/desktop/src/shared/api/personaTypes.ts new file mode 100644 index 00000000000..f18e9fe96b9 --- /dev/null +++ b/desktop/src/shared/api/personaTypes.ts @@ -0,0 +1,98 @@ +// Persona (agent definition) wire types, split out of `types.ts` to keep that +// file inside the repo-wide size ratchet. Consumers import these through +// `@/shared/api/types`, which re-exports everything here. +import type { RespondToMode } from "./types"; + +export type AgentPersona = { + id: string; + displayName: string; + avatarUrl: string | null; + /** + * Optional short, PUBLIC description (max 280 chars), shown on the agent's + * card and profile. Excluded from the persona content hash (no restart + * badge). Null means no owner-authored description. + */ + description: string | null; + systemPrompt: string; + /** Preferred ACP runtime ID (e.g. "goose", "claude"). */ + runtime: string | null; + /** Opaque, harness-specific model identifier string. Buzz stores and passes through without interpretation. */ + model: string | null; + /** LLM inference provider (e.g. "databricks", "anthropic"). Injected as the runtime's provider env var at spawn time. */ + provider: string | null; + namePool: string[]; + isBuiltIn: boolean; + isActive: boolean; + /** Whether this persona is discoverable in the active community catalog. */ + shared: boolean; + /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ + sourceTeam?: string | null; + /** + * Set only on a local copy of another owner's shared catalog entry. A copy + * carries a fresh local `id`, so this coordinate is the only thing that can + * answer "is this catalog entry already added" without minting a duplicate. + */ + catalogSource?: CatalogSourceCoordinate | null; + /** Agent environment variables, layered after desktop parent and persona values. */ + envVars: Record; + /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ + respondTo: RespondToMode | null; + respondToAllowlist: string[]; + parallelism: number | null; + createdAt: string; + updatedAt: string; +}; + +/** + * A catalog publication's coordinate: the owner who published it and the + * `d`-tag identifying the persona within that owner's catalog. Mirrors the + * backend `CatalogSource`. + */ +export type CatalogSourceCoordinate = { + ownerPubkey: string; + personaId: string; +}; + +/** + * NIP-AP behavioral group for a definition: absent preserves the stored group + * for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`. + */ +export type PersonaBehaviorInput = { + respondTo?: RespondToMode; + respondToAllowlist?: string[]; + parallelism?: number; +}; + +export type CreatePersonaInput = { + displayName: string; + avatarUrl?: string; + /** Optional short, PUBLIC description (max 280 chars). Empty string clears. */ + description?: string | null; + systemPrompt: string; + runtime?: string; + model?: string; + provider?: string; + namePool?: string[]; + envVars?: Record; + behavior?: PersonaBehaviorInput; + /** + * Set when this persona is a copy of another owner's shared catalog entry, + * so the catalog can tell an already-added foreign entry from a new one. + */ + catalogSource?: CatalogSourceCoordinate; +}; + +export type UpdatePersonaInput = { + id: string; + displayName: string; + avatarUrl?: string; + /** Optional short, PUBLIC description (max 280 chars). Empty string clears. */ + description?: string | null; + systemPrompt: string; + runtime?: string; + model?: string; + provider?: string; + namePool?: string[]; + envVars?: Record; + behavior?: PersonaBehaviorInput; +}; diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts index a9481429f95..5bd46c8c882 100644 --- a/desktop/src/shared/api/readOnlyRelayClient.ts +++ b/desktop/src/shared/api/readOnlyRelayClient.ts @@ -9,6 +9,10 @@ import { type RelaySubscriptionFilter, } from "@/shared/api/relayClientShared"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; +import { + activateRateLimitIfSignalled, + waitForRateLimit, +} from "@/shared/api/relayRateLimitGate"; import { AUTH_TIMEOUT_MS, HISTORY_TIMEOUT_MS, @@ -107,7 +111,10 @@ export class ReadOnlyRelayClient { async publishEvent(event: RelayEvent): Promise { await this.connect(); - if (this.wsId === null) { + const generation = this.generation; + await waitForRateLimit(); + + if (generation !== this.generation || this.wsId === null) { throw new Error("Read-only relay socket is not connected."); } @@ -281,6 +288,7 @@ export class ReadOnlyRelayClient { if (success) { publish.resolve(); } else { + activateRateLimitIfSignalled(message); publish.reject( new Error(message || "Observer relay rejected the event."), ); diff --git a/desktop/src/shared/api/readOnlyRelayClientPublishRejection.test.mjs b/desktop/src/shared/api/readOnlyRelayClientPublishRejection.test.mjs new file mode 100644 index 00000000000..e764f339d70 --- /dev/null +++ b/desktop/src/shared/api/readOnlyRelayClientPublishRejection.test.mjs @@ -0,0 +1,156 @@ +// ReadOnlyRelayClient publishes to inactive communities, but it shares the +// process-wide relay rate-limit gate with the primary session. Addressed EVENT +// refusals therefore need to settle this client's pending publish, arm the +// shared gate, and defer later sends until the advertised window expires. +import assert from "node:assert/strict"; +import test from "node:test"; + +let fakeNow = 0; +const pendingTimers = new Map(); +let nextTimerId = 1; +const sends = []; + +globalThis.window = { + setTimeout: (fn, ms) => { + const id = nextTimerId++; + pendingTimers.set(id, { fn, fireAt: fakeNow + ms }); + return id; + }, + clearTimeout: (id) => pendingTimers.delete(id), + __TAURI_INTERNALS__: { + invoke: async (command, args) => { + if (command === "plugin:websocket|send") sends.push(args); + }, + }, +}; +Date.now = () => fakeNow; + +const { ReadOnlyRelayClient } = await import("./readOnlyRelayClient.ts"); +const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import( + "./relayRateLimitGate.ts" +); + +function tickTo(ms) { + fakeNow = ms; + for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) { + if (fireAt <= fakeNow) { + pendingTimers.delete(id); + fn(); + } + } +} + +function reset() { + resetRateLimitGate(); + fakeNow = 0; + pendingTimers.clear(); + nextTimerId = 1; + sends.length = 0; +} + +function connectedClient() { + const client = new ReadOnlyRelayClient("wss://inactive.example"); + client.wsId = 7; + client.connect = async () => {}; + return client; +} + +function armPendingPublish(client, eventId) { + const settled = new Promise((resolve, reject) => { + client.publishes.set(eventId, { + resolve, + reject, + timeout: window.setTimeout(() => {}, 25_000), + }); + }); + return settled.then( + () => ({ status: "resolved" }), + (error) => ({ status: "rejected", error }), + ); +} + +function deliver(client, frame) { + return client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.generation, + ); +} + +test("a rate-limited OK rejects the named publish and arms the shared gate", async () => { + reset(); + const client = connectedClient(); + const eventId = "a".repeat(64); + const settled = armPendingPublish(client, eventId); + + await deliver(client, [ + "OK", + eventId, + false, + "rate-limited: quota exceeded; retry in 4s", + ]); + + const outcome = await settled; + assert.equal(outcome.status, "rejected"); + assert.match(outcome.error.message, /rate-limited/); + assert.equal(client.publishes.has(eventId), false); + assert.equal(isRateLimited(), true); +}); + +test("an ordinary OK rejection does not arm the shared gate", async () => { + reset(); + const client = connectedClient(); + const eventId = "b".repeat(64); + const settled = armPendingPublish(client, eventId); + + await deliver(client, ["OK", eventId, false, "invalid: bad signature"]); + + assert.equal((await settled).status, "rejected"); + assert.equal(isRateLimited(), false); +}); + +test("publish waits outside its timeout and pending state, then sends and settles", async () => { + reset(); + activateRateLimit(4); + const client = connectedClient(); + const event = { id: "c".repeat(64), kind: 5 }; + + const published = client.publishEvent(event); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(sends.length, 0, "EVENT must remain unsent while gated"); + assert.equal( + client.publishes.has(event.id), + false, + "publish timeout and pending ownership start only after the gate expires", + ); + assert.equal(pendingTimers.size, 1, "only the gate timer should be armed"); + + tickTo(4_000); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(sends.length, 1); + assert.deepEqual(JSON.parse(sends[0].message.data), ["EVENT", event]); + assert.equal(client.publishes.has(event.id), true); + + await deliver(client, ["OK", event.id, true, ""]); + await published; + assert.equal(client.publishes.has(event.id), false); +}); + +test("a disconnected client does not send after the shared gate expires", async () => { + reset(); + activateRateLimit(4); + const client = connectedClient(); + const event = { id: "d".repeat(64), kind: 5 }; + + const published = client.publishEvent(event); + await Promise.resolve(); + client.disconnect(); + tickTo(4_000); + + await assert.rejects(published, /not connected/); + assert.equal(sends.length, 0); + assert.equal(client.publishes.has(event.id), false); +}); diff --git a/desktop/src/shared/api/relayClientPublishRejection.test.mjs b/desktop/src/shared/api/relayClientPublishRejection.test.mjs new file mode 100644 index 00000000000..7875b8679ab --- /dev/null +++ b/desktop/src/shared/api/relayClientPublishRejection.test.mjs @@ -0,0 +1,295 @@ +// A relay rejection addressed to one event must settle that event's pending +// publish *and* arm the rate-limit gate. +// +// History: the relay rejected an over-quota EVENT with a bare +// `["NOTICE", "rate-limited: ..."]`. A NOTICE carries no event id, and +// `pendingEvents` is keyed by event id, so nothing settled — the publish sat +// until PUBLISH_TIMEOUT_MS (25s) and surfaced as a message stuck on +// "Sending…". Startup quota exhaustion made that routine in the first seconds +// after launch. The relay now rejects on the OK channel instead, so the gate +// arming that used to live in the NOTICE branch has to happen here too. +import assert from "node:assert/strict"; +import test from "node:test"; + +const fakeNow = 0; +const pendingTimers = new Map(); +let nextTimerId = 1; +const sendAttempts = []; +const deliveredFrames = []; +let sendTransport = async (args) => { + deliveredFrames.push(args); +}; + +globalThis.window = { + setTimeout: (fn, ms) => { + const id = nextTimerId++; + pendingTimers.set(id, { fn, fireAt: fakeNow + ms }); + return id; + }, + clearTimeout: (id) => pendingTimers.delete(id), + __TAURI_INTERNALS__: { + invoke: async (command, args) => { + if (command === "plugin:websocket|send") { + sendAttempts.push(args); + return sendTransport(args); + } + }, + }, +}; +Date.now = () => fakeNow; + +const { RelayClient } = await import("./relayClientSession.ts"); +const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import( + "./relayRateLimitGate.ts" +); + +function reset() { + resetRateLimitGate(); + pendingTimers.clear(); + nextTimerId = 1; + sendAttempts.length = 0; + deliveredFrames.length = 0; + sendTransport = async (args) => { + deliveredFrames.push(args); + }; +} + +function connectedClient() { + const client = new RelayClient(); + client.wsId = 7; + return client; +} + +function eventFrames() { + return deliveredFrames.filter( + ({ message }) => JSON.parse(message.data)[0] === "EVENT", + ); +} + +async function flushUntil(predicate, attempts = 20) { + for (let attempt = 0; attempt < attempts; attempt++) { + if (predicate()) return; + await Promise.resolve(); + } + assert.fail("condition did not become true before the microtask limit"); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +/** + * Registers a pending publish the way `publishEvent` does, without needing a + * socket: the OK dispatch under test only reads `pendingEvents`. + */ +function armPendingPublish(client, eventId) { + const event = { id: eventId }; + const settled = new Promise((resolve, reject) => { + client.pendingEvents.set(eventId, { + event, + resolve, + reject, + timeout: window.setTimeout(() => {}, 25_000), + }); + }); + // Keep the rejection from surfacing as an unhandled rejection. + return settled.then( + (value) => ({ status: "resolved", value }), + (error) => ({ status: "rejected", error }), + ); +} + +/** Feeds a raw relay frame through the real inbound dispatch path. */ +function deliver(client, frame) { + return client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.connectionGeneration, + ); +} + +test("a rate-limited OK rejection settles the pending publish", async () => { + resetRateLimitGate(); + pendingTimers.clear(); + const client = new RelayClient(); + const eventId = "a".repeat(64); + const settled = armPendingPublish(client, eventId); + + await deliver(client, [ + "OK", + eventId, + false, + "rate-limited: quota exceeded; retry in 4s", + ]); + + const outcome = await settled; + assert.equal( + outcome.status, + "rejected", + "an over-quota publish must fail fast, not hang until the 25s publish timeout", + ); + assert.match(outcome.error.message, /rate-limited/); + assert.equal( + client.pendingEvents.has(eventId), + false, + "the pending entry must be cleared", + ); +}); + +test("a rate-limited OK rejection arms the rate-limit gate", async () => { + resetRateLimitGate(); + pendingTimers.clear(); + const client = new RelayClient(); + const eventId = "b".repeat(64); + const settled = armPendingPublish(client, eventId); + + assert.equal(isRateLimited(), false, "gate starts closed"); + + await deliver(client, [ + "OK", + eventId, + false, + "rate-limited: quota exceeded; retry in 4s", + ]); + await settled; + + assert.equal( + isRateLimited(), + true, + "back-pressure now arrives on the OK channel — without arming here the " + + "client fails the send and immediately retries into the same quota", + ); +}); + +test("an ordinary OK rejection does not arm the gate", async () => { + resetRateLimitGate(); + pendingTimers.clear(); + const client = new RelayClient(); + const eventId = "c".repeat(64); + const settled = armPendingPublish(client, eventId); + + await deliver(client, ["OK", eventId, false, "invalid: bad signature"]); + const outcome = await settled; + + assert.equal(outcome.status, "rejected"); + assert.equal( + isRateLimited(), + false, + "only `rate-limited:` rejections signal back-pressure", + ); +}); + +test("an accepted OK still resolves the pending publish", async () => { + reset(); + const client = new RelayClient(); + const eventId = "d".repeat(64); + const settled = armPendingPublish(client, eventId); + + await deliver(client, ["OK", eventId, true, ""]); + const outcome = await settled; + + assert.equal(outcome.status, "resolved"); + assert.equal(outcome.value.id, eventId); +}); + +test("a publish started during an ordinary outage reconnects once and settles", async () => { + reset(); + const client = new RelayClient(); + const event = { id: "0".repeat(64), kind: 1 }; + let reconnects = 0; + client.ensureConnected = async () => { + reconnects++; + client.connectionGeneration++; + client.wsId = 8; + return client.connectionGeneration; + }; + + const published = client.publishEvent(event, "timed out", "send failed"); + await flushUntil(() => eventFrames().length === 1); + + assert.equal(reconnects, 1); + assert.equal(sendAttempts.length, 1); + assert.equal(client.pendingEvents.has(event.id), true); + + await deliver(client, ["OK", event.id, true, ""]); + assert.equal(await published, event); + assert.equal(client.pendingEvents.size, 0); +}); + +test("a community switch while gated cannot publish through its replacement socket", async () => { + reset(); + activateRateLimit(4); + const client = connectedClient(); + const event = { id: "e".repeat(64), kind: 1 }; + + const published = client.publishEvent(event, "timed out", "send failed"); + await Promise.resolve(); + assert.equal(client.pendingEvents.size, 0); + + client.disconnect(); + resetRateLimitGate(); + client.wsId = 8; + + await assert.rejects(published, /community switch/); + assert.equal(client.pendingEvents.size, 0); + assert.equal(eventFrames().length, 0); +}); + +test("a community switch after send failure cannot retry through its replacement socket", async () => { + reset(); + const client = connectedClient(); + const event = { id: "f".repeat(64), kind: 1 }; + const reconnect = deferred(); + client.ensureConnected = async () => { + await reconnect.promise; + return client.connectionGeneration; + }; + sendTransport = async () => { + throw new Error("old socket failed"); + }; + + const published = client.publishEvent(event, "timed out", "send failed"); + const outcome = published.then( + () => ({ status: "resolved" }), + (error) => ({ status: "rejected", error }), + ); + await flushUntil(() => client.connectionGeneration === 1); + assert.equal(sendAttempts.length, 1); + assert.equal(eventFrames().length, 0); + assert.equal( + client.connectionGeneration, + 1, + "the failed send reset its socket", + ); + assert.equal( + client.pendingEvents.has(event.id), + true, + "the original publish remains owned while reconnect is pending", + ); + + client.disconnect(); + client.wsId = 8; + const settledBeforeReconnect = await outcome; + assert.equal( + settledBeforeReconnect.status, + "rejected", + "community switch must settle the publish without waiting for reconnect", + ); + assert.match(settledBeforeReconnect.error.message, /community switch/); + + reconnect.resolve(); + await published.catch(() => {}); + await Promise.resolve(); + assert.equal(client.pendingEvents.size, 0); + assert.equal( + sendAttempts.length, + 1, + "the replacement socket must not be used", + ); + assert.equal(eventFrames().length, 0); +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 988013bfbc7..9e13fcba3da 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -38,11 +38,8 @@ import { } from "@/shared/api/relayClosedRecovery"; import { getChannelReconnectRepairEvents } from "@/shared/api/channelReconnectRepair"; import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay"; -import { - activateRateLimit, - parseRateLimitHint, - waitForRateLimit, -} from "@/shared/api/relayRateLimitGate"; +import { publishSessionEvent } from "@/shared/api/relayEventPublisher"; +import { activateRateLimitIfSignalled } from "@/shared/api/relayRateLimitGate"; import { fetchChunkedHistory, requestFirstEventGated, @@ -64,7 +61,6 @@ import { BACKOFF_RESET_STABLE_MS, EVENT_BATCH_MS, HISTORY_TIMEOUT_MS, - PUBLISH_TIMEOUT_MS, RECONNECT_BASE_DELAY_MS, RECONNECT_MAX_DELAY_MS, STALL_CHECK_INTERVAL_MS, @@ -82,7 +78,7 @@ import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; export class RelayClient { private wsId: number | null = null; private relayUrl: string | null = null; - private connectPromise: Promise | null = null; + private connectPromise: Promise | null = null; private reconnectTimeout: number | null = null; private reconnectWaiters = new RelayReconnectWaiters(); private reconnectDelayMs = RECONNECT_BASE_DELAY_MS; @@ -97,6 +93,7 @@ export class RelayClient { private notifyReconnectListeners = false; private onMessageChannel: Channel | null = null; private connectionGeneration = 0; + private sessionEpoch = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; private authOkTracker = new AuthOkTracker(); @@ -126,6 +123,7 @@ export class RelayClient { this.stabilityTimer = null; } this.stallWatchdog.stop(); + this.sessionEpoch++; this.connectionGeneration++; this.keepAliveRequested = false; this.relayUrl = null; @@ -496,7 +494,7 @@ export class RelayClient { } if (this.wsId !== null) { - return; + return this.connectionGeneration; } if ( @@ -507,14 +505,14 @@ export class RelayClient { // The reconnect coordinator owns outage pacing. Query, publish, and // subscription callers must wait for its scheduled attempt instead of // clearing the timer and creating an immediate reconnect storm. - return this.reconnectWaiters.wait(); + return this.reconnectWaiters.wait().then(() => this.connectionGeneration); } const connectPromise = this.connect(); this.connectPromise = connectPromise; try { - await connectPromise; + return await connectPromise; } finally { if (this.connectPromise === connectPromise) { this.connectPromise = null; @@ -584,6 +582,7 @@ export class RelayClient { await this.replayLiveSubscriptions(); this.stallWatchdog.start(); this.emitReconnectIfNeeded(); + return generation; } catch (error) { const connectionError = this.normalizeRelayError( error, @@ -663,6 +662,17 @@ export class RelayClient { }); } + private async sendRawForGeneration(payload: unknown[], generation: number) { + if (generation !== this.connectionGeneration || this.wsId === null) { + throw new Error("Relay publish was superseded by a session change."); + } + const wsId = this.wsId; + await invoke("plugin:websocket|send", { + id: wsId, + message: { type: "Text", data: JSON.stringify(payload) }, + }); + } + private normalizeRelayError(error: unknown, fallbackMessage: string) { return error instanceof Error ? error : new Error(fallbackMessage); } @@ -712,47 +722,23 @@ export class RelayClient { timeoutMessage: string, sendErrorMessage: string, ) { - // Await the gate before sending EVENT; op timeout starts after the wait. - await waitForRateLimit(); - - return new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - this.pendingEvents.delete(event.id); - reject(new Error(timeoutMessage)); - }, PUBLISH_TIMEOUT_MS); - - this.pendingEvents.set(event.id, { - event, - resolve, - reject, - timeout, - }); - - void this.sendRaw(["EVENT", event]).catch(async (error) => { - const pendingEvent = this.pendingEvents.get(event.id); - this.pendingEvents.delete(event.id); - const normalizedError = this.recoverFromSocketFailure( - error, - sendErrorMessage, - ); - - try { - await this.ensureConnected(); - if (!pendingEvent) { - throw normalizedError; - } - - this.pendingEvents.set(event.id, pendingEvent); - await this.sendRaw(["EVENT", event]); - } catch (retryError) { - window.clearTimeout(timeout); - this.pendingEvents.delete(event.id); - reject( - this.recoverFromSocketFailure(retryError, normalizedError.message), - ); - } - }); - }); + return publishSessionEvent( + { + generation: () => this.connectionGeneration, + ownership: () => this.sessionEpoch, + pendingEvents: this.pendingEvents, + send: (payload, generation) => + this.sendRawForGeneration(payload, generation), + reconnect: () => this.ensureConnected(), + normalizeError: (error, fallback) => + this.normalizeRelayError(error, fallback), + recoverSocketFailure: (error, fallback) => + this.recoverFromSocketFailure(error, fallback), + }, + event, + timeoutMessage, + sendErrorMessage, + ); } private async handleWsMessage(message: unknown, generation: number) { @@ -829,11 +815,8 @@ export class RelayClient { } if (type === "NOTICE" && typeof rest[0] === "string") { - const notice: string = rest[0]; - // Relay back-pressure — arm the gate until the window expires. - if (notice.startsWith("rate-limited:")) { - activateRateLimit(parseRateLimitHint(notice)); - } + // Connection-scoped back-pressure — arm the gate until it expires. + activateRateLimitIfSignalled(rest[0]); } } @@ -922,6 +905,10 @@ export class RelayClient { if (success) { pendingEvent.resolve(pendingEvent.event); } else { + // Back-pressure now arrives here rather than as a NOTICE: the relay + // rejects an over-quota EVENT on the OK channel so this pending publish + // can be settled at all. Unarmed, the send retries into the same quota. + activateRateLimitIfSignalled(message); pendingEvent.reject(new Error(message || "Relay rejected the event.")); } } diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts new file mode 100644 index 00000000000..ff719926700 --- /dev/null +++ b/desktop/src/shared/api/relayEventPublisher.ts @@ -0,0 +1,84 @@ +import type { RelayEvent } from "@/shared/api/types"; +import type { PendingEvent } from "@/shared/api/relayClientShared"; +import { waitForRateLimit } from "@/shared/api/relayRateLimitGate"; +import { PUBLISH_TIMEOUT_MS } from "@/shared/api/relayClientTimings"; + +type PublishSession = { + generation: () => number; + ownership: () => number; + pendingEvents: Map; + send: (payload: unknown[], generation: number) => Promise; + reconnect: () => Promise; + normalizeError: (error: unknown, fallback: string) => Error; + recoverSocketFailure: (error: unknown, fallback: string) => Error; +}; + +/** Publish once, with one reconnect retry, without crossing session ownership. */ +export async function publishSessionEvent( + session: PublishSession, + event: RelayEvent, + timeoutMessage: string, + sendErrorMessage: string, +): Promise { + const publishOwnership = session.ownership(); + await waitForRateLimit(); + if (publishOwnership !== session.ownership()) { + throw new Error("Relay disconnected for community switch."); + } + const publishGeneration = session.generation(); + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + session.pendingEvents.delete(event.id); + reject(new Error(timeoutMessage)); + }, PUBLISH_TIMEOUT_MS); + const pendingEvent = { event, resolve, reject, timeout }; + session.pendingEvents.set(event.id, pendingEvent); + + void session + .send(["EVENT", event], publishGeneration) + .catch(async (error) => { + // A disconnect may already have rejected this operation while the send + // was in flight. Its late failure must not reset the replacement session. + if ( + publishOwnership !== session.ownership() || + publishGeneration !== session.generation() || + session.pendingEvents.get(event.id) !== pendingEvent + ) { + return; + } + + // Expected socket recovery must not reject the operation being retried. + session.pendingEvents.delete(event.id); + const sendError = session.recoverSocketFailure(error, sendErrorMessage); + session.pendingEvents.set(event.id, pendingEvent); + let retryGeneration: number | null = null; + + try { + retryGeneration = await session.reconnect(); + if ( + publishOwnership !== session.ownership() || + session.generation() !== retryGeneration || + session.pendingEvents.get(event.id) !== pendingEvent + ) { + throw new Error( + "Relay publish was superseded by a session change.", + ); + } + await session.send(["EVENT", event], retryGeneration); + } catch (retryError) { + if (session.pendingEvents.get(event.id) !== pendingEvent) return; + + window.clearTimeout(timeout); + session.pendingEvents.delete(event.id); + reject( + publishOwnership === session.ownership() && + retryGeneration !== null && + session.generation() === retryGeneration + ? session.recoverSocketFailure(retryError, sendError.message) + : session.normalizeError(retryError, sendError.message), + ); + } + }); + }); +} diff --git a/desktop/src/shared/api/relayRateLimitGate.ts b/desktop/src/shared/api/relayRateLimitGate.ts index 0af3eed7d9d..040bedae780 100644 --- a/desktop/src/shared/api/relayRateLimitGate.ts +++ b/desktop/src/shared/api/relayRateLimitGate.ts @@ -87,6 +87,24 @@ export function activateRateLimit(retryInSeconds: number | null): void { }, durationMs); } +/** + * Arms the gate if `message` is a relay back-pressure signal, and reports + * whether it was. + * + * The relay marks back-pressure with a `rate-limited:` prefix on whichever + * frame carries the rejection — `NOTICE` for connection-scoped limits, `OK` + * for one addressed to a single event, `CLOSED` for a subscription. Every + * inbound path needs the same test, so it lives here with the gate rather than + * being re-derived per call site. + */ +export function activateRateLimitIfSignalled(message: string): boolean { + if (!message.startsWith("rate-limited:")) { + return false; + } + activateRateLimit(parseRateLimitHint(message)); + return true; +} + /** Returns `true` when the relay has signalled back-pressure and the gate is active. */ export function isRateLimited(): boolean { return expiresAt !== null && Date.now() < expiresAt; diff --git a/desktop/src/shared/api/tauriPersonas.test.mjs b/desktop/src/shared/api/tauriPersonas.test.mjs index 13fe66e4382..b93f61b9107 100644 --- a/desktop/src/shared/api/tauriPersonas.test.mjs +++ b/desktop/src/shared/api/tauriPersonas.test.mjs @@ -28,3 +28,12 @@ test("fromRawPersona maps source_team to sourceTeam", () => { assert.equal(persona.sourceTeam, "team-research"); }); + +test("fromRawPersona maps authored description and defaults absence to null", () => { + assert.equal( + fromRawPersona(rawPersona({ description: "A careful analyst." })) + .description, + "A careful analyst.", + ); + assert.equal(fromRawPersona(rawPersona()).description, null); +}); diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 3cd9734ae26..d1619daea4f 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -10,6 +10,8 @@ export type RawPersona = { id: string; display_name: string; avatar_url: string | null; + /** Optional short, PUBLIC description (max 280 chars). */ + description?: string | null; system_prompt: string; runtime?: string | null; model?: string | null; @@ -40,6 +42,7 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { id: persona.id, displayName: persona.display_name, avatarUrl: persona.avatar_url, + description: persona.description ?? null, systemPrompt: persona.system_prompt, runtime: persona.runtime ?? null, model: persona.model ?? null, @@ -64,6 +67,22 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { }; } +/** + * Normalize only the unambiguous empty/absent cases for the wire. The trusted + * Rust boundary validates the authored bytes before applying trim/empty + * storage normalization. + */ +function normalizeDescription( + description: string | null | undefined, +): string | null { + if (description === null || description === undefined || description === "") { + return null; + } + // Preserve the authored bytes for the Rust boundary to validate. Trimming + // here could turn a prohibited edge control into apparently valid text. + return description; +} + export async function listPersonas(): Promise { return (await invokeTauri("list_personas")).map(fromRawPersona); } @@ -76,6 +95,7 @@ export async function createPersona( input: { displayName: input.displayName, avatarUrl: input.avatarUrl, + description: normalizeDescription(input.description), systemPrompt: input.systemPrompt, runtime: input.runtime, model: input.model, @@ -95,6 +115,7 @@ function updatePersonaPayload(input: UpdatePersonaInput) { id: input.id, displayName: input.displayName, avatarUrl: input.avatarUrl, + description: normalizeDescription(input.description), systemPrompt: input.systemPrompt, runtime: input.runtime, model: input.model, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 759245e5ea2..fd0b0505605 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -638,89 +638,16 @@ export type UpdateManagedAgentInput = { */ respondToAllowlist?: string[]; }; -export type AgentPersona = { - id: string; - displayName: string; - avatarUrl: string | null; - systemPrompt: string; - /** Preferred ACP runtime ID (e.g. "goose", "claude"). */ - runtime: string | null; - /** Opaque, harness-specific model identifier string. Buzz stores and passes through without interpretation. */ - model: string | null; - /** LLM inference provider (e.g. "databricks", "anthropic"). Injected as the runtime's provider env var at spawn time. */ - provider: string | null; - namePool: string[]; - isBuiltIn: boolean; - isActive: boolean; - /** Whether this persona is discoverable in the active community catalog. */ - shared: boolean; - /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ - sourceTeam?: string | null; - /** - * Set only on a local copy of another owner's shared catalog entry. A copy - * carries a fresh local `id`, so this coordinate is the only thing that can - * answer "is this catalog entry already added" without minting a duplicate. - */ - catalogSource?: CatalogSourceCoordinate | null; - /** Agent environment variables, layered after desktop parent and persona values. */ - envVars: Record; - /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ - respondTo: RespondToMode | null; - respondToAllowlist: string[]; - parallelism: number | null; - createdAt: string; - updatedAt: string; -}; - -/** - * A catalog publication's coordinate: the owner who published it and the - * `d`-tag identifying the persona within that owner's catalog. Mirrors the - * backend `CatalogSource`. - */ -export type CatalogSourceCoordinate = { - ownerPubkey: string; - personaId: string; -}; - -/** - * NIP-AP behavioral group for a definition: absent preserves the stored group - * for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`. - */ -export type PersonaBehaviorInput = { - respondTo?: RespondToMode; - respondToAllowlist?: string[]; - parallelism?: number; -}; - -export type CreatePersonaInput = { - displayName: string; - avatarUrl?: string; - systemPrompt: string; - runtime?: string; - model?: string; - provider?: string; - namePool?: string[]; - envVars?: Record; - behavior?: PersonaBehaviorInput; - /** - * Set when this persona is a copy of another owner's shared catalog entry, - * so the catalog can tell an already-added foreign entry from a new one. - */ - catalogSource?: CatalogSourceCoordinate; -}; - -export type UpdatePersonaInput = { - id: string; - displayName: string; - avatarUrl?: string; - systemPrompt: string; - runtime?: string; - model?: string; - provider?: string; - namePool?: string[]; - envVars?: Record; - behavior?: PersonaBehaviorInput; -}; +// Persona (agent definition) types live in a sibling module to keep this +// file inside the repo-wide size ratchet; re-exported so import paths +// (`@/shared/api/types`) are unchanged. +export type { + AgentPersona, + CatalogSourceCoordinate, + CreatePersonaInput, + PersonaBehaviorInput, + UpdatePersonaInput, +} from "./personaTypes"; // ── Team types ──────────────────────────────────────────────────────────────── export type { diff --git a/desktop/src/shared/features/manifest.ts b/desktop/src/shared/features/manifest.ts index 1e6f48ae017..423fbc3b36b 100644 --- a/desktop/src/shared/features/manifest.ts +++ b/desktop/src/shared/features/manifest.ts @@ -1,4 +1,5 @@ import manifestJson from "@features-manifest"; +import { protectedFeatureDefinitions } from "@protected-features"; import { z } from "zod"; import type { FeatureDefinition, FeaturesManifest } from "./types"; @@ -25,7 +26,10 @@ const FeaturesManifestSchema = z.object({ const EMPTY_MANIFEST: FeaturesManifest = { version: 1, features: [] }; function loadManifest(): FeaturesManifest { - const result = FeaturesManifestSchema.safeParse(manifestJson); + const result = FeaturesManifestSchema.safeParse({ + ...manifestJson, + features: [...manifestJson.features, ...protectedFeatureDefinitions], + }); if (!result.success) { console.warn( "[FeatureFlags] preview-features.json failed schema validation; falling back to empty manifest.", diff --git a/desktop/src/shared/features/useFeatureEnabled.ts b/desktop/src/shared/features/useFeatureEnabled.ts index b0c9878d0b7..1be1e5e30e4 100644 --- a/desktop/src/shared/features/useFeatureEnabled.ts +++ b/desktop/src/shared/features/useFeatureEnabled.ts @@ -105,6 +105,8 @@ export function useFeatureEnabled(featureId: string): boolean { return resolveEnabled(featureId, overrides, feature.defaultEnabled); } +export { resolveEnabled } from "./resolveEnabled"; + /** * Hook to toggle a feature override. Returns [enabled, toggle]. */ @@ -157,5 +159,3 @@ export function usePreviewFeatureWarning(featureId: string): void { }; }, [feature, enabled]); } - -export { resolveEnabled } from "./resolveEnabled"; diff --git a/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx b/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx index 5140c4d8d52..78a133dbf12 100644 --- a/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx +++ b/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx @@ -99,6 +99,7 @@ function AuxiliaryPanelHeaderBackdrop({ "pointer-events-none absolute inset-x-0 top-0 z-40 h-13", getAuxiliaryPanelSurfaceClass(surface), )} + data-testid="auxiliary-panel-header-backdrop" /> ); } @@ -166,25 +167,30 @@ export function AuxiliaryPanelHeader({ } return ( -
+ <> + {backdrop && backdropSurface !== "transparent" ? ( + + ) : null}
-
- {renderAuxiliaryPanelHeaderContent(children)} +
+
+ {renderAuxiliaryPanelHeaderContent(children)} +
-
+ ); } diff --git a/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs b/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs index 69cea299740..e59d3ee5c83 100644 --- a/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs +++ b/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs @@ -177,6 +177,54 @@ test("AuxiliaryPanelHeader renders a generic close action from context", () => { assert.match(html, /data-testid="auxiliary-panel-close"/); }); +test("AuxiliaryPanelHeader adds its requested backdrop in docked mode", () => { + const html = render( + React.createElement( + AuxiliaryPanel, + { + header: React.createElement( + AuxiliaryPanelHeader, + { backdrop: true }, + React.createElement(AuxiliaryPanelHeaderGroup, null, "Title"), + ), + layout: "split", + onClose: () => {}, + widthPx: 420, + }, + "Panel", + ), + ); + + assert.match(html, /data-testid="auxiliary-panel-header-backdrop"/); + assert.match(html, /pointer-events-none absolute inset-x-0 top-0 z-40 h-13/); +}); + +test("AuxiliaryPanelHeader honors an explicit transparent docked backdrop", () => { + const html = render( + React.createElement( + AuxiliaryPanel, + { + header: React.createElement( + AuxiliaryPanelHeader, + { backdrop: true, backdropSurface: "transparent" }, + React.createElement(AuxiliaryPanelHeaderGroup, null, "Title"), + ), + layout: "split", + onClose: () => {}, + transparentChrome: true, + widthPx: 420, + }, + "Panel", + ), + ); + + assert.doesNotMatch(html, /data-testid="auxiliary-panel-header-backdrop"/); + assert.doesNotMatch( + html, + /pointer-events-none absolute inset-x-0 top-0 z-40 h-13/, + ); +}); + test("AuxiliaryPanelHeader keeps resize border in single-panel mode when requested", () => { const html = render( React.createElement( diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 40fb9dc310b..8618d83c5a1 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -37,6 +37,7 @@ type UserAvatarProps = { displayName: string; size?: UserAvatarSize; accent?: boolean; + shape?: "circle" | "squircle"; className?: string; fallbackDelayMs?: number; testId?: string; @@ -47,6 +48,7 @@ export function UserAvatar({ displayName, size = "md", accent = false, + shape, className, fallbackDelayMs = 200, testId, @@ -61,12 +63,20 @@ export function UserAvatar({ : avatarUrl ? rewriteRelayUrl(avatarUrl) : null; + const resolvedShape = shape ?? "circle"; + const radiusClass = + resolvedShape === "squircle" ? "rounded-[30%]" : "rounded-full"; return ( setIsHovered(true) : undefined} onMouseLeave={animated ? () => setIsHovered(false) : undefined} diff --git a/desktop/src/shared/ui/VideoPlayer.tsx b/desktop/src/shared/ui/VideoPlayer.tsx index 71753104014..f94eb5cc5ce 100644 --- a/desktop/src/shared/ui/VideoPlayer.tsx +++ b/desktop/src/shared/ui/VideoPlayer.tsx @@ -43,7 +43,6 @@ import { saveReviewPlaybackPosition, setVideoReviewOpen, } from "./videoPlayerState"; - type VideoReviewReaction = { emoji: string; emojiUrl?: string; @@ -55,11 +54,11 @@ type VideoReviewReaction = { avatarUrl: string | null; }>; }; - export type VideoReviewComment = { id: string; author: string; avatarUrl?: string | null; + isAgent?: boolean; body: string; createdAt: number; time: string; @@ -67,7 +66,6 @@ export type VideoReviewComment = { parentId?: string | null; reactions?: VideoReviewReaction[]; }; - export type VideoReviewContext = { channelId?: string | null; channelName?: string; @@ -91,7 +89,6 @@ export type VideoReviewContext = { rootEventId?: string; title?: string; }; - type VideoPlayerProps = { src: string; poster?: string; @@ -108,14 +105,12 @@ type VideoPlayerProps = { /** imeta `filename`, used as the save-dialog name. */ filename?: string; }; - type TimecodedComment = { comment: VideoReviewComment; seconds: number | null; timecode: string | null; text: string; }; - const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"]; const DEFAULT_PLAYBACK_SPEED = 1; const INLINE_SPEED_CONTROL_MIN_WIDTH = 220; @@ -1813,6 +1808,9 @@ function VideoReviewDialog({ avatarUrl={item.comment.avatarUrl ?? null} className="h-4 w-4 shadow-none" displayName={item.comment.author} + shape={ + item.comment.isAgent ? "squircle" : "circle" + } size="xs" /> @@ -2143,6 +2141,7 @@ function VideoReviewCommentBody({ avatarUrl={item.comment.avatarUrl ?? null} className="h-6 w-6 shadow-none" displayName={item.comment.author} + shape={item.comment.isAgent ? "squircle" : "circle"} size="xs" />

diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4404302a548..1d9f98f0d3a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1004,6 +1004,7 @@ type RawPersona = { id: string; display_name: string; avatar_url: string | null; + description?: string | null; system_prompt: string; runtime?: string | null; model?: string | null; @@ -3411,6 +3412,7 @@ function mockPersonaCatalogPublications() { ); }); }; + const rawDescription = content.description; if ( typeof displayName !== "string" || !displayName.trim() || @@ -3418,7 +3420,13 @@ function mockPersonaCatalogPublications() { typeof systemPrompt !== "string" || new TextEncoder().encode(systemPrompt).length > 64 * 1024 || !hasValidVisibleText(displayName, false) || - !hasValidVisibleText(systemPrompt, true) + !hasValidVisibleText(systemPrompt, true) || + (rawDescription !== undefined && + rawDescription !== null && + typeof rawDescription !== "string") || + (typeof rawDescription === "string" && + ([...rawDescription].length > 280 || + !hasValidVisibleText(rawDescription, false))) ) continue; publications.push({ @@ -3429,6 +3437,7 @@ function mockPersonaCatalogPublications() { agent: { displayName, avatarUrl: optionalString(content.avatar_url), + description: optionalString(rawDescription), systemPrompt, runtime: optionalString(content.runtime), model: optionalString(content.model), @@ -8697,6 +8706,7 @@ async function handleCreatePersona(args: { input: { displayName: string; avatarUrl?: string; + description?: string | null; systemPrompt: string; runtime?: string; model?: string; @@ -8711,6 +8721,7 @@ async function handleCreatePersona(args: { id: crypto.randomUUID(), display_name: args.input.displayName.trim(), avatar_url: args.input.avatarUrl?.trim() || null, + description: args.input.description?.trim() || null, system_prompt: args.input.systemPrompt.trim(), runtime: args.input.runtime?.trim() || null, model: args.input.model?.trim() || null, @@ -8743,6 +8754,7 @@ type MockUpdatePersonaInput = { id: string; displayName: string; avatarUrl?: string; + description?: string | null; systemPrompt: string; runtime?: string; model?: string; @@ -8776,6 +8788,7 @@ async function applyMockPersonaUpdate( } persona.display_name = input.displayName.trim(); persona.avatar_url = input.avatarUrl?.trim() || null; + persona.description = input.description?.trim() || null; persona.system_prompt = input.systemPrompt.trim(); persona.runtime = input.runtime?.trim() || null; persona.model = input.model?.trim() || null; @@ -8881,6 +8894,7 @@ function upsertMockPersonaEvent( display_name: persona.display_name, system_prompt: persona.system_prompt, avatar_url: persona.avatar_url, + description: persona.description ?? null, runtime: persona.runtime ?? null, model: persona.model ?? null, provider: persona.provider ?? null, diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs index 06c44ae2130..d473587adf3 100644 --- a/desktop/test-loader-hooks.mjs +++ b/desktop/test-loader-hooks.mjs @@ -89,6 +89,12 @@ export function resolve(specifier, context, nextResolve) { const resolved = path.join(repoRoot, "preview-features.json"); return nextResolve(toFileSpecifier(resolved), context); } + if (specifier === "@protected-features") { + const variant = + process.env.VITE_BUZZ_BESTIE === "1" ? "internal.ts" : "public.ts"; + const resolved = path.join(srcRoot, "protectedFeatures", variant); + return nextResolve(toFileSpecifier(resolved), context); + } if (specifier === "@model-capabilities-manifest") { const resolved = path.join(repoRoot, "scripts", "model-capabilities.json"); return nextResolve(toFileSpecifier(resolved), context); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index ba84a6f0da1..57d018f38c5 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -20,6 +20,7 @@ function createCatalogEvent(input: { createdAt?: number; shared?: boolean; avatarUrl?: string; + description?: string; }): RelayEvent { const ownerPrivateKey = input.ownerPrivateKey ?? @@ -43,6 +44,7 @@ function createCatalogEvent(input: { display_name: input.displayName, system_prompt: input.systemPrompt, avatar_url: input.avatarUrl ?? null, + description: input.description ?? null, runtime: null, model: null, provider: null, @@ -309,6 +311,7 @@ test("built-in persona edits persist", async ({ page }) => { const dialog = page.getByTestId("persona-dialog"); await dialog.getByLabel("Agent name").fill("My Fizz"); + await dialog.getByLabel("Description").fill("Helps teams ship reliably."); await dialog.getByLabel("Agent instruction").fill("User-edited instructions"); await dialog.getByRole("button", { name: "Save changes" }).click(); @@ -316,13 +319,22 @@ test("built-in persona edits persist", async ({ page }) => { await expect(page.getByTestId("agents-library-personas")).toContainText( "My Fizz", ); + await expect( + page.getByTestId("persona-agent-row-builtin:fizz"), + ).toContainText("Helps teams ship reliably."); const personas = await invokeTauri< - Array<{ id: string; display_name: string; system_prompt: string }> + Array<{ + id: string; + display_name: string; + description: string | null; + system_prompt: string; + }> >(page, "list_personas"); expect( personas.find((persona) => persona.id === "builtin:fizz"), ).toMatchObject({ display_name: "My Fizz", + description: "Helps teams ship reliably.", system_prompt: "User-edited instructions", }); }); @@ -624,8 +636,38 @@ test("team cards use the thread-style overlapping avatar stack", async ({ ); expect(boxes[1]?.left).toBeLessThan(boxes[0]?.right ?? 0); expect(boxes[2]?.left).toBeLessThan(boxes[1]?.right ?? 0); - await expect(avatars.first()).not.toHaveCSS("mask-image", "none"); - await expect(avatars.last()).toHaveCSS("mask-image", "none"); + const overlapStyles = await avatars.evaluateAll((elements) => + elements.map((element) => { + const styles = getComputedStyle(element); + const outline = getComputedStyle(element, "::before"); + return { + maskImage: styles.maskImage, + outlineBackground: outline.backgroundColor, + outlineBorderRadius: outline.borderRadius, + outlineInset: outline.inset, + }; + }), + ); + expect(overlapStyles).toEqual([ + { + maskImage: "none", + outlineBackground: "rgb(255, 255, 255)", + outlineBorderRadius: "calc(30% + 2px)", + outlineInset: "-2px", + }, + { + maskImage: "none", + outlineBackground: "rgb(255, 255, 255)", + outlineBorderRadius: "calc(30% + 2px)", + outlineInset: "-2px", + }, + { + maskImage: "none", + outlineBackground: "rgb(255, 255, 255)", + outlineBorderRadius: "calc(30% + 2px)", + outlineInset: "-2px", + }, + ]); const avatarSurfaceStyles = await avatars .locator(":scope > *") .evaluateAll((elements) => @@ -806,34 +848,55 @@ test("agent catalog chooser order stays stable when selection changes", async ({ expect(await getCatalogOrder(page)).toEqual(before); }); -test("catalog detail pane shows the full persona details", async ({ page }) => { - const personaId = "custom:researcher"; - await seedActiveIdentity(page, TEST_IDENTITIES.tyler); +test("catalog detail pane shows the full persona details before Add agent", async ({ + page, +}) => { + const personaId = "remote-researcher"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + const description = `Maps evidence across systems: ${"界".repeat(180)}`; await installMockBridge(page, { - personas: [ - { - id: personaId, - displayName: "Researcher", + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + displayName: "Alice’s Researcher", + description, systemPrompt: "Research the question and cite the evidence.", - }, + }), ], }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await sharePersonaToCatalog(page, "Researcher"); await openPersonaCatalog(page); - await selectCatalogPersona(page, personaId); + const catalogRow = page.getByTestId( + `community-catalog-agent-${remoteCatalogId}`, + ); + await expect(catalogRow).toContainText("Alice’s Researcher"); + const rowDescription = page.getByTestId( + `community-catalog-agent-description-${remoteCatalogId}`, + ); + await expect(rowDescription).toHaveText(description); + await expect(rowDescription).toHaveCSS("overflow", "hidden"); + await catalogRow.click(); + const useAgentTarget = page.getByTestId( - `community-catalog-use-agent-${personaId}`, + `community-catalog-use-agent-${remoteCatalogId}`, ); + const detailDescription = page.getByTestId("persona-catalog-description"); await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( - "Researcher", + "Alice’s Researcher", ); await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( - "Added by You", + "Added by alice", ); + await expect(detailDescription).toHaveText(description); + const detailWidth = await detailDescription.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(detailWidth.scrollWidth).toBeLessThanOrEqual(detailWidth.clientWidth); await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Research the question and cite the evidence.", ); @@ -851,10 +914,10 @@ test("catalog detail pane shows the full persona details", async ({ page }) => { ); await expect(useAgentTarget).toHaveAttribute( "aria-label", - "Researcher is already in My Agents", + "Add Alice’s Researcher from Community Catalog", ); - await expect(useAgentTarget).toHaveText("Added to My Agents"); - await expect(useAgentTarget).toBeDisabled(); + await expect(useAgentTarget).toHaveText("Add agent"); + await expect(useAgentTarget).toBeEnabled(); }); type AgentShareCommand = { command: string; payload: unknown }; diff --git a/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts b/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts index 901d1477a76..55fe6148ced 100644 --- a/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts +++ b/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts @@ -41,7 +41,7 @@ async function waitForMockLiveSubscription( test.describe("channel shared header backdrop", () => { test.use({ viewport: { width: 1280, height: 720 } }); - test("spans channel and split auxiliary columns with one backdrop", async ({ + test("backs a scrolled split auxiliary header above the shared channel backdrop", async ({ page, }) => { await installMockBridge(page); @@ -82,6 +82,43 @@ test.describe("channel shared header backdrop", () => { await replyButton.click({ force: true }); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await page.evaluate( + ({ channelName, parentEventId, pubkey }) => { + for (let index = 0; index < 24; index += 1) { + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: `Scrollable thread reply ${index + 1}. `.repeat(4), + parentEventId, + pubkey, + }); + } + }, + { + channelName: CHANNEL_NAME, + parentEventId: rootId, + pubkey: ALICE_PUBKEY, + }, + ); + + const threadBody = page.getByTestId("message-thread-body"); + await expect + .poll(() => + threadBody.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ) + .toBe(true); + await threadBody.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); + await expect + .poll(() => threadBody.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + + const paneBackdrop = page.getByTestId("auxiliary-panel-header-backdrop"); + await expect(paneBackdrop).toHaveCount(1); + const sharedBackdrop = page.getByTestId("channel-shared-header-backdrop"); await expect(sharedBackdrop).toHaveCount(1); @@ -93,6 +130,9 @@ test.describe("channel shared header backdrop", () => { const [ hostBox, backdropBox, + paneBackdropBox, + paneBackdropBackground, + paneBackdropFilter, backdropFilter, backdropZIndex, headerZIndex, @@ -101,6 +141,13 @@ test.describe("channel shared header backdrop", () => { ] = await Promise.all([ page.getByTestId("channel-drop-zone").locator("..").boundingBox(), sharedBackdrop.boundingBox(), + paneBackdrop.boundingBox(), + paneBackdrop.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ), + paneBackdrop.evaluate( + (element) => getComputedStyle(element).backdropFilter, + ), sharedBackdrop.evaluate( (element) => getComputedStyle(element).backdropFilter, ), @@ -120,6 +167,13 @@ test.describe("channel shared header backdrop", () => { expect(hostBox).not.toBeNull(); expect(backdropBox).not.toBeNull(); + expect(paneBackdropBox).not.toBeNull(); + expect(Math.round(paneBackdropBox?.y ?? 0)).toBe( + Math.round(backdropBox?.y ?? 0), + ); + expect(Math.round(paneBackdropBox?.height ?? 0)).toBe(52); + expect(paneBackdropBackground).not.toBe("rgba(0, 0, 0, 0)"); + expect(paneBackdropFilter).not.toBe("none"); expect(Math.round(backdropBox?.x ?? 0)).toBe(Math.round(hostBox?.x ?? 0)); expect(Math.round(backdropBox?.width ?? 0)).toBe( Math.round(hostBox?.width ?? 0), diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index a2a57c66efb..feb7e7590f2 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -8,6 +8,7 @@ "paths": { "@/*": ["./src/*"], "@features-manifest": ["../preview-features.json"], + "@protected-features": ["./src/protectedFeatures/public.ts"], "@model-capabilities-manifest": ["../scripts/model-capabilities.json"] }, diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 5a5de191204..257c8382bbb 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -1,56 +1,71 @@ import path from "node:path"; -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; const host = process.env.TAURI_DEV_HOST; // https://vite.dev/config/ -export default defineConfig(async () => ({ - plugins: [ - tanstackRouter({ - target: "react", - routesDirectory: "./src/app/routes", - generatedRouteTree: "./src/app/routeTree.gen.ts", - virtualRouteConfig: "./src/app/routes.ts", - quoteStyle: "double", - semicolons: true, - routeTreeFileHeader: [ - "// biome-ignore-all lint: generated by TanStack Router", - ], - }), - react(), - ], - resolve: { - alias: { - "@": "/src", - "@features-manifest": path.resolve(__dirname, "../preview-features.json"), - "@model-capabilities-manifest": path.resolve( - __dirname, - "../scripts/model-capabilities.json", - ), +export default defineConfig(async ({ mode }) => { + const modeEnv = loadEnv(mode, __dirname, ""); + const protectedFeaturesEnabled = + (process.env.VITE_BUZZ_BESTIE ?? modeEnv.VITE_BUZZ_BESTIE) === "1"; + + return { + plugins: [ + tanstackRouter({ + target: "react", + routesDirectory: "./src/app/routes", + generatedRouteTree: "./src/app/routeTree.gen.ts", + virtualRouteConfig: "./src/app/routes.ts", + quoteStyle: "double", + semicolons: true, + routeTreeFileHeader: [ + "// biome-ignore-all lint: generated by TanStack Router", + ], + }), + react(), + ], + resolve: { + alias: { + "@": "/src", + "@features-manifest": path.resolve( + __dirname, + "../preview-features.json", + ), + "@protected-features": path.resolve( + __dirname, + protectedFeaturesEnabled + ? "./src/protectedFeatures/internal.ts" + : "./src/protectedFeatures/public.ts", + ), + "@model-capabilities-manifest": path.resolve( + __dirname, + "../scripts/model-capabilities.json", + ), + }, }, - }, - // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` - // - // 1. prevent Vite from obscuring rust errors - clearScreen: false, - // 2. tauri expects a fixed port, fail if that port is not available - server: { - port: parseInt(process.env.VITE_PORT || "1420", 10), - strictPort: true, - host: host || false, - hmr: host - ? { - protocol: "ws", - host, - port: parseInt(process.env.VITE_HMR_PORT || "1421", 10), - } - : undefined, - watch: { - // 3. tell Vite to ignore watching `src-tauri` - ignored: ["**/src-tauri/**"], + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: parseInt(process.env.VITE_PORT || "1420", 10), + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: parseInt(process.env.VITE_HMR_PORT || "1421", 10), + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, }, - }, -})); + }; +}); diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index e9a9ae16055..b2b66f5b5b4 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -53,6 +53,8 @@ The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefo ## PostgreSQL and replicas +The gateway's dedicated pool does not consume the relay-oriented `BUZZ_DB_LOCK_TIMEOUT_MS`, `BUZZ_DB_IDLE_TXN_TIMEOUT_MS`, or `BUZZ_DB_STATEMENT_TIMEOUT_MS` settings. Its session-timeout policy remains separate from the `buzz-db` writer policy and must be designed and rolled out independently. + All replicas must share one PostgreSQL database. Delivery authority, replay admission, and endpoint quota reservation are transactional there, so replica count does not multiply the abuse ceiling. The gateway owns a scoped migration history under `crates/buzz-push-gateway/migrations`; it creates only the six `push_gateway_*` authority tables plus SQLx's migration-history table and never runs relay migrations. The Helm chart runs a single pre-install/pre-upgrade migration Job using `migration.existingSecret`; that secret contains a DDL-capable `DATABASE_URL`. The URL MUST name a dedicated gateway database, not the relay database: SQLx stores its `_sqlx_migrations` history in `public`, so sharing a database would collide with another application's migration history. `migration.runtimeDatabaseRole` names an existing LOGIN role (the default is `buzz_push_gateway_runtime`) used by runtime `DATABASE_URL`. After scoped migrations, the Job revokes database `CREATE` from that role and schema `CREATE` from both `PUBLIC` and the role, then grants only database `CONNECT`, schema `USAGE`, and `SELECT, INSERT, UPDATE, DELETE` on the six gateway tables. The migration role must own the database/schema objects or otherwise be allowed to issue those grants; it is never provided to runtime replicas. Readiness rejects an empty/partial schema, missing DML, or a runtime role that retains database/schema `CREATE`. Helm waits for the migration hook before updating replicas, so rolling deployments never race unconditional startup migration. Readiness must be removed from load-balancer service endpoints before terminating a pod. diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 86edeea0ee7..1252968baf2 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -89,6 +89,9 @@ class _InboxRow extends HookConsumerWidget { final knownAgentPubkeys = channel == null ? ref.watch(knownAgentPubkeysProvider) : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final isAgent = + knownAgentPubkeys.contains(senderPubkey) || + profile?.ownerPubkey != null; final agentMentionPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: knownAgentPubkeys, profileOwnedAgentPubkeys: [ @@ -223,6 +226,7 @@ class _InboxRow extends HookConsumerWidget { _RowAvatar( pubkey: item.item.pubkey, profile: profile, + isAgent: isAgent, ), const SizedBox(width: messageAvatarContentGap), Expanded( @@ -453,8 +457,13 @@ class _InboxSwipeAction extends StatelessWidget { class _RowAvatar extends StatelessWidget { final String pubkey; final UserProfile? profile; + final bool isAgent; - const _RowAvatar({required this.pubkey, required this.profile}); + const _RowAvatar({ + required this.pubkey, + required this.profile, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -472,6 +481,7 @@ class _RowAvatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index a4a6d1e350a..4041f1efbb2 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -191,6 +191,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { backgroundColor: context.colors.primaryContainer, fallback: Text(user.initial), + isAgent: user.isAgent, ), title: Text( user.label, diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 4bfea3f2795..5ea4c041d78 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -231,6 +231,12 @@ class _DmAppBarTitle extends ConsumerWidget { } final avatarUrl = profile?.avatarUrl; + final isAgent = + (otherPubkey != null && + ref + .watch(agentMentionPubkeysProvider(channel.id)) + .contains(otherPubkey)) || + profile?.ownerPubkey != null; final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl); final initial = profile?.initial ?? @@ -249,7 +255,10 @@ class _DmAppBarTitle extends ConsumerWidget { key: const ValueKey('dm-header-avatar'), size: _dmHeaderAvatarSize, geometry: AvatarBadgeMaskGeometry.presenceDot, - avatar: ClipOval( + avatar: ClipRRect( + borderRadius: BorderRadius.circular( + isAgent ? _dmHeaderAvatarSize * 0.3 : _dmHeaderAvatarSize / 2, + ), child: ColoredBox( color: animatedAvatar == null ? context.colors.primaryContainer diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 7442cb8155d..58127f90119 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -124,6 +124,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { fallbackLabel: fallbackLabel, isSelf: isSelf, ); + final isAgent = profile?.isAgent == true || fallbackLabel != null; final semanticStates = [ label, @@ -183,7 +184,14 @@ class _HuddleCallAvatar extends HookConsumerWidget { width: speakingRingSize, height: speakingRingSize, decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: isAgent + ? BorderRadius.circular( + speakingRingSize * 0.3, + ) + : null, color: context.colors.primary.withValues( alpha: 0.07, ), @@ -207,7 +215,14 @@ class _HuddleCallAvatar extends HookConsumerWidget { width: avatarRadius * 2, height: avatarRadius * 2, decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: isAgent + ? BorderRadius.circular( + avatarRadius * 0.6, + ) + : null, color: context.colors.primaryContainer, ), alignment: Alignment.center, @@ -230,6 +245,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { size: fallbackIconSize, color: context.colors.onPrimaryContainer, ), + isAgent: isAgent, ), ), ], diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index 215009365f4..48f55bfd879 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -170,6 +170,7 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { fallbackLabel: fallbackLabel, isSelf: isSelf, ); + final isAgent = profile?.isAgent == true || fallbackLabel != null; return Semantics( label: active ? '$label, speaking' : label, @@ -193,7 +194,12 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { : const Duration(milliseconds: 180), padding: EdgeInsets.all(active ? Grid.xxs : Grid.half), decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent ? BoxShape.rectangle : BoxShape.circle, + borderRadius: isAgent + ? BorderRadius.circular( + (_huddleParticipantSpotlightRadius + Grid.half) * 0.6, + ) + : null, color: context.colors.primary.withValues( alpha: active ? 0.18 : 0.08, ), @@ -207,6 +213,7 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { size: 56, color: context.colors.onPrimaryContainer, ), + isAgent: isAgent, ), ), const SizedBox(height: Grid.twelve), @@ -348,6 +355,9 @@ class _HuddleParticipantRoster extends ConsumerWidget { size: 22, color: context.colors.onPrimaryContainer, ), + isAgent: + profile?.isAgent == true || + fallbackLabels[pubkey] != null, ), const SizedBox(width: Grid.twelve), Expanded( diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 8c953b7211b..18bbfb67037 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -34,6 +34,9 @@ class _MessageBubble extends HookConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? shortPubkey(message.pubkey); + final isAgent = + ref.watch(agentMentionPubkeysProvider(currentChannelId)).contains(pk) || + profile?.ownerPubkey != null; final canManageMessage = currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && @@ -148,6 +151,7 @@ class _MessageBubble extends HookConsumerWidget { child: _UserAvatar( profile: profile, pubkey: message.pubkey, + isAgent: isAgent, ), ) else @@ -318,11 +322,13 @@ Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) { class _UserAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; + final bool isAgent; final double size; const _UserAvatar({ required this.profile, required this.pubkey, + required this.isAgent, this.size = messageAvatarSize, }); @@ -347,6 +353,7 @@ class _UserAvatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 5aaca11f4a6..8084d6c01ba 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -382,6 +382,8 @@ class _MessageStyleSystemMessageContent extends StatelessWidget { child: _UserAvatar( profile: userCache[displayPubkey.toLowerCase()], pubkey: displayPubkey, + isAgent: + userCache[displayPubkey.toLowerCase()]?.ownerPubkey != null, size: messageAvatarSize, ), ), diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 138765386f1..d86c01de7a2 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -672,6 +672,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget { radius: 20, backgroundColor: context.colors.primaryContainer, fallback: Text(label.isEmpty ? '?' : label[0].toUpperCase()), + isAgent: member.isBot, ), title: Text.rich( TextSpan( diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 119f5dce7b0..6fad4ecc4a4 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; @@ -155,12 +156,14 @@ class DirectoryUser { final String? displayName; final String? avatarUrl; final String? nip05Handle; + final bool isAgent; const DirectoryUser({ required this.pubkey, this.displayName, this.avatarUrl, this.nip05Handle, + this.isAgent = false, }); String get label { @@ -313,6 +316,7 @@ List directoryUsersFromProfileEvents(List events) { displayName: profile.displayName, avatarUrl: profile.avatarUrl, nip05Handle: profile.nip05, + isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, ), ]..sort((a, b) { final labelComparison = a.label.toLowerCase().compareTo( @@ -381,6 +385,16 @@ final relayDirectoryUsersProvider = displayName: profile.displayName, avatarUrl: profile.avatarUrl, nip05Handle: profile.nip05, + isAgent: + verifiedOaOwnerPubkey( + profileEvents + .firstWhere( + (event) => event.pubkey.toLowerCase() == pubkey, + ) + .tags, + pubkey, + ) != + null, ) else DirectoryUser(pubkey: pubkey), diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 04d6b38d739..b94be1951f4 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -214,6 +214,7 @@ class _DmAvatar extends ConsumerWidget { fontWeight: FontWeight.w600, ), ), + isAgent: profile?.isAgent == true, ), Positioned( right: -1, diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index a17bcef4761..14dab6497fe 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -745,6 +745,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { fontWeight: FontWeight.w600, ), ), + isAgent: user.isAgent, ), title: Text( user.label, @@ -847,6 +848,7 @@ class _SelectedDmRecipientChip extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: user.isAgent, ), const SizedBox(width: Grid.xxs), Flexible( diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index 9d05858ab8c..e6bee4515ba 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -53,6 +53,7 @@ class _MentionSuggestions extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: candidate.isAgent, ), title: Text(name, style: context.textTheme.titleSmall), subtitle: _MentionSuggestionInfo.build( diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 66154a15e1d..568737362f1 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -227,7 +227,11 @@ class _MemberTile extends ConsumerWidget { return ListTile( contentPadding: EdgeInsets.zero, - leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial), + leading: _MemberAvatar( + avatarUrl: profile?.avatarUrl, + initial: initial, + isAgent: member.isBot || profile?.isAgent == true, + ), title: Text(label), subtitle: isWorking ? Row( @@ -439,8 +443,13 @@ class _RoleSelector extends StatelessWidget { class _MemberAvatar extends StatelessWidget { final String? avatarUrl; final String initial; + final bool isAgent; - const _MemberAvatar({required this.avatarUrl, required this.initial}); + const _MemberAvatar({ + required this.avatarUrl, + required this.initial, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -448,6 +457,7 @@ class _MemberAvatar extends StatelessWidget { imageUrl: avatarUrl, radius: 20, fallback: Text(initial), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 775504abc9a..f5d0066bad7 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -462,6 +462,7 @@ class _ReactorTile extends StatelessWidget { initial: profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'), + isAgent: profile?.isAgent == true, ), title: Text( displayName, @@ -488,8 +489,13 @@ class _ReactorTile extends StatelessWidget { class _ReactorAvatar extends StatelessWidget { final String? avatarUrl; final String initial; + final bool isAgent; - const _ReactorAvatar({required this.avatarUrl, required this.initial}); + const _ReactorAvatar({ + required this.avatarUrl, + required this.initial, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -497,6 +503,7 @@ class _ReactorAvatar extends StatelessWidget { imageUrl: avatarUrl, radius: 20, fallback: Text(initial), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/small_avatar.dart b/mobile/lib/features/channels/small_avatar.dart index e110969e73c..be7a90955e6 100644 --- a/mobile/lib/features/channels/small_avatar.dart +++ b/mobile/lib/features/channels/small_avatar.dart @@ -4,7 +4,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/profile/user_profile.dart'; -/// 20px circle avatar used in thread summary rows and other compact lists. +/// 20px avatar used in thread summary rows and other compact lists. class SmallAvatar extends StatelessWidget { final String pubkey; final Map userCache; @@ -23,12 +23,14 @@ class SmallAvatar extends StatelessWidget { final avatarUrl = profile?.avatarUrl; final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final isAgent = profile?.ownerPubkey != null; return Container( width: size, height: size, decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent ? BoxShape.rectangle : BoxShape.circle, + borderRadius: isAgent ? BorderRadius.circular(size * 0.3) : null, border: Border.all(color: context.colors.surface, width: 1.5), ), child: AvatarImage( @@ -43,6 +45,7 @@ class SmallAvatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: isAgent, ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart index 502d6cffa8d..c5c14acce79 100644 --- a/mobile/lib/features/channels/thread_detail_page/avatar.dart +++ b/mobile/lib/features/channels/thread_detail_page/avatar.dart @@ -3,8 +3,13 @@ part of '../thread_detail_page.dart'; class _Avatar extends StatelessWidget { final UserProfile? profile; final String pubkey; + final bool isAgent; - const _Avatar({required this.profile, required this.pubkey}); + const _Avatar({ + required this.profile, + required this.pubkey, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -23,6 +28,7 @@ class _Avatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 89bdd40998f..61cab82fadf 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -40,6 +40,9 @@ class _ThreadMessage extends HookConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? shortPubkey(message.pubkey); + final isAgent = + ref.watch(agentMentionPubkeysProvider(channelId)).contains(pk) || + profile?.ownerPubkey != null; final canManageMessage = currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && @@ -158,6 +161,7 @@ class _ThreadMessage extends HookConsumerWidget { child: _Avatar( profile: profile, pubkey: message.pubkey, + isAgent: isAgent, ), ) else diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index b3ae953ef69..0a2d2de3e89 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -54,6 +54,9 @@ class ForumPostCard extends HookConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? _shortPubkey(post.pubkey); + final isAgent = + ref.watch(agentMentionPubkeysProvider(post.channelId)).contains(pk) || + profile?.ownerPubkey != null; final profileMentionNames = ref.watch( userCacheProvider.select( (cache) => _buildMentionNames(post.mentionPubkeys, cache), @@ -112,7 +115,11 @@ class ForumPostCard extends HookConsumerWidget { GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => showUserProfileSheet(context, post.pubkey), - child: _PostAvatar(profile: profile, pubkey: post.pubkey), + child: _PostAvatar( + profile: profile, + pubkey: post.pubkey, + isAgent: isAgent, + ), ), const SizedBox(width: Grid.xxs), Expanded( @@ -308,8 +315,13 @@ class ForumPostCard extends HookConsumerWidget { class _PostAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; + final bool isAgent; - const _PostAvatar({required this.profile, required this.pubkey}); + const _PostAvatar({ + required this.profile, + required this.pubkey, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -328,6 +340,7 @@ class _PostAvatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 7c1f4c3cea0..f8c1d904bda 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -359,9 +359,11 @@ class _OriginalPost extends ConsumerWidget { GestureDetector( onTap: () => showUserProfileSheet(context, post.pubkey), child: _Avatar( + key: ValueKey('forum-original-avatar-${post.eventId}'), profile: profile, pubkey: post.pubkey, radius: 16, + isAgent: agentMentionPubkeys.contains(pk), ), ), const SizedBox(width: Grid.xxs), @@ -462,9 +464,11 @@ class _ReplyRow extends ConsumerWidget { GestureDetector( onTap: () => showUserProfileSheet(context, reply.pubkey), child: _Avatar( + key: ValueKey('forum-reply-avatar-${reply.eventId}'), profile: profile, pubkey: reply.pubkey, radius: 12, + isAgent: agentMentionPubkeys.contains(pk), ), ), const SizedBox(width: Grid.xxs), @@ -620,11 +624,14 @@ class _Avatar extends StatelessWidget { final UserProfile? profile; final String pubkey; final double radius; + final bool isAgent; const _Avatar({ + super.key, required this.profile, required this.pubkey, required this.radius, + required this.isAgent, }); @override @@ -645,6 +652,7 @@ class _Avatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 9779db4a872..63b0eddeef8 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -151,6 +151,7 @@ class UserProfileSheet extends HookConsumerWidget { child: _ProfileAvatar( avatarUrl: avatarUrl, initial: initial, + isAgent: profile?.isAgent == true, ), ), ), @@ -377,8 +378,13 @@ class _ProfilePresenceChip extends StatelessWidget { class _ProfileAvatar extends HookWidget { final String? avatarUrl; final String initial; + final bool isAgent; - const _ProfileAvatar({required this.avatarUrl, required this.initial}); + const _ProfileAvatar({ + required this.avatarUrl, + required this.initial, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -398,17 +404,26 @@ class _ProfileAvatar extends HookWidget { stoppedAnimationUrl.value == animatedAvatar.animationUrl ? null : animatedAvatar.animationUrl, - child: ClipOval( - child: isPlaying - ? ProgressiveAnimatedAvatar( - key: ValueKey(animatedAvatar.animationUrl), - descriptor: animatedAvatar, - fallback: _AvatarFallback(initial: initial), - ) - : AvatarImageContent( - imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, - fallback: _AvatarFallback(initial: initial), - ), + child: LayoutBuilder( + builder: (context, constraints) { + final avatar = isPlaying + ? ProgressiveAnimatedAvatar( + key: ValueKey(animatedAvatar.animationUrl), + descriptor: animatedAvatar, + fallback: _AvatarFallback(initial: initial), + ) + : AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, + fallback: _AvatarFallback(initial: initial), + ); + if (!isAgent) return ClipOval(child: avatar); + return ClipRRect( + borderRadius: BorderRadius.circular( + constraints.biggest.shortestSide * 0.3, + ), + child: avatar, + ); + }, ), ), ); diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index 7920cf6ad5e..f844244cc94 100644 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ b/mobile/lib/features/pulse/agent_activity_card.dart @@ -47,6 +47,7 @@ class AgentActivityCard extends HookConsumerWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: const Icon(LucideIcons.bot, size: 18), + isAgent: true, ), Positioned( right: 0, diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 8d26d58717a..30294a988f4 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -75,6 +75,7 @@ class NoteCard extends HookConsumerWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: profile?.ownerPubkey != null, ), ), const SizedBox(width: Grid.xs), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 0e489ed486b..507e05fb2cc 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; - import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; @@ -730,6 +729,7 @@ class _PeopleSection extends ConsumerWidget { imageUrl: user.avatarUrl, radius: 20, fallback: Text(user.label.substring(0, 1).toUpperCase()), + isAgent: user.isAgent, ), title: Text( user.label, diff --git a/mobile/lib/shared/profile/user_profile.dart b/mobile/lib/shared/profile/user_profile.dart index de58d955e3e..abc915437bd 100644 --- a/mobile/lib/shared/profile/user_profile.dart +++ b/mobile/lib/shared/profile/user_profile.dart @@ -12,6 +12,8 @@ class UserProfile { /// means this identity is an agent (mirrors desktop's `ownerPubkey`). final String? ownerPubkey; + bool get isAgent => ownerPubkey != null; + const UserProfile({ required this.pubkey, this.displayName, diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index a8209a9557b..6a787cce129 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -311,7 +311,13 @@ class RelaySessionNotifier extends Notifier { Future publish( NostrEvent event, { Duration timeout = const Duration(seconds: 8), - }) { + }) async { + final generation = _connectionGeneration; + if (_rateLimitGate.isActive) await _rateLimitGate.wait(); + if (!_isActiveConnection(generation) || !_socketConnected) { + throw StateError('Relay session is not connected'); + } + final completer = Completer(); final timer = Timer(timeout, () { @@ -824,6 +830,13 @@ class RelaySessionNotifier extends Notifier { ); } } else { + // Back-pressure now arrives here rather than as a NOTICE: the relay + // rejects an over-quota EVENT on the OK channel so this pending publish + // can be settled at all. Without arming the gate the send would fail + // without ever backing off. + if (message.startsWith('rate-limited:')) { + _rateLimitGate.activate(parseRateLimitRetrySeconds(message)); + } if (!pending.completer.isCompleted) { pending.completer.completeError( Exception(message.isNotEmpty ? message : 'Event rejected'), diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index b869bf8fbc4..33f4d65470d 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -13,7 +13,7 @@ import '../emoji/native_emoji_glyph.dart'; import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; -/// A circular avatar that supports both remote URLs and inline image data. +/// An avatar that supports both remote URLs and inline image data. /// /// Flutter's [NetworkImage] only loads network URLs, while desktop browsers also /// accept `data:image/*` sources directly. Agent emoji avatars are inline SVGs, @@ -23,6 +23,7 @@ class AvatarImage extends StatelessWidget { final double radius; final Color? backgroundColor; final Widget fallback; + final bool isAgent; const AvatarImage({ super.key, @@ -30,28 +31,33 @@ class AvatarImage extends StatelessWidget { required this.radius, required this.fallback, this.backgroundColor, + this.isAgent = false, }); @override Widget build(BuildContext context) { final animatedAvatar = parseAnimatedAvatarUrl(imageUrl); - return CircleAvatar( - radius: radius, - // Animated avatar posters carry their own backdrop disc; preserve their - // transparent surroundings on static/list surfaces, matching desktop. - backgroundColor: animatedAvatar == null - ? backgroundColor - : Colors.transparent, - child: ClipOval( - child: SizedBox.square( - dimension: radius * 2, - child: AvatarImageContent( - imageUrl: animatedAvatar?.posterUrl ?? imageUrl, - fallback: fallback, - ), - ), + final color = animatedAvatar == null ? backgroundColor : Colors.transparent; + final content = SizedBox.square( + dimension: radius * 2, + child: AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? imageUrl, + fallback: fallback, ), ); + if (!isAgent) { + return CircleAvatar( + radius: radius, + backgroundColor: color, + child: ClipOval(child: content), + ); + } + + final borderRadius = BorderRadius.circular(radius * 0.6); + return DecoratedBox( + decoration: BoxDecoration(color: color, borderRadius: borderRadius), + child: ClipRRect(borderRadius: borderRadius, child: content), + ); } } diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index f770e1ccb11..767e15278f9 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; @@ -120,6 +121,7 @@ void main() { ValueListenable? tabReselection, List drafts = const [], List reminders = const [], + Set knownAgentPubkeys = const {}, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -135,6 +137,7 @@ void main() { userCacheProvider.overrideWith( () => _FakeUserCacheNotifier(users ?? testUsers), ), + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), readStateProvider.overrideWith( () => _FakeReadStateNotifier(readContexts), ), @@ -533,6 +536,26 @@ void main() { ); }); + testWidgets('directory-known Activity authors use agent avatars', ( + tester, + ) async { + await tester.pumpWidget( + await buildTestable(knownAgentPubkeys: const {'agent_pk'}), + ); + await tester.pumpAndSettle(); + + final agentRow = find.byKey(const ValueKey('inbox-row-ag1')); + final agentAvatar = tester.widget( + find.descendant(of: agentRow, matching: find.byType(AvatarImage)), + ); + final humanRow = find.byKey(const ValueKey('inbox-row-m1')); + final humanAvatar = tester.widget( + find.descendant(of: humanRow, matching: find.byType(AvatarImage)), + ); + expect(agentAvatar.isAgent, isTrue); + expect(humanAvatar.isAgent, isFalse); + }); + testWidgets('multiple top-level messages in one DM render one row', ( tester, ) async { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 85ab76a14d7..21fa3d124ec 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -477,6 +477,47 @@ void main() { }); group('ChannelDetailPage', () { + testWidgets( + 'bot-role author avatars stay squircles in channel and thread', + (tester) async { + final message = _textMsg( + id: 'bot-message', + pubkey: 'bot', + content: 'Bot message', + ); + await tester.pumpWidget( + _buildTestable( + messages: [message], + users: const { + 'bot': UserProfile(pubkey: 'bot', displayName: 'Bot'), + }, + loadChannelBotPubkeys: () async => const {'bot'}, + threadReplies: const {'bot-message': []}, + ), + ); + await tester.pumpAndSettle(); + + AvatarImage avatarIn(Finder row) => tester.widget( + find.descendant(of: row, matching: find.byType(AvatarImage)), + ); + expect( + avatarIn( + find.byKey(const ValueKey('message-row-bot-message')), + ).isAgent, + isTrue, + ); + + await tester.tap(find.byKey(const ValueKey('message-row-bot-message'))); + await tester.pumpAndSettle(); + expect( + avatarIn( + find.byKey(const ValueKey('thread-message-row-bot-message')), + ).isAgent, + isTrue, + ); + }, + ); + testWidgets('uses the shared 32px masked presence avatar in DM headers', ( tester, ) async { @@ -510,6 +551,17 @@ void main() { expect(tester.getSize(avatarFinder), const Size.square(32)); expect(avatar.geometry, AvatarBadgeMaskGeometry.presenceDot); expect(avatar.badge, isNotNull); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(ClipRRect), + ), + ) + .borderRadius, + BorderRadius.circular(16), + ); expect( find.descendant(of: avatarFinder, matching: find.byType(ClipPath)), findsOneWidget, @@ -528,6 +580,61 @@ void main() { expect(find.byTooltip('Start Huddle'), findsOneWidget); }); + testWidgets('uses a fallback squircle for bot-role DM participants', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Bot DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message with a bot', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Bot'], + participantPubkeys: const ['self', 'bot'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadChannelBotPubkeys: () async => const {'bot'}, + ), + ); + await tester.pumpAndSettle(); + + final avatarFinder = find.byKey(const ValueKey('dm-header-avatar')); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(ClipRRect), + ), + ) + .borderRadius, + BorderRadius.circular(9.6), + ); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(AvatarImageContent), + ), + ) + .imageUrl, + isNull, + ); + expect( + find.descendant(of: avatarFinder, matching: find.byType(ClipPath)), + findsOneWidget, + ); + }); + testWidgets('hides the Huddle action in a one-to-one agent DM', ( tester, ) async { @@ -560,6 +667,18 @@ void main() { ); await tester.pumpAndSettle(); + final avatarFinder = find.byKey(const ValueKey('dm-header-avatar')); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(ClipRRect), + ), + ) + .borderRadius, + BorderRadius.circular(9.6), + ); expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); expect(find.byTooltip('Start Huddle'), findsNothing); }); diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 939a25e1397..338dbf36ac3 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -8,10 +8,12 @@ import 'package:buzz/features/forum/forum_posts_view.dart'; import 'package:buzz/features/forum/forum_provider.dart'; import 'package:buzz/features/forum/forum_thread_page.dart'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _channelId = 'forum-channel'; @@ -62,10 +64,12 @@ Widget _buildPostCard({ VoidCallback? onTap, void Function(String)? onDelete, TextScaler textScaler = TextScaler.noScaling, + Set knownAgentPubkeys = const {}, }) { return ProviderScope( overrides: [ userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), ], child: MaterialApp( theme: AppTheme.light(), @@ -121,11 +125,17 @@ Widget _buildThreadPage({ bool isMember = true, bool isArchived = false, Map users = const {}, + Set knownAgentPubkeys = const {}, + Set channelBotPubkeys = const {}, TextScaler textScaler = TextScaler.noScaling, }) { return ProviderScope( overrides: [ userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), + channelBotPubkeysProvider( + _channelId, + ).overrideWith((ref) async => channelBotPubkeys), profileProvider.overrideWith(() => _FakeProfileNotifier()), forumThreadProvider(( channelId: _channelId, @@ -207,6 +217,38 @@ void main() { expect(find.text('abcdef12\u2026'), findsOneWidget); }); + testWidgets('uses directory classification for uncached author avatar', ( + tester, + ) async { + await tester.pumpWidget( + _buildPostCard( + post: _makePost(pubkey: 'directory-agent'), + knownAgentPubkeys: const {'directory-agent'}, + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isTrue, + ); + }); + + testWidgets('keeps human author avatar circular', (tester) async { + await tester.pumpWidget( + _buildPostCard( + post: _makePost(), + users: const {'alice': _aliceProfile}, + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isFalse, + ); + }); + testWidgets( 'constrains an older timestamp at large accessible text sizes', (tester) async { @@ -534,6 +576,81 @@ void main() { }); group('ForumThreadPage', () { + AvatarImage avatarIn(WidgetTester tester, Key key) => + tester.widget( + find.descendant( + of: find.byKey(key), + matching: find.byType(AvatarImage), + ), + ); + + testWidgets( + 'uses directory classification for an uncached original author', + (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(pubkey: 'directory-agent'), + replies: const [], + totalReplies: 0, + ), + knownAgentPubkeys: const {'directory-agent'}, + ), + ); + await tester.pumpAndSettle(); + + expect( + avatarIn( + tester, + const ValueKey('forum-original-avatar-post1'), + ).isAgent, + isTrue, + ); + }, + ); + + testWidgets('uses bot-role classification for an uncached reply author', ( + tester, + ) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(), + replies: const [ + ThreadReply( + eventId: 'bot-reply', + pubkey: 'channel-bot', + content: 'Automated reply', + kind: 45003, + createdAt: 2000, + channelId: _channelId, + tags: [ + ['h', _channelId], + ], + depth: 1, + ), + ], + totalReplies: 1, + ), + users: const {'alice': _aliceProfile}, + channelBotPubkeys: const {'channel-bot'}, + ), + ); + await tester.pumpAndSettle(); + + expect( + avatarIn( + tester, + const ValueKey('forum-reply-avatar-bot-reply'), + ).isAgent, + isTrue, + ); + expect( + avatarIn(tester, const ValueKey('forum-original-avatar-post1')).isAgent, + isFalse, + ); + }); + testWidgets('shows original post and replies header', (tester) async { await tester.pumpWidget( _buildThreadPage( diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index d896250536d..aca113451fb 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -1382,6 +1382,150 @@ void main() { expect(closedMessages, ['restricted: no longer valid']); unsubscribe(); }); + + // The relay rejects an over-quota EVENT on the OK channel rather than with a + // bare NOTICE, because a NOTICE carries no event id and `_pendingEvents` is + // keyed by one — nothing settled, so the publish could only time out. The + // gate arming that used to depend on the NOTICE has to happen here too. + test( + 'a rate-limited OK rejection fails the publish and arms the gate', + () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final session = RelaySessionNotifier(rateLimitGate: gate); + session.debugAttachSocketForTest(_RecordingRelaySocket()); + + final publish = session.publish(_event()); + session.debugHandleMessage([ + 'OK', + 'event-1', + false, + 'rate-limited: quota exceeded; retry in 4s', + ]); + + await expectLater(publish, throwsA(isA())); + expect( + gate.isActive, + isTrue, + reason: + 'back-pressure now arrives on the OK channel — without arming here ' + 'the client fails the send and retries into the same quota', + ); + expect(gateTimers.single.duration, const Duration(seconds: 4)); + }, + ); + + test( + 'publish waits out the rate-limit gate before timeout registration and send', + () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier(rateLimitGate: gate); + session.debugAttachSocketForTest(socket); + + final firstPublish = session.publish(_event(id: 'event-a')); + session.debugHandleMessage([ + 'OK', + 'event-a', + false, + 'rate-limited: quota exceeded; retry in 4s', + ]); + await expectLater(firstPublish, throwsA(isA())); + + var secondSettled = false; + final secondPublish = session.publish( + _event(id: 'event-b'), + timeout: Duration.zero, + ); + unawaited(secondPublish.whenComplete(() => secondSettled = true)); + await Future.delayed(Duration.zero); + + expect( + socket.messages.where((message) => message.first == 'EVENT'), + hasLength(1), + reason: 'the next EVENT must remain unsent while the gate is active', + ); + expect( + secondSettled, + isFalse, + reason: + 'the publish timeout must not start until after the gate expires', + ); + + gateTimers.single.fire(); + await Future.microtask(() {}); + + final events = socket.messages + .where((message) => message.first == 'EVENT') + .toList(); + expect(events, hasLength(2)); + expect((events.last[1] as Map)['id'], 'event-b'); + session.debugHandleMessage(['OK', 'event-b', true, '']); + expect((await secondPublish).id, 'event-b'); + }, + ); + + test( + 'a gated publish is cancelled if the connection changes while waiting', + () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier(rateLimitGate: gate); + session.debugAttachSocketForTest(socket); + gate.activate(4); + + final publish = session.publish(_event(id: 'event-b')); + session.debugSupersedeConnection(); + gateTimers.single.fire(); + + await expectLater(publish, throwsA(isA())); + expect(socket.messages, isEmpty); + }, + ); + + test('an ordinary OK rejection does not arm the gate', () async { + final gate = RelayRateLimitGate(now: () => DateTime(2026)); + final session = RelaySessionNotifier(rateLimitGate: gate); + session.debugAttachSocketForTest(_RecordingRelaySocket()); + + final publish = session.publish(_event()); + session.debugHandleMessage([ + 'OK', + 'event-1', + false, + 'invalid: bad signature', + ]); + + await expectLater(publish, throwsA(isA())); + expect( + gate.isActive, + isFalse, + reason: 'only `rate-limited:` rejections signal back-pressure', + ); + }); } class _ControlledHttpClient extends http.BaseClient { @@ -1524,9 +1668,9 @@ class _FakeRelayConfigNotifier extends RelayConfigNotifier { RelayConfig build() => RelayConfig(baseUrl: _baseUrl, nsec: _nsec); } -NostrEvent _event({int createdAt = 20}) { +NostrEvent _event({int createdAt = 20, String id = 'event-1'}) { return NostrEvent( - id: 'event-1', + id: id, pubkey: 'alice', createdAt: createdAt, kind: EventKind.streamMessageV2, diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 821de63a480..34cf86c1443 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -13,13 +13,18 @@ void main() { '' '🦝'; - Widget subject(String? imageUrl, {Color? backgroundColor}) => ProviderScope( + Widget subject( + String? imageUrl, { + Color? backgroundColor, + bool isAgent = false, + }) => ProviderScope( child: MaterialApp( home: AvatarImage( imageUrl: imageUrl, radius: 16, backgroundColor: backgroundColor, fallback: const Text('R'), + isAgent: isAgent, ), ), ); @@ -33,6 +38,14 @@ void main() { expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse); }); + testWidgets('clips agents to a 30 percent squircle', (tester) async { + await tester.pumpWidget(subject(null, isAgent: true)); + + expect(find.byType(CircleAvatar), findsNothing); + final clip = tester.widget(find.byType(ClipRRect)); + expect(clip.borderRadius, BorderRadius.circular(9.6)); + }); + testWidgets('renders raccoon percent-encoded SVG data avatar', ( tester, ) async { diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 9cb93671128..631c8387da2 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -130,6 +130,11 @@ run_unit_tests() { # `just test-unit` — the two lists must stay in step. run_test_step "buzz-agent unit tests" \ cargo test -p buzz-agent --lib -- --nocapture + + # ACP author-gate and queue tests are pure unit tests. Keep this fallback in + # step with `just test-unit`; ignored lifecycle tests run elsewhere. + run_test_step "buzz-acp unit tests" \ + cargo test -p buzz-acp --lib -- --nocapture } # ---- DB / integration tests (infra required) --------------------------------