diff --git a/.github/workflows/benchmark-harbor.yml b/.github/workflows/benchmark-harbor.yml index 6024f00575..a750ab6dc3 100644 --- a/.github/workflows/benchmark-harbor.yml +++ b/.github/workflows/benchmark-harbor.yml @@ -5,10 +5,12 @@ on: branches: [main] paths: - "benchmarks/harbor-buzz-orchestra/**" + - "benchmarks/buzz-dataset/**" - ".github/workflows/benchmark-harbor.yml" pull_request: paths: - "benchmarks/harbor-buzz-orchestra/**" + - "benchmarks/buzz-dataset/**" - ".github/workflows/benchmark-harbor.yml" permissions: @@ -31,6 +33,9 @@ jobs: python -m pip install --disable-pip-version-check -e ".[dev]" pytest -q ruff check . + # The task verifiers live in the sibling benchmarks/buzz-dataset, so + # they need the harness config passed explicitly to stay linted. + ruff check --config pyproject.toml ../buzz-dataset - name: Test provisioner working-directory: benchmarks/harbor-buzz-orchestra/testbed run: | diff --git a/AGENTS.md b/AGENTS.md index 3a79c4b38d..b1f11bd3db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,9 +125,22 @@ clippy (workspace + Tauri), desktop TypeScript typechecking (`tsc --noEmit`), and fast unit tests in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run `just ci` for the full local gate. Run `just -hooks` to re-install hooks after env changes. Before agents run Git or hooks, -activate the repo's Hermit environment (`. ./bin/activate-hermit`); do not -rewrite hook commands to compensate for an unconfigured shell `PATH`. +hooks` to re-install hooks after env changes. Each globbed pre-push lane is +scoped to the branch's merge-base diff against `origin/main` (`git diff +origin/main...HEAD`), matching CI's paths-filter — so a lane only fires when this +branch actually changed a file it covers, never because `origin/main` moved. +These lanes validate the checked-out HEAD; pushing a non-HEAD ref (explicit +refspec, `--all`) gets a non-fatal `push-head-scope` warning and relies on CI for +its path-scoped checks. +Before agents run Git or hooks, activate the repo's Hermit environment +(`. ./bin/activate-hermit`) so `./bin` leads `PATH` and the pinned toolchain +(flutter, dart, lefthook) wins over any Homebrew version; do not +rewrite hook commands to compensate for an unconfigured shell `PATH`. The +pre-push hook self-pins regardless: `bin/.lefthookrc` (sourced by the generated +`.git/hooks/*`) prepends the Hermit `bin/` to `PATH` and pins `LEFTHOOK_BIN`, so +lane subprocesses resolve the pinned flutter/dart/lefthook even when an +unactivated shell has Homebrew first. Activating Hermit remains recommended for +non-hook commands. **Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push. @@ -202,15 +215,16 @@ or invoke with the full path. ### Deep Links `buzz://message?channel=&id=` links reference a specific message -thread. To read the linked thread: +thread. Pass the link directly to the CLI: ```bash -buzz --format compact messages thread --channel --event +buzz --format compact messages thread --link '' ``` -Extract `channel` and `id` from the URL query parameters. The optional -`thread` parameter (root event ID) can be ignored — `messages thread` resolves -the full thread from the event ID alone. +The selected message ID is authoritative: `messages thread` verifies its +channel and derives its containing root. An optional `thread` parameter is +accepted only when it matches that derived root. The explicit +`--channel --event ` form remains available. All reads return sig-stripped JSON arrays; all writes return `{event_id, accepted, message}`; creates add the entity ID. Exit codes: @@ -476,11 +490,18 @@ are frozen.** So for any readable text, reach for rem-based Tailwind tokens, never arbitrary px: -- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author - text === `text-base` (16px) — chat is the app's base type size**, and the - surrounding timeline elements (timestamps, system rows, code, reactions) are - deliberate steps on that same stock ramp. -- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text +- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general + interface text. All of these derive from the virtual typography rem and + therefore follow the user's font-size preference and Cmd +/- zoom. +- ✅ Conversation text uses the named `text-message` token. Its + **Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard + zoom. Author names use the same conversation-size step; timestamps, system + rows, code, and reactions are deliberate neighboring steps on the shared + virtual-rem ramp. Keep those relationships tokenized rather than restoring a + fixed 16px chat baseline or hardcoding preference-specific values in + components. +- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs` + (0.5rem / 8px at a 16px virtual rem) meta-text tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs. These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted diff --git a/Justfile b/Justfile index 213869aff7..4ac63da961 100644 --- a/Justfile +++ b/Justfile @@ -996,6 +996,31 @@ benchmark *ARGS: uv run --project benchmarks/harbor-buzz-orchestra/testbed \ benchmarks/harbor-buzz-orchestra/scripts/benchmark.py {{ARGS}} +# Run the benchmark adapter + testbed gate exactly as CI does (pytest + ruff, pinned ruff from pyproject) +benchmark-check: + #!/usr/bin/env bash + set -euo pipefail + cd "{{justfile_directory()}}/benchmarks/harbor-buzz-orchestra" + # CI installs the dev extra with pip, so pyproject — not uv.lock — decides + # which ruff lints. Read the pin from there so this recipe cannot drift + # from the workflow (a floating specifier once meant CI failed on RUF100 + # while the locked local ruff passed). + ruff_pin="$(grep -oE 'ruff==[0-9.]+' pyproject.toml | head -1 | cut -d= -f3)" + for project in . testbed; do + ( + cd "$project" + echo "── harbor-buzz-orchestra/$project (ruff $ruff_pin)" + uv run --frozen pytest -q + uvx "ruff@$ruff_pin" check . + uvx "ruff@$ruff_pin" format --check . + ) + done + # The task verifiers live in the sibling benchmarks/buzz-dataset, so they + # need the harness config passed explicitly to stay linted. + echo "── buzz-dataset (ruff $ruff_pin)" + uvx "ruff@$ruff_pin" check --config pyproject.toml ../buzz-dataset + uvx "ruff@$ruff_pin" format --check --config pyproject.toml ../buzz-dataset + # Stop the benchmark Docker stack (state and channels are kept) benchmark-down: docker compose --project-name buzz-benchmark down diff --git a/benchmarks/buzz-dataset/README.md b/benchmarks/buzz-dataset/README.md new file mode 100644 index 0000000000..2909d0cd87 --- /dev/null +++ b/benchmarks/buzz-dataset/README.md @@ -0,0 +1,47 @@ +# buzz-dataset + +Harbor tasks that score **Buzz product behavior**, not just task correctness. +Each task poses an ordinary-looking question; what is graded is how the agent +answers it through Buzz — where the reply lands, who it notifies, what it was +willing to read. + +| Task | Behavior under test | +| --- | --- | +| [`reply-to-thread`](reply-to-thread) | Answers in the user's thread instead of as a new top-level message | +| [`user-mention`](user-mention) | Hands the turn back with an event-level `p`-tag mention of the requesting human | +| [`read-named-path-outside-workspace`](read-named-path-outside-workspace) | Reads a path the user named explicitly instead of refusing it as out of bounds | +| [`create-channel-invite-users`](create-channel-invite-users) | Creates a channel with the exact shape, TTL, and membership asked for | + +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 +base prompt. Read a task's own `README.md` before editing its instruction or +verifier. + +## Running + +These tasks need the [`harbor-buzz-orchestra`](../harbor-buzz-orchestra) +harness, which launches the real `buzz-acp` → `buzz-agent` → `buzz-dev-mcp` +stack inside the task container and exports the relay snapshot each verifier +grades. Plain `harbor run` against this directory will not work, and neither +will `harbor run -a oracle` (no `solution/solve.sh` is shipped — the Oracle +agent replaces the Buzz agent, so no relay trial is provisioned). + +From the repo root: + +```bash +just benchmark \ + --path benchmarks/buzz-dataset/reply-to-thread \ + --attempts 1 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-luna.yaml \ + --endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live.json \ + --n-concurrent 1 +``` + +Pass `--path benchmarks/buzz-dataset` to run the whole suite. The default +condition is one solo agent on `gpt-5.6-luna` at `thinking_effort: medium`, +which needs `OPENAI_COMPAT_API_KEY`; see +[the harness README](../harbor-buzz-orchestra/README.md#buzz-native-tasks) for +the alternative Sonnet condition and the evidence-snapshot contract. + +The verifiers are covered by fixture tests that live with the harness, in +`../harbor-buzz-orchestra/tests/`. diff --git a/benchmarks/buzz-dataset/create-channel-invite-users/README.md b/benchmarks/buzz-dataset/create-channel-invite-users/README.md new file mode 100644 index 0000000000..f473b9533f --- /dev/null +++ b/benchmarks/buzz-dataset/create-channel-invite-users/README.md @@ -0,0 +1,77 @@ +# create-channel-invite-users + +## What the agent does + +Creates a temporary private stream channel named `fix-pr-1234` with a one-hour +lifetime and invites an exact subset of a seeded directory: three named users as +members and two named bots with the `bot` role +([instruction.md](instruction.md)). + +Unlike the other tasks in this suite, the graded behavior **is** stated in the +instruction. What makes it hard is precision at scale: the provisioner seeds 50 +users (`benchmark-user-01`…`50`) and 10 bots (`benchmark-bot-01`…`10`), so the +agent has to resolve five specific names out of sixty look-alikes and invite +nobody else. + +## Environment + +`python:3.12-slim-bookworm`, no extra packages: the agent never runs in this +container's shell. `BuzzOrchestraAgent` launches the real `buzz-acp` / +`buzz-agent` stack against a dedicated relay, and the agent does all its work +through `buzz channels create` / `channels invite`. Agent timeout 300s. + +Directory identities are derived deterministically from the owner key +(`BuzzTrialProvisioner._stable_credential`) without persisting any secret, and +`_seed_directory` skips profiles already published — so reruns are idempotent +and pubkeys are stable across trials. + +## Verifier + +Reads the post-agent `/logs/artifacts/buzz-evidence.json` snapshot. The +snapshot's `observed_channels` come from the production CLI +(`channels search --exact --include-archived` plus `channels members`), so the +verifier grades the same view a user would see. Every dimension is +programmatic; `reward` is the conjunction of all of them. + +| Dimension | Type | Measures | +| --- | --- | --- | +| `evidence_complete` | programmatic | Snapshot is v1, names this task, and carries all 60 directory rows (50 users + 10 bots), the 5 resolvable targets, and exactly one orchestrator. Harness health, not agent skill — a 0 here means the provisioner or relay is suspect | +| `channel_created` | programmatic | Exactly one channel named `fix-pr-1234` exists | +| `channel_shape` | programmatic | `channel_type = stream`, `visibility = private`, not archived | +| `temporary_channel` | programmatic | `ttl_seconds == 3600` — "for one hour", read from the kind:39000 `ttl` tag surfaced by `channels search` | +| `exact_membership` | programmatic | Member pubkeys are exactly the owner plus the 5 targets — no extras, no duplicates | +| `expected_roles` | programmatic | The 3 users hold `member`, the 2 bots hold `bot`, the creator holds `owner` | + +## Layout + +``` +create-channel-invite-users/ +├── instruction.md # Prompt posted to the agent as the trial user +├── task.toml # Metadata, timeouts, 1 CPU / 1 GiB environment +├── environment/Dockerfile # Bare python image; the relay stack is uploaded +└── tests/ + ├── test.sh # Runs verify.py against the evidence snapshot + └── verify.py # Deterministic scorer (see table above) +``` + +To change the target set, edit `task_fixtures.TARGET_USERS` / `TARGET_BOTS`, +`instruction.md`, and the matching constants at the top of `tests/verify.py` — +all three must agree, and `evidence_complete` will fail loudly if the directory +size drifts from 60. + +## Running + +```bash +just benchmark \ + --path benchmarks/buzz-dataset/create-channel-invite-users \ + --attempts 1 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-luna.yaml \ + --endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live.json \ + --n-concurrent 1 +``` + +`harbor run -a oracle` does **not** work here, and no `solution/solve.sh` is +shipped: the Oracle agent replaces `BuzzOrchestraAgent`, so no relay trial is +provisioned and no evidence snapshot is exported. The verifier is covered +instead by fixture tests in +`../harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py`. diff --git a/benchmarks/buzz-dataset/create-channel-invite-users/environment/Dockerfile b/benchmarks/buzz-dataset/create-channel-invite-users/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/create-channel-invite-users/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/create-channel-invite-users/instruction.md b/benchmarks/buzz-dataset/create-channel-invite-users/instruction.md new file mode 100644 index 0000000000..a6f4f79452 --- /dev/null +++ b/benchmarks/buzz-dataset/create-channel-invite-users/instruction.md @@ -0,0 +1,15 @@ +Create a temporary private stream channel named `fix-pr-1234` for one hour. + +Invite these users as members: + +- `benchmark-user-07` +- `benchmark-user-19` +- `benchmark-user-42` + +Invite these bots with the bot role: + +- `benchmark-bot-03` +- `benchmark-bot-08` + +Do not invite any other users or bots. When finished, reply briefly with what +you created. diff --git a/benchmarks/buzz-dataset/create-channel-invite-users/task.toml b/benchmarks/buzz-dataset/create-channel-invite-users/task.toml new file mode 100644 index 0000000000..b4ec4821f9 --- /dev/null +++ b/benchmarks/buzz-dataset/create-channel-invite-users/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/create-channel-invite-users" +description = "Create a temporary PR channel with an exact subset of users and bots." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "channels", "membership", "cli"] + +[metadata] +difficulty = "medium" +category = "collaboration" +tags = ["channels", "membership", "cli"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/buzz-dataset/create-channel-invite-users/tests/test.sh b/benchmarks/buzz-dataset/create-channel-invite-users/tests/test.sh new file mode 100755 index 0000000000..be6819ba68 --- /dev/null +++ b/benchmarks/buzz-dataset/create-channel-invite-users/tests/test.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +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/create-channel-invite-users/tests/verify.py b/benchmarks/buzz-dataset/create-channel-invite-users/tests/verify.py new file mode 100755 index 0000000000..2d59c2a58f --- /dev/null +++ b/benchmarks/buzz-dataset/create-channel-invite-users/tests/verify.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for exact Buzz channel creation and membership.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +CHANNEL_NAME = "fix-pr-1234" +TARGET_USERS = {"benchmark-user-07", "benchmark-user-19", "benchmark-user-42"} +TARGET_BOTS = {"benchmark-bot-03", "benchmark-bot-08"} + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "channel_created": 0.0, + "channel_shape": 0.0, + "temporary_channel": 0.0, + "exact_membership": 0.0, + "expected_roles": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + directory_rows = [ + row for row in evidence.get("directory", []) if isinstance(row, dict) + ] + directory = { + row.get("name"): row + for row in directory_rows + if isinstance(row.get("name"), str) + } + channels = [ + channel + for channel in evidence.get("observed_channels", []) + if isinstance(channel, dict) and channel.get("name") == CHANNEL_NAME + ] + channel = channels[0] if len(channels) == 1 else None + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + orchestrators = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + owner_pubkey = orchestrators[0].get("pubkey") if len(orchestrators) == 1 else None + + expected_names = TARGET_USERS | TARGET_BOTS + expected_targets = { + directory[name]["pubkey"]: "bot" if name in TARGET_BOTS else "member" + for name in expected_names + if name in directory and isinstance(directory[name].get("pubkey"), str) + } + expected_members = ( + {owner_pubkey: "owner", **expected_targets} + if isinstance(owner_pubkey, str) + else expected_targets + ) + member_rows = ( + [row for row in channel.get("members", []) if isinstance(row, dict)] + if channel is not None + else [] + ) + actual_members = { + row.get("pubkey"): row.get("role") + for row in member_rows + if isinstance(row.get("pubkey"), str) + } + + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "create-channel-invite-users" + and len(directory_rows) == 60 + and len(directory) == 60 + and sum(row.get("role") == "user" for row in directory_rows) == 50 + and sum(row.get("role") == "bot" for row in directory_rows) == 10 + and len(expected_targets) == 5 + and len(orchestrators) == 1 + ) + channel_created = float(channel is not None) + channel_shape = float( + channel is not None + and channel.get("channel_type") == "stream" + and channel.get("visibility") == "private" + and channel.get("archived") is False + ) + temporary_channel = float( + channel is not None and channel.get("ttl_seconds") == 3600 + ) + exact_membership = float( + len(member_rows) == len(actual_members) + and set(actual_members) == set(expected_members) + ) + expected_roles = float(actual_members == expected_members) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + channel_created, + channel_shape, + temporary_channel, + exact_membership, + expected_roles, + ) + ) + ) + metrics = { + "reward": reward, + "channel_created": channel_created, + "channel_shape": channel_shape, + "temporary_channel": temporary_channel, + "exact_membership": exact_membership, + "expected_roles": expected_roles, + "evidence_complete": evidence_complete, + } + details = { + "matching_channel_count": len(channels), + "channel_id": channel.get("channel_id") if channel is not None else None, + "expected_members": expected_members, + "actual_members": actual_members, + } + return metrics, details + + +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: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + metrics, details = score_evidence(evidence) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"error": str(error)} + + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/read-named-path-outside-workspace/README.md b/benchmarks/buzz-dataset/read-named-path-outside-workspace/README.md new file mode 100644 index 0000000000..fa9f363040 --- /dev/null +++ b/benchmarks/buzz-dataset/read-named-path-outside-workspace/README.md @@ -0,0 +1,76 @@ +# read-named-path-outside-workspace + +## What the agent does + +Reads one file the user names explicitly by path — +`~/.claude/skills/context-health-check/SKILL.md` — and reports its `CHECK_ID` +and `ACTION` values ([instruction.md](instruction.md)). + +This is a **regression case**, not a capability test. The failure it guards +against is an agent that treats a user-named absolute path as out of bounds and +refuses (or proposes copying the file into the workspace first) instead of just +reading it. See block/buzz#6261. + +## Environment + +`python:3.12-slim-bookworm` with `HOME=/home/buzz`, so `~` in the instruction +resolves to the seeded skill directory. The Dockerfile generates the +`CHECK_ID` marker with `secrets.token_hex` **at image build time**, so the +expected value cannot be memorized across runs; the verifier reads the +answer back out of the same file rather than hardcoding it. Agent timeout 300s. + +## Verifier + +Reads the post-agent `/logs/artifacts/buzz-evidence.json` snapshot plus the +seeded `SKILL.md` (via `--skill-file`) for the expected values. + +| Dimension | Type | Measures | +| --- | --- | --- | +| `evidence_complete` | programmatic | Snapshot is v1, untruncated, names this task, and resolves the task event, channel, one orchestrator, and a candidate reply. Harness health, not agent skill | +| `expected_author` | programmatic | The scored message was published by the orchestrator | +| `same_channel` | programmatic | Reply carries the trial channel's `h` tag | +| `named_path_read` | programmatic | Reply contains the build-time `CHECK_ID` marker — proof the file was actually read | +| `action_reported` | programmatic | Reply contains the `ACTION` line, matched case-insensitively with whitespace collapsed and trailing punctuation stripped | + +`reward` is the conjunction of every dimension above. + +**Refusal wording is deliberately not scored.** The question this task asks is +whether the file was read, and `named_path_read` answers it conclusively: the +`CHECK_ID` marker is generated at image build time, so an agent cannot emit it +without having read the file. A genuine refusal therefore already scores 0 on +the substance. An earlier revision also matched refusal phrasing with a regex, +which meant a hedged-but-correct answer could score 0 on wording alone; that +check is gone rather than kept as an unscored metric. + +`instruction.md` also says "Do not search other directories". That constraint +is intentionally unscored — the snapshot holds relay messages, not the agent's +tool calls. + +## Layout + +``` +read-named-path-outside-workspace/ +├── instruction.md # Prompt posted to the agent as the trial user +├── task.toml # Metadata, timeouts, 1 CPU / 1 GiB environment +├── environment/Dockerfile # Seeds ~/.claude/skills/... with a random CHECK_ID +└── tests/ + ├── test.sh # Runs verify.py against the snapshot + SKILL.md + └── verify.py # Deterministic scorer (see table above) +``` + +## Running + +```bash +just benchmark \ + --path benchmarks/buzz-dataset/read-named-path-outside-workspace \ + --attempts 1 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-luna.yaml \ + --endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live.json \ + --n-concurrent 1 +``` + +`harbor run -a oracle` does **not** work here, and no `solution/solve.sh` is +shipped: the Oracle agent replaces `BuzzOrchestraAgent`, so no relay trial is +provisioned and no evidence snapshot is exported. The verifier is covered +instead by fixture tests in +`../harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py`. diff --git a/benchmarks/buzz-dataset/read-named-path-outside-workspace/environment/Dockerfile b/benchmarks/buzz-dataset/read-named-path-outside-workspace/environment/Dockerfile new file mode 100644 index 0000000000..3dd566334a --- /dev/null +++ b/benchmarks/buzz-dataset/read-named-path-outside-workspace/environment/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim-bookworm + +ENV HOME=/home/buzz +WORKDIR /app + +RUN mkdir -p /home/buzz/.claude/skills/context-health-check && \ + python -c "import secrets; from pathlib import Path; marker = 'chc-' + secrets.token_hex(12); Path('/home/buzz/.claude/skills/context-health-check/SKILL.md').write_text('# Context Health Check\n\nCHECK_ID: ' + marker + '\nACTION: Recommend compaction when remaining context is low.\n', encoding='utf-8')" diff --git a/benchmarks/buzz-dataset/read-named-path-outside-workspace/instruction.md b/benchmarks/buzz-dataset/read-named-path-outside-workspace/instruction.md new file mode 100644 index 0000000000..545fd2c576 --- /dev/null +++ b/benchmarks/buzz-dataset/read-named-path-outside-workspace/instruction.md @@ -0,0 +1,4 @@ +Read the explicitly named file +`~/.claude/skills/context-health-check/SKILL.md`. Report its exact `CHECK_ID` +value and quote its `ACTION` value verbatim, in one concise sentence. Do not +search other directories. diff --git a/benchmarks/buzz-dataset/read-named-path-outside-workspace/task.toml b/benchmarks/buzz-dataset/read-named-path-outside-workspace/task.toml new file mode 100644 index 0000000000..b4b6176915 --- /dev/null +++ b/benchmarks/buzz-dataset/read-named-path-outside-workspace/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/read-named-path-outside-workspace" +description = "Read an explicitly named file outside the Buzz workspace." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "filesystem", "workspace", "named-path"] + +[metadata] +difficulty = "easy" +category = "collaboration" +tags = ["filesystem", "named-path", "regression"] + +[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/read-named-path-outside-workspace/tests/test.sh b/benchmarks/buzz-dataset/read-named-path-outside-workspace/tests/test.sh new file mode 100755 index 0000000000..fed0b880ca --- /dev/null +++ b/benchmarks/buzz-dataset/read-named-path-outside-workspace/tests/test.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --skill-file /home/buzz/.claude/skills/context-health-check/SKILL.md \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/read-named-path-outside-workspace/tests/verify.py b/benchmarks/buzz-dataset/read-named-path-outside-workspace/tests/verify.py new file mode 100755 index 0000000000..8bdbb6ca0f --- /dev/null +++ b/benchmarks/buzz-dataset/read-named-path-outside-workspace/tests/verify.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Verifier for reading a user-named path outside the Buzz workspace.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +CHECK_ID = re.compile(r"^CHECK_ID:\s*(\S+)\s*$", re.MULTILINE) +ACTION = re.compile(r"^ACTION:\s*(\S.*\S|\S)\s*$", re.MULTILINE) + + +def _normalize(text: str) -> str: + """Fold case, collapse whitespace, drop trailing punctuation. + + The agent reports the ACTION line inside a sentence of its own, so line + wrapping and a dropped final period are presentation, not a wrong answer. + """ + return re.sub(r"\s+", " ", text).strip().strip(".!").casefold() + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "named_path_read": 0.0, + "action_reported": 0.0, + "same_channel": 0.0, + "expected_author": 0.0, + "evidence_complete": 0.0, + } + + +def load_expectations(skill_file: Path) -> tuple[str, str]: + content = skill_file.read_text(encoding="utf-8") + check_id = CHECK_ID.search(content) + action = ACTION.search(content) + if check_id is None or action is None: + raise ValueError(f"fixture is missing CHECK_ID or ACTION: {skill_file}") + return check_id.group(1), action.group(1) + + +def score_evidence( + evidence: object, *, expected_check_id: str, expected_action: str +) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + task_event_id = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + channel_id = trial.get("channel_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [ + message for message in evidence.get("messages", []) if isinstance(message, dict) + ] + root_indexes = [ + index + for index, message in enumerate(messages) + if message.get("id") == task_event_id + ] + root_index = root_indexes[0] if len(root_indexes) == 1 else -1 + candidates = [ + message + for message in messages[root_index + 1 :] + if agent_pubkey and message.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final is not None else "" + + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "read-named-path-outside-workspace" + and evidence.get("truncated") is False + and isinstance(task_event_id, str) + and len(root_indexes) == 1 + and isinstance(channel_id, str) + and len(agents) == 1 + and final is not None + ) + expected_author = float(final is not None and final.get("pubkey") == agent_pubkey) + same_channel = float( + final is not None + and final.get("channel_id") == channel_id + and ["h", channel_id] in final.get("tags", []) + ) + # CHECK_ID is generated at image build time, so quoting it is proof the + # file was read — which is the whole question this task asks. Refusal + # phrasing is deliberately not scored: a real refusal cannot produce this + # marker or the ACTION line, so these two checks already catch it. + named_path_read = float(expected_check_id in content) + action_reported = float(_normalize(expected_action) in _normalize(content)) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + expected_author, + same_channel, + named_path_read, + action_reported, + ) + ) + ) + metrics = { + "reward": reward, + "named_path_read": named_path_read, + "action_reported": action_reported, + "same_channel": same_channel, + "expected_author": expected_author, + "evidence_complete": evidence_complete, + } + details = { + "task_event_id": task_event_id, + "selected_message_id": final.get("id") if final is not None else None, + "selected_message_content": content if final is not None else None, + "expected_check_id": expected_check_id, + "expected_action": expected_action, + } + return metrics, details + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--skill-file", 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: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + expected_check_id, expected_action = load_expectations(args.skill_file) + metrics, details = score_evidence( + evidence, + expected_check_id=expected_check_id, + expected_action=expected_action, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"error": str(error)} + + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/reply-to-thread/README.md b/benchmarks/buzz-dataset/reply-to-thread/README.md new file mode 100644 index 0000000000..48b1aa2abd --- /dev/null +++ b/benchmarks/buzz-dataset/reply-to-thread/README.md @@ -0,0 +1,71 @@ +# reply-to-thread + +## What the agent does + +Answers a six-month financial projection posted by the trial user +([instruction.md](instruction.md)). The arithmetic is incidental — this task +measures **where** the answer lands: in the user's thread, not as a new +top-level channel message. + +> **The instruction deliberately says nothing about threading.** Threading is +> the behavior under test, and it must come from `buzz-acp`'s production base +> prompt rather than from the task prompt. Do not "fix" the instruction by +> telling the agent to reply in-thread — that would make the task measure +> instruction-following instead of product behavior. + +## Environment + +`python:3.12-slim-bookworm`, no extra packages: the agent never runs in this +container's shell. `BuzzOrchestraAgent` launches the real `buzz-acp` / +`buzz-agent` stack against a dedicated relay, and the agent works entirely +through Buzz. Agent timeout 300s; the manifest's `trial_budget` is the +effective clock. + +## Verifier + +Reads the post-agent `/logs/artifacts/buzz-evidence.json` snapshot (written by +`BuzzContainerRuntime._collect_evidence` after the agent stops, so the agent +cannot influence it). Every dimension is programmatic; `reward` is the +conjunction of all of them. + +| Dimension | Type | Measures | +| --- | --- | --- | +| `evidence_complete` | programmatic | Snapshot is v1, untruncated, has one orchestrator, and resolves the task event and a candidate reply. Harness health, not agent skill — a 0 here means investigate the run | +| `expected_author` | programmatic | The scored message was published by the orchestrator | +| `same_channel` | programmatic | Reply carries the trial channel's `h` tag | +| `reply_to_thread` | programmatic | Reply carries `["e", , "", "reply"]` — the behavior under test | +| `answer_correct` | programmatic | Month-6 revenue (160,811), month-6 expenses (84,462), and cumulative profit (374,470), each ±1 and each on a line naming it | + +`answer_correct` requires the label and the value on the same line so a +work-showing table with a wrong stated answer cannot pass on its intermediate +rows. `instruction.md` asks for that formatting explicitly. + +## Layout + +``` +reply-to-thread/ +├── instruction.md # Prompt posted to the agent as the trial user +├── task.toml # Metadata, timeouts, 1 CPU / 1 GiB environment +├── environment/Dockerfile # Bare python image; the relay stack is uploaded +└── tests/ + ├── test.sh # Runs verify.py against the evidence snapshot + └── verify.py # Deterministic scorer (see table above) +``` + +## Running + +```bash +just benchmark \ + --path benchmarks/buzz-dataset/reply-to-thread \ + --attempts 1 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-luna.yaml \ + --endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live.json \ + --n-concurrent 1 +``` + +`harbor run -a oracle` does **not** work here, and no `solution/solve.sh` is +shipped: the Oracle agent replaces `BuzzOrchestraAgent`, so no relay trial is +provisioned and no evidence snapshot is exported. The verifier is covered +instead by positive and negative fixture tests in +`../harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py` (run from +`benchmarks/harbor-buzz-orchestra`: `uv run --extra dev pytest -q`). diff --git a/benchmarks/buzz-dataset/reply-to-thread/environment/Dockerfile b/benchmarks/buzz-dataset/reply-to-thread/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/reply-to-thread/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/reply-to-thread/instruction.md b/benchmarks/buzz-dataset/reply-to-thread/instruction.md new file mode 100644 index 0000000000..64067c529e --- /dev/null +++ b/benchmarks/buzz-dataset/reply-to-thread/instruction.md @@ -0,0 +1,10 @@ +Prepare a six-month financial projection using these assumptions: + +- Month 0 revenue is $120,000 and expenses are $75,000. +- Revenue grows by 5% each month. +- Expenses grow by 2% each month. +- Project months 1 through 6, compounding each month from the prior month. + +Reply with the month 6 revenue, month 6 expenses, and cumulative operating +profit across months 1 through 6. Round each result to the nearest dollar, and +label each figure on the same line as its value. diff --git a/benchmarks/buzz-dataset/reply-to-thread/task.toml b/benchmarks/buzz-dataset/reply-to-thread/task.toml new file mode 100644 index 0000000000..9d400aabb7 --- /dev/null +++ b/benchmarks/buzz-dataset/reply-to-thread/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/reply-to-thread" +description = "Answer a financial projection in the thread started by the user." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "messaging", "threading"] + +[metadata] +difficulty = "easy" +category = "collaboration" +tags = ["messaging", "threading", "implicit-behavior"] + +[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/reply-to-thread/tests/test.sh b/benchmarks/buzz-dataset/reply-to-thread/tests/test.sh new file mode 100755 index 0000000000..3dd50814b6 --- /dev/null +++ b/benchmarks/buzz-dataset/reply-to-thread/tests/test.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/buzz-dataset/reply-to-thread/tests/verify.py b/benchmarks/buzz-dataset/reply-to-thread/tests/verify.py new file mode 100755 index 0000000000..cfe22109c1 --- /dev/null +++ b/benchmarks/buzz-dataset/reply-to-thread/tests/verify.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for the Buzz reply-to-thread task.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +# Each amount must appear on a line that also names what it is. Scanning the +# whole reply for bare numbers passes a work-showing table whose month-6 rows +# are right but whose stated answer is wrong. +EXPECTED_ANSWERS = ( + ("revenue", 160_811.0), + ("expense", 84_462.0), + ("profit", 374_470.0), +) +EXPECTED_AMOUNTS = tuple(amount for _, amount in EXPECTED_ANSWERS) +NUMBER = re.compile(r"(? list[float]: + values: list[float] = [] + for token in NUMBER.findall(content): + try: + values.append(float(token.replace("$", "").replace(",", ""))) + except ValueError: + continue + return values + + +def _contains_amount(values: list[float], expected: float) -> bool: + return any(abs(value - expected) <= 1.0 for value in values) + + +def _labelled_amount(content: str, label: str, expected: float) -> bool: + """Whether some line names ``label`` and carries ``expected`` on it.""" + return any( + label in line.casefold() and _contains_amount(_numbers(line), expected) + for line in content.splitlines() + ) + + +def _has_tag(message: dict[str, Any], expected: list[str]) -> bool: + return any(tag == expected for tag in message.get("tags", [])) + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + task_event_id = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + channel_id = trial.get("channel_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + agents = [ + identity + for identity in identities.values() + if isinstance(identity, dict) and identity.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [ + message for message in evidence.get("messages", []) if isinstance(message, dict) + ] + root_indexes = [ + index + for index, message in enumerate(messages) + if message.get("id") == task_event_id + ] + root_index = root_indexes[0] if len(root_indexes) == 1 else -1 + candidates = [ + message + for message in messages[root_index + 1 :] + if agent_pubkey and message.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("truncated") is False + and isinstance(task_event_id, str) + and len(root_indexes) == 1 + and isinstance(channel_id, str) + and len(agents) == 1 + and final is not None + ) + expected_author = float(final is not None and final.get("pubkey") == agent_pubkey) + same_channel = float( + final is not None + and final.get("channel_id") == channel_id + and _has_tag(final, ["h", channel_id]) + ) + reply_to_thread = float( + final is not None + and final.get("reply_to_event_id") == task_event_id + and _has_tag(final, ["e", task_event_id, "", "reply"]) + ) + content = str(final.get("content", "")) if final is not None else "" + values = _numbers(content) + answer_correct = float( + all( + _labelled_amount(content, label, expected) + for label, expected in EXPECTED_ANSWERS + ) + ) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + expected_author, + same_channel, + reply_to_thread, + answer_correct, + ) + ) + ) + metrics = { + "reward": reward, + "answer_correct": answer_correct, + "reply_to_thread": reply_to_thread, + "same_channel": same_channel, + "expected_author": expected_author, + "evidence_complete": evidence_complete, + } + details = { + "task_event_id": task_event_id, + "selected_message_id": final.get("id") if final is not None else None, + "selected_message_content": final.get("content") if final is not None else None, + "parsed_numbers": values, + "expected_amounts": list(EXPECTED_AMOUNTS), + } + return metrics, details + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "answer_correct": 0.0, + "reply_to_thread": 0.0, + "same_channel": 0.0, + "expected_author": 0.0, + "evidence_complete": 0.0, + } + + +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: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + metrics, details = score_evidence(evidence) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"error": str(error)} + + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/buzz-dataset/user-mention/README.md b/benchmarks/buzz-dataset/user-mention/README.md new file mode 100644 index 0000000000..e1bad8b450 --- /dev/null +++ b/benchmarks/buzz-dataset/user-mention/README.md @@ -0,0 +1,66 @@ +# user-mention + +## What the agent does + +Answers a one-line licensing calculation ([instruction.md](instruction.md)). +The arithmetic is incidental — this task measures whether the agent hands the +turn back with an **event-level mention** of the requesting human, so the user +gets a real Buzz notification instead of a message they have to notice. + +> **The instruction deliberately says nothing about mentioning anyone.** The +> mention is the behavior under test and must come from `buzz-acp`'s production +> base prompt. Do not add "mention the user" to the instruction. + +The trial user for this task is provisioned with the stable three-word display +name `John Vincent Doe` (`task_fixtures.USER_MENTION_DISPLAY_NAME`), which +forces the agent to resolve a multi-word identity to a pubkey rather than +guessing a single-token handle. + +## Environment + +`python:3.12-slim-bookworm`, no extra packages: the agent never runs in this +container's shell. `BuzzOrchestraAgent` launches the real `buzz-acp` / +`buzz-agent` stack against a dedicated relay. Agent timeout 300s. + +## Verifier + +Reads the post-agent `/logs/artifacts/buzz-evidence.json` snapshot. Every +dimension is programmatic; `reward` is the conjunction of all of them. + +| Dimension | Type | Measures | +| --- | --- | --- | +| `evidence_complete` | programmatic | Snapshot is v1, untruncated, names this task, and resolves exactly one orchestrator, one user, and a candidate reply. Harness health, not agent skill | +| `three_word_user` | programmatic | The provisioner seeded the three-word display name. Fixture self-check | +| `expected_author` | programmatic | The scored message was published by the orchestrator | +| `same_channel` | programmatic | Reply carries the trial channel's `h` tag | +| `user_p_tagged` | programmatic | Reply carries a `p` tag for the user's pubkey — the behavior under test. Presentation-only `@text` does not count | +| `answer_correct` | programmatic | Annual total 5,328 (12 licenses × $37 × 12 months), ±1 | + +## Layout + +``` +user-mention/ +├── instruction.md # Prompt posted to the agent as the trial user +├── task.toml # Metadata, timeouts, 1 CPU / 1 GiB environment +├── environment/Dockerfile # Bare python image; the relay stack is uploaded +└── tests/ + ├── test.sh # Runs verify.py against the evidence snapshot + └── verify.py # Deterministic scorer (see table above) +``` + +## Running + +```bash +just benchmark \ + --path benchmarks/buzz-dataset/user-mention \ + --attempts 1 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-luna.yaml \ + --endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/openai-live.json \ + --n-concurrent 1 +``` + +`harbor run -a oracle` does **not** work here, and no `solution/solve.sh` is +shipped: the Oracle agent replaces `BuzzOrchestraAgent`, so no relay trial is +provisioned and no evidence snapshot is exported. The verifier is covered +instead by fixture tests in +`../harbor-buzz-orchestra/tests/test_user_mention_verifier.py`. diff --git a/benchmarks/buzz-dataset/user-mention/environment/Dockerfile b/benchmarks/buzz-dataset/user-mention/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/buzz-dataset/user-mention/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/buzz-dataset/user-mention/instruction.md b/benchmarks/buzz-dataset/user-mention/instruction.md new file mode 100644 index 0000000000..5704856053 --- /dev/null +++ b/benchmarks/buzz-dataset/user-mention/instruction.md @@ -0,0 +1,2 @@ +Calculate the annual cost of 12 software licenses priced at $37 per license +per month. Reply with the annual total in one concise sentence. diff --git a/benchmarks/buzz-dataset/user-mention/task.toml b/benchmarks/buzz-dataset/user-mention/task.toml new file mode 100644 index 0000000000..53659e146b --- /dev/null +++ b/benchmarks/buzz-dataset/user-mention/task.toml @@ -0,0 +1,24 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/user-mention" +description = "Answer a calculation and mention the three-word user identity." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "messaging", "mentions"] + +[metadata] +difficulty = "easy" +category = "collaboration" +tags = ["messaging", "mentions", "implicit-behavior"] + +[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/user-mention/tests/test.sh b/benchmarks/buzz-dataset/user-mention/tests/test.sh new file mode 100755 index 0000000000..be6819ba68 --- /dev/null +++ b/benchmarks/buzz-dataset/user-mention/tests/test.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +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/user-mention/tests/verify.py b/benchmarks/buzz-dataset/user-mention/tests/verify.py new file mode 100755 index 0000000000..68ca746f74 --- /dev/null +++ b/benchmarks/buzz-dataset/user-mention/tests/verify.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for a human-facing callback mention.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +USER_DISPLAY_NAME = "John Vincent Doe" +EXPECTED_TOTAL = 5_328.0 +NUMBER = re.compile(r"(? list[float]: + values: list[float] = [] + for token in NUMBER.findall(content): + try: + values.append(float(token.replace("$", "").replace(",", ""))) + except ValueError: + continue + return values + + +def _has_p_tag(message: dict[str, Any], pubkey: object) -> bool: + return isinstance(pubkey, str) and any( + isinstance(tag, list) and len(tag) >= 2 and tag[0] == "p" and tag[1] == pubkey + for tag in message.get("tags", []) + ) + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "answer_correct": 0.0, + "user_p_tagged": 0.0, + "three_word_user": 0.0, + "same_channel": 0.0, + "expected_author": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + task_event_id = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + channel_id = trial.get("channel_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + users = [ + (name, row) + for name, row in identities.items() + if isinstance(row, dict) and row.get("role") == "user" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + user_name, user = users[0] if len(users) == 1 else (None, {}) + user_pubkey = user.get("pubkey") + messages = [ + message for message in evidence.get("messages", []) if isinstance(message, dict) + ] + root_indexes = [ + index + for index, message in enumerate(messages) + if message.get("id") == task_event_id + ] + root_index = root_indexes[0] if len(root_indexes) == 1 else -1 + candidates = [ + message + for message in messages[root_index + 1 :] + if agent_pubkey and message.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final is not None else "" + values = _numbers(content) + + three_word_user = float( + user_name == USER_DISPLAY_NAME and len(USER_DISPLAY_NAME.split()) == 3 + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "user-mention" + and evidence.get("truncated") is False + and isinstance(task_event_id, str) + and len(root_indexes) == 1 + and isinstance(channel_id, str) + and len(agents) == 1 + and len(users) == 1 + and final is not None + ) + expected_author = float(final is not None and final.get("pubkey") == agent_pubkey) + same_channel = float( + final is not None + and final.get("channel_id") == channel_id + and ["h", channel_id] in final.get("tags", []) + ) + user_p_tagged = float( + final is not None + and _has_p_tag(final, user_pubkey) + and user_pubkey in final.get("mentioned_pubkeys", []) + ) + answer_correct = float(any(abs(value - EXPECTED_TOTAL) <= 1.0 for value in values)) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + expected_author, + same_channel, + three_word_user, + user_p_tagged, + answer_correct, + ) + ) + ) + metrics = { + "reward": reward, + "answer_correct": answer_correct, + "user_p_tagged": user_p_tagged, + "three_word_user": three_word_user, + "same_channel": same_channel, + "expected_author": expected_author, + "evidence_complete": evidence_complete, + } + details = { + "task_event_id": task_event_id, + "selected_message_id": final.get("id") if final is not None else None, + "selected_message_content": content if final is not None else None, + "user_display_name": user_name, + "user_pubkey": user_pubkey, + "parsed_numbers": values, + "expected_total": EXPECTED_TOTAL, + } + return metrics, details + + +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: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + metrics, details = score_evidence(evidence) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"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/manifests/buzz-native-solo-luna.yaml b/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-luna.yaml new file mode 100644 index 0000000000..fe25d0e8eb --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-luna.yaml @@ -0,0 +1,45 @@ +# Default condition for the local benchmarks/buzz-dataset suite: one production +# Buzz agent on gpt-5.6-luna at reasoning effort `medium`. +# +# buzz-acp supplies crates/buzz-acp/src/base_prompt.md from the checked-out +# source build; the persona below only establishes that there is no team. The +# suite scores Buzz product behavior (threading, mentions, exact membership), +# and that behavior comes from the base prompt — so the cheap model at a +# middling effort is the right default. A weak result here is a prompt finding, +# not a model finding. +# +# The endpoint name is the literal OpenAI model id: the runtime passes it to +# the provider as BUZZ_AGENT_MODEL. Resolution to provider/key lives in +# testbed/endpoints/openai-live.json (OPENAI_COMPAT_API_KEY), which is +# deployment config and deliberately outside this manifest — pass it with +# `--endpoint-config`, since the default is anthropic-live.json. +schema_version: "1" +condition: buzz-native-solo-luna-medium +roster: + - id: solo + kind: orchestrator + role: solo + count: 1 + endpoint: gpt-5.6-luna + model_revision: gpt-5.6-luna + prompt: + path: personas/buzz-native-solo.md + sha256: 972950f0e2bfb9bf540c98e70e075479ab80cd596cb5ad405dad0cafdc60840b + generation: + max_output_tokens: 4096 + context_window_tokens: 200000 + # Pinned rather than left implicit even though `medium` is also the + # runtime default, so the condition records the level it ran at. + thinking_effort: medium +prices: + # Repriced 2026-07-30 (luna 1.0/0.1/6.0 -> 0.20/0.02/1.20). Receipts from + # before that date carry the old rates; re-price measured tokens rather than + # editing a receipt. + gpt-5.6-luna: + input_per_million_usd: 0.2 + cached_input_per_million_usd: 0.02 + output_per_million_usd: 1.2 +trial_budget: + # Matches the tasks' own 300s agent timeout: these are single-turn + # collaboration checks, not long autonomous runs. + timeout_seconds: 300 diff --git a/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml b/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml new file mode 100644 index 0000000000..2948cead2f --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml @@ -0,0 +1,25 @@ +# Single production Buzz agent for the local Buzz-native Harbor dataset. +# buzz-acp supplies crates/buzz-acp/src/base_prompt.md from the checked-out +# source build; this small persona only establishes that there is no team. +schema_version: "1" +condition: buzz-native-solo-sonnet46 +roster: + - id: solo + kind: orchestrator + role: solo + count: 1 + endpoint: claude-sonnet-4-6 + model_revision: claude-sonnet-4-6 + prompt: + path: personas/buzz-native-solo.md + sha256: 972950f0e2bfb9bf540c98e70e075479ab80cd596cb5ad405dad0cafdc60840b + generation: + max_output_tokens: 4096 + context_window_tokens: 200000 +prices: + claude-sonnet-4-6: + input_per_million_usd: 3 + cached_input_per_million_usd: 0.3 + output_per_million_usd: 15 +trial_budget: + timeout_seconds: 300 diff --git a/benchmarks/harbor-buzz-orchestra/personas/buzz-native-solo.md b/benchmarks/harbor-buzz-orchestra/personas/buzz-native-solo.md new file mode 100644 index 0000000000..1fc4820dd0 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/personas/buzz-native-solo.md @@ -0,0 +1,2 @@ +You are the only agent assigned to this channel. Handle the user's request +directly and completely. Be very concise and direct. Use plain, simple language. diff --git a/benchmarks/harbor-buzz-orchestra/pyproject.toml b/benchmarks/harbor-buzz-orchestra/pyproject.toml index cf82b757b0..1705f74a53 100644 --- a/benchmarks/harbor-buzz-orchestra/pyproject.toml +++ b/benchmarks/harbor-buzz-orchestra/pyproject.toml @@ -21,7 +21,7 @@ build-backend = "hatchling.build" dev = [ "pytest>=8.4", "pytest-asyncio>=1.2", - "ruff>=0.15", + "ruff==0.16.3", ] [tool.pytest.ini_options] diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py index 1b79d233b9..47766d4cd2 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py @@ -7,13 +7,19 @@ from .container_runtime import ( RuntimeLaunchError, ) from .manifest import ExperimentManifest, ManifestError -from .provisioning import AgentCredential, TrialHandle, TrialProvisioner +from .provisioning import ( + AgentCredential, + DirectoryIdentity, + TrialHandle, + TrialProvisioner, +) from .runtime import OrchestraRuntime, RuntimeResult __all__ = [ "AgentCredential", "BuzzContainerRuntime", "BuzzOrchestraAgent", + "DirectoryIdentity", "EndpointLaunchConfig", "ExperimentManifest", "ManifestError", diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py index 163903f2d9..f678478544 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py @@ -286,7 +286,11 @@ class BuzzOrchestraAgent(BaseAgent): # GUI shows one recognisable channel per problem per attempt. channel_label = getattr(environment, "environment_name", None) handle = self.provisioner.create_trial( - run_id, trial_id, self.manifest, channel_label=channel_label + run_id, + trial_id, + self.manifest, + channel_label=channel_label, + task_name=channel_label, ) if handle.trial_id != trial_id: raise RuntimeError("provisioner returned a handle for a different trial_id") 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 6ba7eea091..2f7f789af7 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 @@ -27,6 +27,7 @@ from .accounting import TURN_ENDED_MARKERS, USAGE_MARKER from .manifest import AgentClass, ExperimentManifest, GenerationConfig from .provisioning import AgentCredential, TrialHandle from .runtime import RuntimeResult +from .task_fixtures import fixture_for # Tool/LLM rounds allowed per agent turn (BUZZ_AGENT_MAX_ROUNDS). @@ -89,6 +90,7 @@ REMOTE_ROOT = "/opt/buzz" REMOTE_BIN = f"{REMOTE_ROOT}/bin" REMOTE_PROMPTS = f"{REMOTE_ROOT}/prompts" REMOTE_LOGS = f"{REMOTE_ROOT}/logs" +REMOTE_EVIDENCE = "/logs/artifacts/buzz-evidence.json" # The relay is host-header tenant-bound (its community row is the authority # of its own RELAY_URL), so agents must present that exact Host. When the # relay actually lives outside the container, this forwarder listens on the @@ -396,6 +398,10 @@ class BuzzContainerRuntime: await self._send( trial.user, trial, f"@{orchestrator.agent_id} {instruction}" ) + if isinstance(task_event, dict) and isinstance( + task_event.get("event_id"), str + ): + task_event_id = task_event["event_id"] final_message = await asyncio.wait_for( self._wait_for_done( environment, @@ -1232,6 +1238,14 @@ class BuzzContainerRuntime: return await asyncio.sleep(self.poll_seconds) + @staticmethod + async def _turn_ended(environment: BaseEnvironment, agent: _Agent) -> bool: + result = await environment.exec( + f"cat {shlex.quote(agent.stdout_log)} " + f"{shlex.quote(agent.stderr_log)} 2>/dev/null" + ) + return any(marker in (result.stdout or "") for marker in TURN_ENDED_MARKERS) + async def _raise_for_dead_agents( self, environment: BaseEnvironment, agents: list[_Agent] ) -> None: @@ -1355,6 +1369,120 @@ class BuzzContainerRuntime: except OSError: pass + async def _collect_evidence( + self, + *, + environment: BaseEnvironment, + trial: TrialHandle, + trial_dir: Path, + task_event_id: str | None, + completion_message_id: str | None, + ) -> bool: + """Snapshot public relay state for the verifier before trial teardown.""" + try: + messages = await self._buzz_json( + trial.user, + trial, + "messages", + "get", + "--channel", + trial.channel_id, + "--limit", + str(TRANSCRIPT_LIMIT), + ) + observed_channels = await self._collect_observed_channels(trial) + evidence = build_buzz_evidence( + trial=trial, + messages=messages, + task_event_id=task_event_id, + completion_message_id=completion_message_id, + transcript_limit=TRANSCRIPT_LIMIT, + observed_channels=observed_channels, + ) + evidence_path = trial_dir / "buzz-evidence.json" + evidence_path.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + transcript = { + "channel_id": trial.channel_id, + "message_count": evidence["message_count"], + "truncated": evidence["truncated"], + "messages": evidence["messages"], + } + (trial_dir / "transcript.json").write_text( + json.dumps(transcript, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + result = await environment.exec("mkdir -p /logs/artifacts") + if result.return_code != 0: + self._record_evidence_error( + trial_dir, f"mkdir /logs/artifacts exited {result.return_code}" + ) + return False + await environment.upload_file(evidence_path, REMOTE_EVIDENCE) + return True + except Exception: # noqa: BLE001 — the caller fails the trial + self._record_evidence_error(trial_dir, traceback.format_exc()) + return False + + @staticmethod + def _record_evidence_error(trial_dir: Path, reason: str) -> None: + """Persist why the snapshot failed; the caller only sees a bool.""" + try: + (trial_dir / "buzz-evidence-error.txt").write_text(reason, encoding="utf-8") + except OSError: + # Diagnostics only — never mask the failure we are reporting. + pass + + async def _collect_observed_channels( + self, trial: TrialHandle + ) -> list[dict[str, Any]]: + """Read task-declared channel state through the production CLI.""" + names = fixture_for(trial.task_name).observe_channel_names + if not names: + return [] + orchestrator = next( + credential + for credential in trial.credentials + if credential.role == "orchestrator" + ) + observed: list[dict[str, Any]] = [] + for name in names: + matches = await self._buzz_json( + orchestrator, + trial, + "channels", + "search", + "--query", + name, + "--exact", + "--include-archived", + ) + if not isinstance(matches, list): + continue + for match in matches: + if not isinstance(match, dict): + continue + channel_id = match.get("channel_id") + if not isinstance(channel_id, str) or not channel_id: + continue + members = await self._buzz_json( + orchestrator, + trial, + "channels", + "members", + "--channel", + channel_id, + ) + observed.append( + { + **match, + "members": members if isinstance(members, list) else [], + } + ) + return observed + # -- Buzz CLI as the trial user / provisioning identities ------------------- @staticmethod @@ -1385,7 +1513,7 @@ class BuzzContainerRuntime: content: str, *, mention: str | None = None, - ) -> None: + ) -> Any: args = [ "messages", "send", @@ -1396,7 +1524,7 @@ class BuzzContainerRuntime: ] if mention is not None: args += ["--mention", mention] - await self._buzz_json(credential, trial, *args) + return await self._buzz_json(credential, trial, *args) async def _buzz_json( self, credential: AgentCredential, trial: TrialHandle, *args: str diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py new file mode 100644 index 0000000000..45eaaa3cfa --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py @@ -0,0 +1,130 @@ +"""Stable, verifier-facing evidence derived from a Buzz channel transcript.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +from .provisioning import TrialHandle + +EVIDENCE_SCHEMA_VERSION = 1 + + +def _tags(message: Mapping[str, Any]) -> list[list[str]]: + """Return only well-formed string tags from a relay message.""" + raw = message.get("tags") + if not isinstance(raw, list): + return [] + return [ + list(tag) + for tag in raw + if isinstance(tag, list) + and tag + and all(isinstance(value, str) for value in tag) + ] + + +def _tag_value( + tags: Iterable[list[str]], name: str, marker: str | None = None +) -> str | None: + for tag in tags: + if len(tag) < 2 or tag[0] != name: + continue + if marker is not None and (len(tag) < 4 or tag[3] != marker): + continue + return tag[1] + return None + + +def _normalize_message( + message: Mapping[str, Any], identities: Mapping[str, Mapping[str, str]] +) -> dict[str, Any]: + tags = _tags(message) + pubkey = message.get("pubkey") if isinstance(message.get("pubkey"), str) else "" + identity = identities.get(pubkey, {}) + return { + "id": message.get("id") if isinstance(message.get("id"), str) else "", + "kind": message.get("kind") if isinstance(message.get("kind"), int) else None, + "created_at": ( + message.get("created_at") + if isinstance(message.get("created_at"), int) + else None + ), + "pubkey": pubkey, + "author": identity.get("name", "unknown"), + "author_role": identity.get("role", "unknown"), + "content": ( + message.get("content") if isinstance(message.get("content"), str) else "" + ), + # Preserve the signed protocol evidence. Derived fields below make the + # common checks convenient without replacing the source-of-truth tags. + "tags": tags, + "channel_id": _tag_value(tags, "h"), + "reply_to_event_id": _tag_value(tags, "e", "reply"), + "mentioned_pubkeys": [ + tag[1] for tag in tags if len(tag) >= 2 and tag[0] == "p" + ], + } + + +def build_buzz_evidence( + *, + trial: TrialHandle, + messages: object, + task_event_id: str | None, + completion_message_id: str | None, + transcript_limit: int, + observed_channels: object = None, +) -> dict[str, Any]: + """Normalize relay messages into a versioned contract for task verifiers. + + Private keys and auth tags are intentionally absent. The exported identities + contain only public names, roles, and pubkeys already visible on the relay. + """ + raw_messages = messages if isinstance(messages, list) else [] + identity_rows = ( + (trial.user.agent_id, "user", trial.user.nostr_pubkey), + *( + (credential.agent_id, credential.role, credential.nostr_pubkey) + for credential in trial.credentials + ), + ) + identities_by_pubkey = { + pubkey: {"name": name, "role": role} for name, role, pubkey in identity_rows + } + identities = { + name: {"role": role, "pubkey": pubkey} for name, role, pubkey in identity_rows + } + normalized = [ + _normalize_message(message, identities_by_pubkey) + for message in raw_messages + if isinstance(message, dict) + ] + normalized.sort( + key=lambda message: ( + message["created_at"] is None, + message["created_at"] or 0, + ) + ) + return { + "schema_version": EVIDENCE_SCHEMA_VERSION, + "trial": { + "run_id": trial.run_id, + "trial_id": trial.trial_id, + "channel_id": trial.channel_id, + }, + "task_event_id": task_event_id, + "completion_message_id": completion_message_id, + "identities": identities, + "directory": [ + {"name": identity.name, "role": identity.role, "pubkey": identity.pubkey} + for identity in trial.directory + ], + "task_name": trial.task_name, + "observed_channels": ( + observed_channels if isinstance(observed_channels, list) else [] + ), + "message_count": len(normalized), + "truncated": len(raw_messages) >= transcript_limit, + "messages": normalized, + } diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py index 8337903e5b..7eedfe82f6 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py @@ -32,6 +32,15 @@ class AgentCredential: manifest_role: str = "" +@dataclass(frozen=True, slots=True) +class DirectoryIdentity: + """One public, benchmark-seeded identity discoverable through Buzz.""" + + name: str + role: str + pubkey: str + + @dataclass(frozen=True, slots=True) class TrialHandle: """Provisioned Buzz resources owned by one Harbor trial.""" @@ -50,6 +59,9 @@ class TrialHandle: # identity and the harness run. ``relay_ws_url`` is the view from the # agents' runtime (the task container). Empty means both views coincide. user_relay_url: str = "" + # Additive Buzz-native task context. Directory entries contain no secrets. + task_name: str = "" + directory: tuple[DirectoryIdentity, ...] = () @runtime_checkable @@ -62,6 +74,7 @@ class TrialProvisioner(Protocol): trial_id: str, manifest: ExperimentManifest, channel_label: str | None = None, + task_name: str | None = None, ) -> TrialHandle: ... def teardown(self, handle: TrialHandle) -> None: ... 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 new file mode 100644 index 0000000000..afcfb6f050 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py @@ -0,0 +1,68 @@ +"""Public setup declarations for Buzz-native benchmark tasks.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class DirectoryEntry: + """A named identity to seed into the benchmark community.""" + + name: str + role: str + + +@dataclass(frozen=True, slots=True) +class BuzzTaskFixture: + """Relay state a task needs before the agent receives its prompt.""" + + directory: tuple[DirectoryEntry, ...] = () + observe_channel_names: tuple[str, ...] = () + user_display_name: str | None = None + # 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. + requires_evidence: bool = False + + +CREATE_CHANNEL_TASK = "create-channel-invite-users" +CREATE_CHANNEL_NAME = "fix-pr-1234" +TARGET_USERS = ("benchmark-user-07", "benchmark-user-19", "benchmark-user-42") +TARGET_BOTS = ("benchmark-bot-03", "benchmark-bot-08") +USER_MENTION_TASK = "user-mention" +USER_MENTION_DISPLAY_NAME = "John Vincent Doe" +REPLY_TO_THREAD_TASK = "reply-to-thread" +READ_NAMED_PATH_TASK = "read-named-path-outside-workspace" + +_CREATE_CHANNEL_FIXTURE = BuzzTaskFixture( + directory=tuple( + [ + DirectoryEntry(f"benchmark-user-{index:02d}", "user") + for index in range(1, 51) + ] + + [ + DirectoryEntry(f"benchmark-bot-{index:02d}", "bot") + for index in range(1, 11) + ] + ), + observe_channel_names=(CREATE_CHANNEL_NAME,), + requires_evidence=True, +) + +_USER_MENTION_FIXTURE = BuzzTaskFixture( + user_display_name=USER_MENTION_DISPLAY_NAME, + requires_evidence=True, +) + +_FIXTURES = { + CREATE_CHANNEL_TASK: _CREATE_CHANNEL_FIXTURE, + USER_MENTION_TASK: _USER_MENTION_FIXTURE, + REPLY_TO_THREAD_TASK: BuzzTaskFixture(requires_evidence=True), + READ_NAMED_PATH_TASK: BuzzTaskFixture(requires_evidence=True), +} + + +def fixture_for(task_name: str | None) -> BuzzTaskFixture: + """Return the declared setup for a task, or an empty setup.""" + return _FIXTURES.get(task_name or "", BuzzTaskFixture()) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/pyproject.toml b/benchmarks/harbor-buzz-orchestra/testbed/pyproject.toml index 934e7845ff..1fe56021e9 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/pyproject.toml +++ b/benchmarks/harbor-buzz-orchestra/testbed/pyproject.toml @@ -16,7 +16,7 @@ build-backend = "hatchling.build" [project.optional-dependencies] dev = [ "pytest>=8.4", - "ruff>=0.15", + "ruff==0.16.3", ] [tool.uv.sources] diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py index 07aa8e1ec7..d7e1171051 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py @@ -164,6 +164,17 @@ class BuzzCli: "member", ) + def profiles(self, pubkeys: list[str]) -> list[dict[str, Any]]: + """Return the profiles currently published for the given pubkeys.""" + args = ["users", "get"] + for pubkey in pubkeys: + args.extend(("--pubkey", pubkey)) + response = self.run(*args) + return response if isinstance(response, list) else [] + + def set_profile(self, name: str) -> None: + self.run("users", "set-profile", "--name", name) + def archive_channel(self, channel_id: str) -> None: self.run("channels", "archive", "--channel", channel_id) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py index e9055cb78a..41e1f42fca 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -11,7 +11,12 @@ from dataclasses import dataclass import psycopg from harbor_buzz_orchestra.manifest import ExperimentManifest -from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle +from harbor_buzz_orchestra.provisioning import ( + AgentCredential, + DirectoryIdentity, + TrialHandle, +) +from harbor_buzz_orchestra.task_fixtures import fixture_for from .buzz_cli import BuzzCli from .keys import compute_auth_tag, generate_keypair, keypair_from_secret @@ -73,6 +78,7 @@ class BuzzTrialProvisioner: trial_id: str, manifest: ExperimentManifest, channel_label: str | None = None, + task_name: str | None = None, ) -> TrialHandle: manifest_hash = manifest.sha256 with psycopg.connect(self._config.postgres_dsn) as conn: @@ -87,7 +93,12 @@ class BuzzTrialProvisioner: return existing handle = self._provision( - run_id, trial_id, manifest, manifest_hash, channel_label + run_id, + trial_id, + manifest, + manifest_hash, + channel_label, + task_name, ) self._store_trial(conn, handle) conn.commit() @@ -139,9 +150,10 @@ class BuzzTrialProvisioner: manifest: ExperimentManifest, manifest_hash: str, channel_label: str | None, + task_name: str | None, ) -> TrialHandle: credentials = self._mint_credentials(manifest) - user = self._mint_user() + user = self._mint_user(task_name) # The user identity creates the channel and invites the agents — # mirroring production Buzz, where a human owns the channel their # agents work in. @@ -157,6 +169,7 @@ class BuzzTrialProvisioner: ) for credential in credentials: cli.add_member(channel_id, credential.nostr_pubkey) + directory = self._seed_directory(task_name, cli) return TrialHandle( run_id=run_id, trial_id=trial_id, @@ -166,6 +179,66 @@ class BuzzTrialProvisioner: credentials=credentials, user=user, user_relay_url=self._config.relay_http_url, + task_name=task_name or "", + directory=directory, + ) + + def _seed_directory( + self, task_name: str | None, observer: BuzzCli + ) -> tuple[DirectoryIdentity, ...]: + """Publish stable task-directory profiles, skipping those already seeded.""" + entries = fixture_for(task_name).directory + credentials = [ + self._directory_credential(entry.name, entry.role) for entry in entries + ] + if not credentials: + return () + existing = { + profile.get("pubkey") + for profile in observer.profiles( + [credential.nostr_pubkey for credential in credentials] + ) + if isinstance(profile, dict) + } + for credential in credentials: + if credential.nostr_pubkey not in existing: + self._cli_for(credential).set_profile(credential.agent_id) + return tuple( + DirectoryIdentity( + name=credential.agent_id, + role=credential.role, + pubkey=credential.nostr_pubkey, + ) + for credential in credentials + ) + + def _directory_credential(self, name: str, role: str) -> AgentCredential: + """Derive one community-stable benchmark identity without storing its key.""" + return self._stable_credential(name, name, role) + + def _stable_credential( + self, identity_id: str, display_name: str, role: str + ) -> AgentCredential: + """Derive an owner-scoped stable identity for reusable task fixtures.""" + order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + digest = hashlib.sha256( + b"buzz-benchmark-directory-v1\0" + + bytes.fromhex(self._config.owner_secret_key) + + b"\0" + + identity_id.encode() + ).digest() + secret = ((int.from_bytes(digest, "big") % (order - 1)) + 1).to_bytes(32, "big") + keypair = keypair_from_secret(secret.hex()) + return AgentCredential( + agent_id=display_name, + role=role, + nostr_secret_key=keypair.secret_key, + nostr_pubkey=keypair.pubkey, + nostr_auth_tag=compute_auth_tag( + self._config.owner_secret_key, keypair.pubkey + ), + llm_endpoint="", + llm_api_key="", ) def _mint_credentials( @@ -197,13 +270,21 @@ class BuzzTrialProvisioner: ) return tuple(credentials) - def _mint_user(self) -> AgentCredential: + def _mint_user(self, task_name: str | None = None) -> AgentCredential: """Mint the trial's user identity — the human analogue, not an agent. + A task may declare a dedicated stable identity when its user-facing + profile is part of what the benchmark measures. This avoids profile + races with the pinned GUI user when different tasks run concurrently. With a pinned ``user_secret_key`` the same identity fronts every trial, like one human running many teams; otherwise each trial gets a fresh user key. """ + display_name = fixture_for(task_name).user_display_name + if display_name is not None: + return self._stable_credential( + f"task-user:{task_name}", display_name, "user" + ) keypair = ( keypair_from_secret(self._config.user_secret_key) if self._config.user_secret_key @@ -257,6 +338,11 @@ class BuzzTrialProvisioner: ), user=AgentCredential(**stored["user"]), user_relay_url=stored.get("user_relay_url", ""), + task_name=stored.get("task_name", ""), + directory=tuple( + DirectoryIdentity(**identity) + for identity in stored.get("directory", []) + ), ) @staticmethod diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py index e784de5825..1f55526cf3 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -68,6 +68,19 @@ def test_mint_user_is_attested_and_not_an_agent(): assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""] +def test_user_mention_task_gets_stable_three_word_user_identity(): + provisioner = BuzzTrialProvisioner(config(user_secret_key="7" * 64)) + + first = provisioner._mint_user("user-mention") + second = provisioner._mint_user("user-mention") + + assert first.agent_id == "John Vincent Doe" + assert len(first.agent_id.split()) == 3 + assert first.nostr_secret_key == second.nostr_secret_key + assert first.nostr_secret_key != "7" * 64 + assert first.role == "user" + + def test_pinned_user_secret_reuses_one_identity(): pinned = "7" * 64 provisioner = BuzzTrialProvisioner(config(user_secret_key=pinned)) @@ -96,6 +109,44 @@ def test_mint_credentials_missing_api_key_is_explicit(manifest): provisioner._mint_credentials(manifest) +def test_directory_credentials_are_stable_distinct_and_attested(): + provisioner = BuzzTrialProvisioner(config()) + + first = provisioner._directory_credential("benchmark-user-01", "user") + again = provisioner._directory_credential("benchmark-user-01", "user") + other = provisioner._directory_credential("benchmark-bot-01", "bot") + + assert first.nostr_secret_key == again.nostr_secret_key + assert first.nostr_pubkey == again.nostr_pubkey + assert first.nostr_pubkey != other.nostr_pubkey + assert first.role == "user" and other.role == "bot" + assert json.loads(first.nostr_auth_tag)[2] == "" + + +def test_seed_directory_has_50_users_10_bots_and_skips_existing(monkeypatch): + provisioner = BuzzTrialProvisioner(config()) + + class Observer: + def profiles(self, pubkeys): + return [{"pubkey": pubkeys[0]}] + + published = [] + + class Publisher: + def set_profile(self, name): + published.append(name) + + monkeypatch.setattr(provisioner, "_cli_for", lambda _credential: Publisher()) + + directory = provisioner._seed_directory("create-channel-invite-users", Observer()) + + assert len(directory) == 60 + assert sum(identity.role == "user" for identity in directory) == 50 + assert sum(identity.role == "bot" for identity in directory) == 10 + assert len(published) == 59 + assert directory[0].name not in published + + def test_lock_key_is_deterministic_and_distinct(): calls: list[int] = [] diff --git a/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/threaded.json b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/threaded.json new file mode 100644 index 0000000000..9f7d045ddd --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/threaded.json @@ -0,0 +1,32 @@ +{ + "channel_id": "6a178caa-07de-4594-9296-1f130b3f32e2", + "message_count": 2, + "truncated": false, + "messages": [ + { + "id": "585eeddbd9c1384696a615faeafdca5c00ff806276c8c6c66b654e7bbb40e167", + "author": "user", + "pubkey": "779c3f730c638f67cc06c7e7d55d31720c85c9eb74cec2050d0ac5fdabaafea8", + "content": "@solo-1 Complete the requested task.", + "created_at": 1786995079, + "kind": 9, + "tags": [ + ["h", "6a178caa-07de-4594-9296-1f130b3f32e2"], + ["p", "ed8ce3ee42988114b5940d9dd0023d576649e06c47bc018ef74933b2472e4854"] + ] + }, + { + "id": "220c6ac96cf28a995cc14ff8b72d5ec7d968c0a859eeaa0d2ec354670def60e5", + "author": "solo-1", + "pubkey": "ed8ce3ee42988114b5940d9dd0023d576649e06c47bc018ef74933b2472e4854", + "content": "DONE: Completed the requested task.", + "created_at": 1786995113, + "kind": 9, + "tags": [ + ["h", "6a178caa-07de-4594-9296-1f130b3f32e2"], + ["e", "585eeddbd9c1384696a615faeafdca5c00ff806276c8c6c66b654e7bbb40e167", "", "reply"], + ["p", "779c3f730c638f67cc06c7e7d55d31720c85c9eb74cec2050d0ac5fdabaafea8"] + ] + } + ] +} diff --git a/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/top-level.json b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/top-level.json new file mode 100644 index 0000000000..a00d5da78b --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/top-level.json @@ -0,0 +1,30 @@ +{ + "channel_id": "91879837-0cae-4c42-ab42-b01fe6ff8f35", + "message_count": 2, + "truncated": false, + "messages": [ + { + "id": "774c8ea010c6cdf4b6cbd667463d5a62371de00ac8913af190b8988b48f2b230", + "author": "user", + "pubkey": "d56c715eb650f6e880851b3f37e9a742f29436a085e925d80c6d0904a397fb0f", + "content": "@solo-1 Complete the requested task.", + "created_at": 1786993144, + "kind": 9, + "tags": [ + ["h", "91879837-0cae-4c42-ab42-b01fe6ff8f35"], + ["p", "40ed137e6e725207a63905cbbdbfe99497c28aa16eec8efbeeff49145ad575c8"] + ] + }, + { + "id": "943fefebec06504c99a48f704e77fe246cd81a0e90f5e3d6a1d073554a812c0d", + "author": "solo-1", + "pubkey": "40ed137e6e725207a63905cbbdbfe99497c28aa16eec8efbeeff49145ad575c8", + "content": "DONE: Completed the requested task.", + "created_at": 1786993179, + "kind": 9, + "tags": [ + ["h", "91879837-0cae-4c42-ab42-b01fe6ff8f35"] + ] + } + ] +} diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py index b4aa11cf0d..c04bbf73ee 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py @@ -38,8 +38,10 @@ class Provisioner: def healthcheck(self): self.healthchecked = True - def create_trial(self, run_id, trial_id, manifest, channel_label=None): - self.created = (run_id, trial_id, manifest, channel_label) + def create_trial( + self, run_id, trial_id, manifest, channel_label=None, task_name=None + ): + self.created = (run_id, trial_id, manifest, channel_label, task_name) return TrialHandle( run_id, trial_id, @@ -92,6 +94,7 @@ async def test_agent_lifecycle_and_context(tmp_path, manifest_data): assert provisioner.created[:2] == ("run-1", str(context_id)) # The task short name labels the trial channel for spectator GUIs. assert provisioner.created[3] == "hello-world" + assert provisioner.created[4] == "hello-world" assert provisioner.torn_down.channel_id == "channel-1" assert runtime.called["instruction"] == "solve it" assert ( diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index 07739c0ce1..11927134dc 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -4,6 +4,7 @@ import asyncio import hashlib import json import re +from dataclasses import replace from pathlib import Path import pytest @@ -23,6 +24,7 @@ from harbor_buzz_orchestra.container_runtime import ( ) from harbor_buzz_orchestra.manifest import ExperimentManifest from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle +from harbor_buzz_orchestra.task_fixtures import fixture_for def write_manifest( @@ -195,6 +197,51 @@ def test_user_relay_url_prefers_host_view(tmp_path): ) # pre-v1.2 handles fall back to deriving http from the agents' ws view. assert rt._user_relay_url(trial_handle(())) == "http://host.docker.internal:3600" + + +async def test_collects_task_declared_channel_membership(tmp_path, monkeypatch): + rt = runtime(tmp_path) + trial = replace( + trial_handle((credential("orch-1", "orchestrator", "orch-model"),)), + task_name="create-channel-invite-users", + ) + calls = [] + + async def buzz_json(credential_arg, trial_arg, *args): + calls.append((credential_arg, trial_arg, args)) + if args[:2] == ("channels", "search"): + return [ + { + "channel_id": "created-channel", + "name": "fix-pr-1234", + "channel_type": "stream", + "visibility": "private", + "archived": False, + "ttl_seconds": 3600, + } + ] + return [{"pubkey": "member", "role": "member"}] + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + + observed = await rt._collect_observed_channels(trial) + + assert observed[0]["members"] == [{"pubkey": "member", "role": "member"}] + assert calls[0][0].agent_id == "orch-1" + assert calls[0][2] == ( + "channels", + "search", + "--query", + "fix-pr-1234", + "--exact", + "--include-archived", + ) + assert calls[1][2] == ( + "channels", + "members", + "--channel", + "created-channel", + ) with pytest.raises(RuntimeLaunchError, match="ws://"): rt._cli_relay_url("http://relay") diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py new file mode 100644 index 0000000000..7910ab221b --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py @@ -0,0 +1,117 @@ +import copy +import importlib.util +from pathlib import Path + +from harbor_buzz_orchestra.task_fixtures import TARGET_BOTS, TARGET_USERS, fixture_for + +# The Buzz-native tasks are a sibling dataset of this harness package. +DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" +VERIFIER = DATASET_ROOT / "create-channel-invite-users" / "tests" / "verify.py" +SPEC = importlib.util.spec_from_file_location("create_channel_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +ORCHESTRATOR = "a" * 64 + + +def _evidence() -> dict: + fixture = fixture_for("create-channel-invite-users") + directory = [ + {"name": entry.name, "role": entry.role, "pubkey": f"{index:064x}"} + for index, entry in enumerate(fixture.directory, start=1) + ] + by_name = {entry["name"]: entry for entry in directory} + members = [{"pubkey": ORCHESTRATOR, "role": "owner"}] + members += [ + {"pubkey": by_name[name]["pubkey"], "role": "member"} for name in TARGET_USERS + ] + members += [ + {"pubkey": by_name[name]["pubkey"], "role": "bot"} for name in TARGET_BOTS + ] + return { + "schema_version": 1, + "task_name": "create-channel-invite-users", + "identities": {"solo-1": {"role": "orchestrator", "pubkey": ORCHESTRATOR}}, + "directory": directory, + "observed_channels": [ + { + "channel_id": "channel-1", + "name": "fix-pr-1234", + "channel_type": "stream", + "visibility": "private", + "archived": False, + "ttl_seconds": 3600, + "members": members, + } + ], + } + + +def test_exact_temporary_channel_and_roster_passes(): + metrics, details = verifier.score_evidence(_evidence()) + + assert all(value == 1.0 for value in metrics.values()) + assert details["channel_id"] == "channel-1" + + +def test_extra_member_fails_exact_membership(): + evidence = _evidence() + evidence["observed_channels"][0]["members"].append( + {"pubkey": "f" * 64, "role": "member"} + ) + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["channel_created"] == 1.0 + assert metrics["exact_membership"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_wrong_bot_role_fails_roles(): + evidence = _evidence() + bot_pubkey = next( + row["pubkey"] for row in evidence["directory"] if row["name"] == TARGET_BOTS[0] + ) + member = next( + row + for row in evidence["observed_channels"][0]["members"] + if row["pubkey"] == bot_pubkey + ) + member["role"] = "member" + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["exact_membership"] == 1.0 + assert metrics["expected_roles"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_permanent_channel_fails_temporary_requirement(): + evidence = _evidence() + evidence["observed_channels"][0]["ttl_seconds"] = None + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["temporary_channel"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_duplicate_exact_name_fails_channel_creation(): + evidence = _evidence() + evidence["observed_channels"].append( + copy.deepcopy(evidence["observed_channels"][0]) + ) + + metrics, details = verifier.score_evidence(evidence) + + assert details["matching_channel_count"] == 2 + assert metrics["channel_created"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence(None) + + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py new file mode 100644 index 0000000000..3e67f43a3b --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py @@ -0,0 +1,129 @@ +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import ( + AgentCredential, + DirectoryIdentity, + TrialHandle, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key=f"secret-{agent_id}", + nostr_pubkey=pubkey, + nostr_auth_tag=f"auth-{agent_id}", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _load(name: str) -> dict: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def _trial(transcript: dict) -> TrialHandle: + user_message, agent_message = transcript["messages"] + return TrialHandle( + run_id="run-1", + trial_id="trial-1", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", agent_message["pubkey"]),), + user=_credential("user", "user", user_message["pubkey"]), + ) + + +def _evidence(name: str) -> dict: + transcript = _load(name) + return build_buzz_evidence( + trial=_trial(transcript), + messages=list(reversed(transcript["messages"])), + task_event_id=transcript["messages"][0]["id"], + completion_message_id=transcript["messages"][-1]["id"], + transcript_limit=1000, + ) + + +def test_normalizes_real_threaded_transcript_and_preserves_protocol_tags(): + evidence = _evidence("threaded.json") + + assert evidence["schema_version"] == 1 + assert evidence["message_count"] == 2 + assert evidence["messages"][0]["author_role"] == "user" + reply = evidence["messages"][-1] + assert reply["author"] == "solo-1" + assert reply["author_role"] == "orchestrator" + assert reply["channel_id"] == evidence["trial"]["channel_id"] + assert reply["reply_to_event_id"] == evidence["task_event_id"] + assert ["e", evidence["task_event_id"], "", "reply"] in reply["tags"] + + +def test_top_level_agent_message_has_no_derived_reply_destination(): + evidence = _evidence("top-level.json") + + assert evidence["messages"][-1]["reply_to_event_id"] is None + + +def test_malformed_messages_are_safe_and_secrets_are_never_exported(): + transcript = _load("threaded.json") + trial = _trial(transcript) + evidence = build_buzz_evidence( + trial=trial, + messages=[None, {"id": "broken", "tags": ["bad", ["e", 7]]}], + task_event_id=None, + completion_message_id=None, + transcript_limit=1, + ) + + assert evidence["message_count"] == 1 + assert evidence["messages"][0]["tags"] == [] + assert evidence["truncated"] is True + encoded = json.dumps(evidence) + assert "nostr_secret_key" not in encoded + assert "auth-user" not in encoded + assert "secret-solo-1" not in encoded + + +def test_exports_only_public_directory_and_observed_channel_state(): + transcript = _load("threaded.json") + trial = _trial(transcript) + trial = TrialHandle( + **{ + field: getattr(trial, field) + for field in ( + "run_id", + "trial_id", + "manifest_hash", + "relay_ws_url", + "channel_id", + "credentials", + "user", + "user_relay_url", + ) + }, + task_name="create-channel-invite-users", + directory=(DirectoryIdentity("benchmark-user-01", "user", "d" * 64),), + ) + channels = [{"name": "fix-pr-1234", "members": []}] + + evidence = build_buzz_evidence( + trial=trial, + messages=[], + task_event_id=None, + completion_message_id=None, + transcript_limit=1000, + observed_channels=channels, + ) + + assert evidence["task_name"] == "create-channel-invite-users" + assert evidence["directory"] == [ + {"name": "benchmark-user-01", "role": "user", "pubkey": "d" * 64} + ] + assert evidence["observed_channels"] == channels diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py new file mode 100644 index 0000000000..d87a30e422 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py @@ -0,0 +1,142 @@ +import importlib.util +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle + +# The Buzz-native tasks are a sibling dataset of this harness package. +DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" +VERIFIER = DATASET_ROOT / "read-named-path-outside-workspace" / "tests" / "verify.py" +SPEC = importlib.util.spec_from_file_location("named_path_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +CHECK_ID = "chc-unit-test-42" +ACTION = "Recommend compaction when remaining context is low." + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key="secret", + nostr_pubkey=pubkey, + nostr_auth_tag="auth", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _evidence(content: str) -> dict: + transcript = json.loads((FIXTURES / "top-level.json").read_text(encoding="utf-8")) + root, answer = transcript["messages"] + answer["content"] = content + trial = TrialHandle( + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", answer["pubkey"]),), + user=_credential("user", "user", root["pubkey"]), + task_name="read-named-path-outside-workspace", + ) + return build_buzz_evidence( + trial=trial, + messages=transcript["messages"], + task_event_id=root["id"], + completion_message_id=None, + transcript_limit=1000, + ) + + +def _score(content: str): + return verifier.score_evidence( + _evidence(content), expected_check_id=CHECK_ID, expected_action=ACTION + ) + + +def test_exact_marker_and_action_pass(): + metrics, details = _score(f"{CHECK_ID}: {ACTION}") + + assert all(value == 1.0 for value in metrics.values()) + assert details["expected_check_id"] == CHECK_ID + + +def test_refusal_phrasing_does_not_sink_a_correct_answer(): + # The marker is generated at image build time, so quoting it proves the + # file was read — and that is the only question this task asks. Hedged + # phrasing around a correct answer is not scored. + metrics, _ = _score(f"I won't read that path. {CHECK_ID}: {ACTION}") + + assert metrics["named_path_read"] == 1.0 + assert metrics["action_reported"] == 1.0 + assert metrics["reward"] == 1.0 + + +def test_actual_refusal_scores_zero(): + # A real refusal fails on the substance, with no refusal regex involved: + # it cannot produce the build-time marker or the ACTION line. + metrics, _ = _score("I cannot read files outside the workspace.") + + assert metrics["named_path_read"] == 0.0 + assert metrics["action_reported"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_reworded_action_line_still_passes(): + metrics, _ = _score( + f"CHECK_ID is {CHECK_ID} and ACTION is " + '"Recommend compaction when\n remaining context is low"' + ) + + assert metrics["action_reported"] == 1.0 + assert metrics["reward"] == 1.0 + + +def test_missing_marker_fails_named_path_read(): + metrics, _ = _score(ACTION) + + assert metrics["named_path_read"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_wrong_action_fails_action_report(): + metrics, _ = _score(f"{CHECK_ID}: Ignore context health.") + + assert metrics["named_path_read"] == 1.0 + assert metrics["action_reported"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_load_expectations_reads_fixture_fields(tmp_path): + skill_file = tmp_path / "SKILL.md" + skill_file.write_text( + f"# Fixture\nCHECK_ID: {CHECK_ID}\nACTION: {ACTION}\n", encoding="utf-8" + ) + + assert verifier.load_expectations(skill_file) == (CHECK_ID, ACTION) + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence( + None, expected_check_id=CHECK_ID, expected_action=ACTION + ) + + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details + + +def test_missing_final_message_fails_closed(): + evidence = _evidence(f"{CHECK_ID}: {ACTION}") + evidence["messages"].pop() + evidence["message_count"] -= 1 + + metrics, _ = verifier.score_evidence( + evidence, expected_check_id=CHECK_ID, expected_action=ACTION + ) + + assert all(value == 0.0 for value in metrics.values()) diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py new file mode 100644 index 0000000000..12f4d43352 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py @@ -0,0 +1,163 @@ +import copy +import importlib.util +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle + +# The Buzz-native tasks are a sibling dataset of this harness package. +DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" +VERIFIER = DATASET_ROOT / "reply-to-thread" / "tests" / "verify.py" +SPEC = importlib.util.spec_from_file_location("reply_to_thread_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +CORRECT_ANSWER = ( + "Month 6 revenue: $160,811; month 6 expenses: $84,462; " + "cumulative operating profit: $374,470." +) + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key="secret", + nostr_pubkey=pubkey, + nostr_auth_tag="auth", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _evidence(fixture: str) -> dict: + transcript = json.loads((FIXTURES / fixture).read_text(encoding="utf-8")) + root, answer = transcript["messages"] + trial = TrialHandle( + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", answer["pubkey"]),), + user=_credential("user", "user", root["pubkey"]), + ) + answer["content"] = CORRECT_ANSWER + return build_buzz_evidence( + trial=trial, + messages=transcript["messages"], + task_event_id=root["id"], + completion_message_id=None, + transcript_limit=1000, + ) + + +def test_correct_answer_in_direct_thread_reply_passes(): + metrics, details = verifier.score_evidence(_evidence("threaded.json")) + + assert metrics == { + "reward": 1.0, + "answer_correct": 1.0, + "reply_to_thread": 1.0, + "same_channel": 1.0, + "expected_author": 1.0, + "evidence_complete": 1.0, + } + assert details["selected_message_id"] is not None + + +def test_correct_top_level_answer_fails_only_threading_and_reward(): + metrics, _ = verifier.score_evidence(_evidence("top-level.json")) + + assert metrics["answer_correct"] == 1.0 + assert metrics["reply_to_thread"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_wrong_answer_in_correct_thread_fails_correctness(): + evidence = _evidence("threaded.json") + evidence["messages"][-1]["content"] = "Revenue $1, expenses $2, profit $3" + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["reply_to_thread"] == 1.0 + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_work_showing_table_with_a_wrong_stated_answer_fails(): + # The month-6 rows are right, so an unanchored scan of every number in the + # message would score this 1.0 despite the stated total being wrong. + evidence = _evidence("threaded.json") + evidence["messages"][-1]["content"] = ( + "Month 5: 153,153 / 82,806\n" + "Month 6: 160,811 / 84,462\n" + "Cumulative operating profit: $412,900" + ) + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_labelled_multiline_answer_passes(): + evidence = _evidence("threaded.json") + evidence["messages"][-1]["content"] = ( + "- Month 6 revenue: $160,811\n" + "- Month 6 expenses: $84,462\n" + "- Cumulative operating profit (months 1-6): $374,470" + ) + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["answer_correct"] == 1.0 + assert metrics["reward"] == 1.0 + + +def test_reply_to_unrelated_event_fails_threading(): + evidence = _evidence("threaded.json") + answer = evidence["messages"][-1] + answer["reply_to_event_id"] = "unrelated" + answer["tags"] = [ + ["h", evidence["trial"]["channel_id"]], + ["e", "unrelated", "", "reply"], + ] + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["answer_correct"] == 1.0 + assert metrics["reply_to_thread"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_latest_agent_message_is_the_final_message_being_scored(): + evidence = _evidence("threaded.json") + later = copy.deepcopy(evidence["messages"][-1]) + later.update( + { + "id": "later-top-level", + "created_at": later["created_at"] + 1, + "reply_to_event_id": None, + "tags": [["h", evidence["trial"]["channel_id"]]], + } + ) + evidence["messages"].append(later) + evidence["message_count"] += 1 + + metrics, details = verifier.score_evidence(evidence) + + assert details["selected_message_id"] == "later-top-level" + assert metrics["reply_to_thread"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence(None) + + assert metrics["reward"] == 0.0 + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_user_mention_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_user_mention_verifier.py new file mode 100644 index 0000000000..dc5e3f4746 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_user_mention_verifier.py @@ -0,0 +1,97 @@ +import importlib.util +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle + +# The Buzz-native tasks are a sibling dataset of this harness package. +DATASET_ROOT = Path(__file__).resolve().parents[2] / "buzz-dataset" +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" +VERIFIER = DATASET_ROOT / "user-mention" / "tests" / "verify.py" +SPEC = importlib.util.spec_from_file_location("user_mention_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +CORRECT_ANSWER = "The annual cost is $5,328." + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key="secret", + nostr_pubkey=pubkey, + nostr_auth_tag="auth", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _evidence(fixture: str, content: str = CORRECT_ANSWER) -> dict: + transcript = json.loads((FIXTURES / fixture).read_text(encoding="utf-8")) + root, answer = transcript["messages"] + answer["content"] = content + trial = TrialHandle( + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", answer["pubkey"]),), + user=_credential("John Vincent Doe", "user", root["pubkey"]), + task_name="user-mention", + ) + return build_buzz_evidence( + trial=trial, + messages=transcript["messages"], + task_event_id=root["id"], + completion_message_id=None, + transcript_limit=1000, + ) + + +def test_correct_answer_with_user_p_tag_passes(): + metrics, details = verifier.score_evidence(_evidence("threaded.json")) + + assert all(value == 1.0 for value in metrics.values()) + assert details["user_display_name"] == "John Vincent Doe" + + +def test_answer_text_without_p_tag_fails_delivery_mention(): + evidence = _evidence( + "top-level.json", "@John Vincent Doe, the annual cost is $5,328." + ) + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["answer_correct"] == 1.0 + assert metrics["user_p_tagged"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_p_tag_without_visible_display_name_passes(): + evidence = _evidence("threaded.json") + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["user_p_tagged"] == 1.0 + assert metrics["reward"] == 1.0 + + +def test_wrong_answer_fails_correctness_only(): + evidence = _evidence("threaded.json", "The annual cost is $1.") + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["user_p_tagged"] == 1.0 + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence(None) + + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details diff --git a/bin/.lefthookrc b/bin/.lefthookrc new file mode 100755 index 0000000000..f3b0be9e79 --- /dev/null +++ b/bin/.lefthookrc @@ -0,0 +1,21 @@ +# Sourced by the generated .git/hooks/* dispatchers (see `rc:` in lefthook.yml) +# before their $LEFTHOOK_BIN-first lookup. Two jobs, both anchored on the repo +# root so they hold regardless of the hook's working dir: +# 1. Pin dispatch to the Hermit-managed lefthook (bin/lefthook -> +# .lefthook-2.1.3.pkg) so a push from any worktree runs the pinned version +# even when a newer lefthook is on PATH (e.g. Homebrew). +# 2. Prepend the Hermit bin/ to PATH so every lane subprocess (just mobile-check +# -> flutter/dart, etc.) resolves the repo's pinned toolchain, not whatever +# the invoking shell had first (e.g. Homebrew flutter). This is the safe +# subset of `activate-hermit`: a plain PATH prepend, no interactive-shell +# machinery. It makes the hook self-pinning regardless of shell setup. +_lefthook_root="$(git rev-parse --show-toplevel 2>/dev/null)" +if [ -n "$_lefthook_root" ] && [ -d "$_lefthook_root/bin" ]; then + PATH="$_lefthook_root/bin:$PATH" + export PATH + if [ -x "$_lefthook_root/bin/lefthook" ]; then + LEFTHOOK_BIN="$_lefthook_root/bin/lefthook" + export LEFTHOOK_BIN + fi +fi +unset _lefthook_root diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index fa3a565d49..b6d5a21dcb 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2244,6 +2244,28 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option Option { + let arr = result["configOptions"].as_array()?; + for opt in arr { + if opt.get("category").and_then(|c| c.as_str()) == Some("thought_level") { + let config_id = opt + .get("configId") + .or_else(|| opt.get("id")) + .and_then(|v| v.as_str())?; + return Some(config_id.to_string()); + } + } + None +} + /// Match a desired model ID against a fresh `session/new` response. /// /// Returns the correct ACP method to call, or `None` if no match. @@ -2868,6 +2890,54 @@ mod tests { assert!(super::extract_model_state(&result).is_none()); } + #[test] + fn extract_thought_level_config_id_finds_config_id() { + let result = serde_json::json!({ + "sessionId": "sess-1", + "configOptions": [ + { "configId": "model", "category": "model" }, + { + "configId": "effort", + "category": "thought_level", + "options": [{ "value": "high" }, { "value": "low" }] + } + ] + }); + assert_eq!( + super::extract_thought_level_config_id(&result).as_deref(), + Some("effort") + ); + } + + #[test] + fn extract_thought_level_config_id_falls_back_to_id_key() { + let result = serde_json::json!({ + "configOptions": [ + { "id": "effort", "category": "thought_level" } + ] + }); + assert_eq!( + super::extract_thought_level_config_id(&result).as_deref(), + Some("effort") + ); + } + + #[test] + fn extract_thought_level_config_id_none_without_category() { + let result = serde_json::json!({ + "configOptions": [ + { "configId": "model", "category": "model" } + ] + }); + assert!(super::extract_thought_level_config_id(&result).is_none()); + } + + #[test] + fn extract_thought_level_config_id_none_without_config_options() { + let result = serde_json::json!({ "sessionId": "sess-1" }); + assert!(super::extract_thought_level_config_id(&result).is_none()); + } + #[test] fn resolve_prefers_stable_over_unstable() { let result = serde_json::json!({ diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 8750dfc32e..39458fc282 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -37,13 +37,9 @@ To assign an issue to someone, run `buzz issues assign --issue --repo ## Conversational Agent Creation -When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. +When someone asks to create an agent, ask for at most two things: its name and what it should do day-to-day. Write the `--system-prompt` yourself. Do not ask about runtime, provider, model, credentials, environment variables, or access unless the request is genuinely ambiguous. -`buzz agents draft-create --channel --display-name --system-prompt ` - -Use the channel UUID from `[Context]`. Do not ask about runtime, provider, model, credentials, environment variables, or access: Buzz Desktop resolves local runtime/provider/model defaults and new agents default to owner-only access. The command only opens a reviewable draft in the owner's Desktop; never claim the agent exists until the owner saves it. - -For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. Draft updates also require owner review and save. +Open an owner-reviewed draft with `buzz agents draft-create --channel --display-name --system-prompt `, using the UUID from `[Context]`. Never claim the agent exists until the owner saves it. For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. ## Communication Patterns diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index f9e7bf1ed8..5244ef5537 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -124,6 +124,11 @@ pub enum PermissionMode { /// Agent default — permission requests per tool call. #[value(alias = "default")] Default, + /// Auto mode — fully autonomous execution; model-gated (requires a model + /// that supports `supportsAutoMode`). Degrades gracefully to `default` + /// when the session's active model does not support it. + #[value(alias = "auto")] + Auto, /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, @@ -144,6 +149,7 @@ impl PermissionMode { pub fn as_wire_str(&self) -> &'static str { match self { Self::Default => "default", + Self::Auto => "auto", Self::AcceptEdits => "acceptEdits", Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", @@ -423,6 +429,14 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MODEL")] pub model: Option, + /// Persisted effort level value (e.g. "high", "medium", "low") to apply via + /// `session/set_config_option` at the first session creation. The configId is + /// resolved from the adapter's advertised `thought_level` capability — not + /// hardcoded. Non-fatal: if the adapter does not advertise `thought_level`, + /// the value is silently ignored and the persisted effort is not overwritten. + #[arg(long, env = "BUZZ_ACP_EFFORT_LEVEL")] + pub effort_level: Option, + /// Title for the agent's ACP sessions, passed out-of-band in `session/new` /// `_meta`. Adapters that recognize it name the session after this value; /// others ignore it. Never enters the prompt. @@ -540,6 +554,12 @@ pub struct Config { pub memory_enabled: bool, /// Desired LLM model ID. Applied after every `session_new_full()`. pub model: Option, + /// Persisted effort level value (e.g. "high", "medium", "low"). Held as a + /// per-worker spawn-scoped value and applied at the first session creation + /// by pairing with the adapter's advertised `thought_level` configId. + /// Non-fatal when absent or when the adapter does not advertise + /// `thought_level`. + pub effort_level: Option, /// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`. /// `None` when unset or when the configured value sanitized to empty. pub session_title: Option, @@ -1105,6 +1125,7 @@ impl Config { typing_enabled: !args.no_typing, memory_enabled: args.memory && !args.no_memory, model, + effort_level: args.effort_level, session_title: args .session_title .as_deref() @@ -1480,6 +1501,7 @@ mod tests { typing_enabled: true, memory_enabled: true, model: None, + effort_level: None, session_title: None, permission_mode: PermissionMode::BypassPermissions, respond_to: RespondTo::Anyone, @@ -2298,6 +2320,7 @@ channels = "ALL" #[test] fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); + assert_eq!(PermissionMode::Auto.as_wire_str(), "auto"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); assert_eq!( PermissionMode::BypassPermissions.as_wire_str(), @@ -2310,12 +2333,24 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); + assert!(!PermissionMode::Auto.is_default()); assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); } + #[test] + fn test_permission_mode_auto_degrades_to_default_when_unsupported() { + // The wire string is "auto" — the adapter handles graceful downgrade + // to "default" when the active model does not support Auto mode. + // Verify only that the wire string is correct and distinct from "default". + let auto = PermissionMode::Auto; + assert_eq!(auto.as_wire_str(), "auto"); + assert_ne!(auto.as_wire_str(), "default"); + assert!(!auto.is_default()); + } + #[test] fn test_permission_mode_display() { assert_eq!( @@ -2323,6 +2358,7 @@ channels = "ALL" "bypassPermissions" ); assert_eq!(format!("{}", PermissionMode::Default), "default"); + assert_eq!(format!("{}", PermissionMode::Auto), "auto"); } #[test] @@ -2360,6 +2396,7 @@ channels = "ALL" use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), + ("auto", PermissionMode::Auto), ("accept-edits", PermissionMode::AcceptEdits), ("bypass-permissions", PermissionMode::BypassPermissions), ("dont-ask", PermissionMode::DontAsk), @@ -2382,6 +2419,7 @@ channels = "ALL" use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), + ("auto", PermissionMode::Auto), ("acceptEdits", PermissionMode::AcceptEdits), ("bypassPermissions", PermissionMode::BypassPermissions), ("dontAsk", PermissionMode::DontAsk), diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0f5cce3b8f..b170c70999 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1336,6 +1336,13 @@ fn handle_switch_model_control( tracing::warn!("observer switch_model control frame missing modelId"); return; }; + // Opaque per-pick correlator, echoed on every result frame so the Desktop + // can ignore a replayed result for an earlier pick. Optional: absent on + // older Desktop clients, in which case the frames simply carry no id. + let request_id = payload + .get("requestId") + .and_then(|value| value.as_str()) + .map(str::to_string); // A turn is in flight for this channel iff a task_map entry exists. The // agent is moved out of the pool during a turn, so the control oneshot is @@ -1352,7 +1359,10 @@ fn handle_switch_model_control( if signal_in_flight_task( pool, channel_id, - ControlSignal::SwitchModel(model_id.to_string()), + ControlSignal::SwitchModel { + model_id: model_id.to_string(), + request_id: request_id.clone(), + }, ) { "sent" } else { @@ -1360,7 +1370,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) { + match pool.switch_idle_agent_model(channel_id, model_id, request_id.clone()) { IdleSwitchResult::Switched => "switched", IdleSwitchResult::UnsupportedModel => "unsupported_model", IdleSwitchResult::NoIdleAgent => "no_active_turn", @@ -1381,6 +1391,9 @@ fn handle_switch_model_control( "type": "switch_model", "status": status, "modelId": model_id, + // Echo the correlator on the immediate ack so a `sent` / + // `turn_ending` / idle-path terminal frame matches the pick. + "requestId": request_id, }), ); } @@ -2025,19 +2038,6 @@ async fn tokio_main() -> Result<()> { } let owner_cache = OwnerCache::new(startup_owner.clone()); - // Relay `self` pubkey (NIP-11), used to recognize relay-signed workflow - // messages in the inbound author gate. Best-effort: `None` simply means - // workflow messages get no attributed-author exemption (pre-fix behavior), - // so a fetch failure degrades gracefully instead of blocking startup. - let relay_self: Option = relay.rest_client().fetch_relay_self().await; - match &relay_self { - Some(pk) => tracing::info!("relay self pubkey: {pk}"), - None => tracing::warn!( - "relay self pubkey unavailable (NIP-11 fetch failed or no stable relay key) — \ - relay-signed workflow messages will be dropped by the author gate" - ), - } - let mut relay_observer_control_rx = None; let mut relay_observer_publisher_task = None; let mut relay_observer_publisher = None; @@ -2478,6 +2478,9 @@ async fn tokio_main() -> Result<()> { model_capabilities: None, desired_model: config.model.clone(), model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: config.effort_level.clone(), agent_name, goose_system_prompt_supported: None, protocol_version, @@ -2849,31 +2852,7 @@ async fn tokio_main() -> Result<()> { // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. { - // Relay-signed workflow messages (workflow - // `send_message` actions) are authored by the - // relay keypair, not the workflow owner — the - // plain author gate would drop them and the - // scheduled @mention would silently never wake - // the agent. Gate them on their *attributed* - // author (the `buzz:workflow-owner` tag — the - // pubkey that created the workflow) instead. - // See `workflow_attributed_author` - // for the recognition + trust argument. - let author = match workflow_attributed_author( - &buzz_event.event, - relay_self.as_deref(), - ) { - Some(attributed) => { - tracing::debug!( - channel_id = %buzz_event.channel_id, - relay_author = %buzz_event.event.pubkey.to_hex(), - attributed_author = %attributed, - "relay-signed workflow message — gating on attributed author" - ); - attributed - } - None => buzz_event.event.pubkey.to_hex(), - }; + 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. @@ -3554,90 +3533,6 @@ fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool { }) } -/// If `event` is a relay-signed workflow message, return its *attributed* -/// author for inbound author gating; otherwise `None`. -/// -/// Workflow `send_message` actions are signed by the **relay keypair** -/// (`event.pubkey` = the relay's NIP-11 `self` key), not by the human who owns -/// the workflow — so the plain author gate would drop them even though they -/// carry `p` tags meant to wake mentioned agents. The relay attributes the -/// message to the **workflow owner** (the pubkey that created the workflow, -/// `workflow.owner_pubkey` relay-side) via the explicit `buzz:workflow-owner` -/// tag emitted by `workflow_sink.rs`, and it has already verified that owner's -/// access to the destination channel before emitting the event. -/// -/// Recognition requires ALL of the following, failing closed otherwise: -/// 1. kind `9` (stream message) — the only kind the workflow sink emits; -/// 2. a known, syntactically valid relay `self` pubkey (fetched from NIP-11 -/// at startup) — no `relay_self`, no exemption; -/// 3. `event.pubkey` == relay `self`, with a **valid event signature** -/// verified here. The relay verifies signatures on submission, but this -/// gate re-checks locally so the exemption never rests on an upstream -/// guarantee it can't see; -/// 4. **exactly one** tag exactly equal to `["buzz:workflow", "true"]` — no -/// duplicates, no extra fields, no other value; -/// 5. **exactly one** tag exactly equal to `["buzz:workflow-owner", ]` -/// where the owner parses as a full pubkey — no duplicates, no extra -/// fields. Mention `p` tags are never used for attribution, so who is -/// @mentioned in the message text has no bearing on whose authority the -/// gate evaluates. -/// -/// The returned pubkey is gated exactly like a direct author: owner/sibling -/// under `owner-only`, plus the explicit list under `allowlist`. A workflow -/// owned by a random channel member therefore still cannot wake an -/// owner-only agent. -fn workflow_attributed_author(event: &nostr::Event, relay_self: Option<&str>) -> Option { - // 1. Kind gate first — cheapest check, and everything below only makes - // sense for the kind:9 messages the workflow sink emits. - if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE { - return None; - } - - // 2. Relay identity must be known AND syntactically valid. - let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?; - if event.pubkey != relay_self { - return None; - } - - // 4. Exactly one marker tag, exactly ["buzz:workflow", "true"]. Collect - // every tag with the marker key so duplicates or shape/value mismatches - // (extra fields, wrong value) disqualify instead of being skipped over. - let markers: Vec<&[String]> = event - .tags - .iter() - .map(|t| t.as_slice()) - .filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow")) - .collect(); - if markers.len() != 1 || markers[0] != ["buzz:workflow", "true"] { - return None; - } - - // 5. Exactly one owner tag, exactly ["buzz:workflow-owner", ]. - // The owner must parse as a full pubkey — not merely look hex-ish — - // before it is fed into the owner/sibling/allowlist comparison. - let owners: Vec<&[String]> = event - .tags - .iter() - .map(|t| t.as_slice()) - .filter(|s| s.first().map(|k| k.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()?; - - // 3. Signature check last — it is the most expensive step, so only pay - // for it once every structural requirement has already passed. - if event.verify().is_err() { - return None; - } - - Some(owner.to_hex()) -} - fn is_owner_control_command( event: &nostr::Event, kind_u32: u32, @@ -4706,6 +4601,7 @@ struct PoolStartup { extra_env: Vec<(String, String)>, has_generated_codex_config: bool, model: Option, + effort_level: Option, observer: Option, } @@ -4718,6 +4614,7 @@ impl PoolStartup { extra_env: config.persona_env_vars.clone(), has_generated_codex_config: config.has_generated_codex_config, model: config.model.clone(), + effort_level: config.effort_level.clone(), observer, } } @@ -4785,6 +4682,9 @@ async fn initialize_agent_pool( model_capabilities: None, desired_model: startup.model.clone(), model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: startup.effort_level.clone(), agent_name, goose_system_prompt_supported: None, protocol_version, @@ -5799,273 +5699,6 @@ mod author_gate_tests { } } -#[cfg(test)] -mod workflow_attributed_author_tests { - use super::*; - use nostr::{EventBuilder, Keys, Kind, Tag}; - - /// Build a kind:9 event signed by `signer` with the given extra tags. - fn make_event(signer: &Keys, tags: Vec) -> nostr::Event { - EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "wake up") - .tags(tags) - .sign_with_keys(signer) - .expect("sign test event") - } - - fn workflow_tags(owner_hex: &str, mention_hex: &str) -> Vec { - vec![ - Tag::parse(["p", owner_hex]).unwrap(), - Tag::parse(["h", "3204e3f9-fd09-4e95-b749-76966794c287"]).unwrap(), - Tag::parse(["buzz:workflow", "true"]).unwrap(), - Tag::parse(["buzz:workflow-owner", owner_hex]).unwrap(), - Tag::parse(["p", mention_hex]).unwrap(), - ] - } - - #[test] - fn relay_signed_workflow_message_attributes_to_workflow_owner_tag() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let agent = Keys::generate().public_key().to_hex(); - let event = make_event(&relay, workflow_tags(&owner, &agent)); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - Some(owner), - "a relay-signed buzz:workflow event must attribute to the \ - buzz:workflow-owner tag, not any mentioned agent" - ); - } - - #[test] - fn attribution_ignores_p_tags_entirely() { - // Only the explicit buzz:workflow-owner tag attributes; p tags - // (owner attribution + mentions) must have no effect on the gate. - let relay = Keys::generate(); - let someone = Keys::generate().public_key().to_hex(); - let event = make_event( - &relay, - vec![ - Tag::parse(["p", &someone]).unwrap(), - Tag::parse(["buzz:workflow", "true"]).unwrap(), - ], - ); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "without a buzz:workflow-owner tag there is no attributed author, \ - even when p tags are present" - ); - } - - #[test] - fn malformed_owner_tag_value_attributes_to_no_one() { - let relay = Keys::generate(); - let event = make_event( - &relay, - vec![ - Tag::parse(["buzz:workflow", "true"]).unwrap(), - Tag::parse(["buzz:workflow-owner", "not-a-pubkey"]).unwrap(), - ], - ); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "a buzz:workflow-owner value that is not 64-hex must be rejected" - ); - } - - #[test] - fn no_relay_self_means_no_exemption() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let agent = Keys::generate().public_key().to_hex(); - let event = make_event(&relay, workflow_tags(&owner, &agent)); - assert_eq!( - workflow_attributed_author(&event, None), - None, - "without a known relay self pubkey the exemption must not apply (fail closed)" - ); - } - - #[test] - fn non_relay_author_gets_no_exemption_even_with_workflow_tag() { - // A member forging the buzz:workflow tag on their own event must not - // be able to attribute it to someone else via a p tag. - let forger = Keys::generate(); - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let agent = Keys::generate().public_key().to_hex(); - let event = make_event(&forger, workflow_tags(&owner, &agent)); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "a buzz:workflow tag on a non-relay-signed event must be ignored" - ); - } - - #[test] - fn relay_signed_message_without_workflow_tag_gets_no_exemption() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let event = make_event(&relay, vec![Tag::parse(["p", &owner]).unwrap()]); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "relay-signed events without the buzz:workflow tag keep the plain author gate" - ); - } - - #[test] - fn workflow_message_without_owner_tag_attributes_to_no_one() { - let relay = Keys::generate(); - let event = make_event(&relay, vec![Tag::parse(["buzz:workflow", "true"]).unwrap()]); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "a workflow message with no buzz:workflow-owner tag has no attributed \ - author and must fall through to the plain (relay-pubkey) author gate" - ); - } - - #[test] - fn duplicate_marker_tags_disqualify() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let event = make_event( - &relay, - vec![ - Tag::parse(["buzz:workflow", "true"]).unwrap(), - Tag::parse(["buzz:workflow", "true"]).unwrap(), - Tag::parse(["buzz:workflow-owner", &owner]).unwrap(), - ], - ); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "more than one buzz:workflow marker tag must fail closed" - ); - } - - #[test] - fn marker_value_mismatch_disqualifies() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - for bad_marker in [ - Tag::parse(["buzz:workflow", "false"]).unwrap(), - Tag::parse(["buzz:workflow"]).unwrap(), - Tag::parse(["buzz:workflow", "true", "extra"]).unwrap(), - ] { - let event = make_event( - &relay, - vec![ - bad_marker.clone(), - Tag::parse(["buzz:workflow-owner", &owner]).unwrap(), - ], - ); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "marker tag {:?} is not exactly [\"buzz:workflow\", \"true\"] and must fail closed", - bad_marker.as_slice() - ); - } - } - - #[test] - fn duplicate_owner_tags_disqualify() { - // Two owner tags — even with identical values — are ambiguous - // provenance and must not attribute to anyone. - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let other = Keys::generate().public_key().to_hex(); - for second_owner in [&owner, &other] { - let event = make_event( - &relay, - vec![ - Tag::parse(["buzz:workflow", "true"]).unwrap(), - Tag::parse(["buzz:workflow-owner", &owner]).unwrap(), - Tag::parse(["buzz:workflow-owner", second_owner]).unwrap(), - ], - ); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "duplicate buzz:workflow-owner tags must fail closed" - ); - } - } - - #[test] - fn owner_tag_with_extra_fields_disqualifies() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let event = make_event( - &relay, - vec![ - Tag::parse(["buzz:workflow", "true"]).unwrap(), - Tag::parse(["buzz:workflow-owner", &owner, "extra"]).unwrap(), - ], - ); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "an owner tag with extra fields is not the exact shape the relay \ - emits and must fail closed" - ); - } - - #[test] - fn wrong_kind_disqualifies() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let agent = Keys::generate().public_key().to_hex(); - let event = EventBuilder::new(Kind::from(1u16), "wake up") - .tags(workflow_tags(&owner, &agent)) - .sign_with_keys(&relay) - .expect("sign test event"); - assert_eq!( - workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), - None, - "only kind:9 stream messages may use the workflow exemption" - ); - } - - #[test] - fn tampered_event_fails_signature_check() { - // Alter the content after signing: pubkey still matches relay_self - // and the tags are pristine, but the signature no longer covers the - // event — the local verify must reject it. - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let agent = Keys::generate().public_key().to_hex(); - let event = make_event(&relay, workflow_tags(&owner, &agent)); - let mut json = serde_json::to_value(&event).expect("event to JSON"); - json["content"] = serde_json::Value::String("tampered".into()); - let tampered: nostr::Event = serde_json::from_value(json).expect("tampered event parses"); - assert_eq!( - workflow_attributed_author(&tampered, Some(&relay.public_key().to_hex())), - None, - "a tampered event must fail the local signature check" - ); - } - - #[test] - fn syntactically_invalid_relay_self_means_no_exemption() { - let relay = Keys::generate(); - let owner = Keys::generate().public_key().to_hex(); - let agent = Keys::generate().public_key().to_hex(); - let event = make_event(&relay, workflow_tags(&owner, &agent)); - let long_not_hex = "zz".repeat(32); - for bad_self in ["", "not-hex", long_not_hex.as_str()] { - assert_eq!( - workflow_attributed_author(&event, Some(bad_self)), - None, - "an invalid NIP-11 self value {bad_self:?} must disable the exemption" - ); - } - } -} - #[cfg(test)] mod observer_snapshot_race_tests { use super::*; @@ -7144,6 +6777,7 @@ mod build_mcp_servers_tests { typing_enabled: true, memory_enabled: false, model: None, + effort_level: None, session_title: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, @@ -7367,6 +7001,7 @@ mod error_outcome_emission_tests { typing_enabled: true, memory_enabled: false, model: None, + effort_level: None, session_title: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, @@ -7413,6 +7048,9 @@ mod error_outcome_emission_tests { model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, // Error branches under test never read this; 1 is the legacy diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 5e1e960e0d..db6dfce29f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -30,9 +30,9 @@ use tokio::time::timeout; use uuid::Uuid; use crate::acp::{ - extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, - StopReason, SystemPromptTransport, + extract_model_config_options, extract_model_state, extract_thought_level_config_id, + model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, + ModelSwitchMethod, StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -88,6 +88,12 @@ pub struct AgentModelCapabilities { pub config_options_raw: Vec, /// Unstable: SessionModelState from session/new. pub available_models_raw: Option, + /// B5: configId for the `thought_level` category option, if the adapter + /// advertised one in session/new. Resolved at session time so the + /// spawn-scoped effort application forwards the adapter's real configId + /// instead of hardcoding it. `None` when the adapter advertises no + /// `thought_level` option. + pub thought_level_config_id: Option, } /// Successful deliveries associated with one live channel session. @@ -203,6 +209,28 @@ pub struct OwnedAgent { /// desktop reader to distinguish a genuine runtime override from a stale /// session whose persona model was edited. Reset on spawn/restart. pub model_overridden: bool, + /// Opaque per-pick `request_id` from the live `SwitchModel` that set + /// `desired_model`, echoed on the late `control_result` frame so the + /// Desktop ModelPicker can correlate it to the pick that fired the switch. + /// `None` for config/persona-derived models (no live pick to correlate). + pub desired_model_request_id: Option, + /// True when a busy-path live switch is awaiting its deferred apply: the + /// switch was delivered to an in-flight turn (`sent` ack), the turn was + /// cancelled+requeued, and the real apply runs at the next session. On that + /// apply, `create_session_and_apply_model` emits a positive terminal + /// `control_result` (success) so the Desktop learns the outcome instead of + /// inferring it from timeout silence. The idle path never sets this — it + /// already emits its terminal immediately — so this gate prevents a + /// double-emit there. Consumed (reset) at apply time. + pub desired_model_pending_ack: bool, + /// Persisted startup effort value from `BUZZ_ACP_EFFORT_LEVEL` (carried from + /// the Desktop record via `Config.effort_level`). Held per-worker and applied + /// once, at the first session creation, by pairing with the adapter's + /// advertised `thought_level` configId. This is spawn-scoped only — there is + /// no pool-level effort state and no live mid-conversation effort switching. + /// Non-fatal when absent or when the adapter does not advertise + /// `thought_level`. + pub startup_effort: Option, /// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`). pub agent_name: String, /// Whether Goose accepted its custom system-prompt method. `None` probes on @@ -304,7 +332,7 @@ fn apply_completed_before_control_signal( // the fresh session applies the new model on its next creation. if matches!( control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ControlSignal::Rotate | ControlSignal::SwitchModel { .. } ) { state.invalidate(source); } @@ -312,7 +340,7 @@ fn apply_completed_before_control_signal( /// Control signal for an in-flight channel turn. /// -/// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when +/// Not `Copy`: `SwitchModel` carries owned `String`s. Callers must clone when /// a value is needed after a move, or match by reference. #[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlSignal { @@ -335,7 +363,14 @@ pub enum ControlSignal { /// setting `OwnedAgent::desired_model` before invalidation; the requeued /// turn re-creates the session and re-applies `desired_model`. Runtime-only /// — never persisted, gone on restart/respawn. - SwitchModel(String), + /// + /// Carries `(model_id, request_id)`: the opaque per-pick `request_id` + /// originates in the Desktop ModelPicker and is echoed on every + /// `control_result` frame so a replayed result cannot settle a later pick. + SwitchModel { + model_id: String, + request_id: Option, + }, } /// Goose-native non-cancelling steer request, sent from the main loop to an @@ -844,6 +879,7 @@ impl AgentPool { &mut self, channel_id: Uuid, model_id: &str, + request_id: Option, ) -> IdleSwitchResult { let Some(agent) = self .agents @@ -868,6 +904,9 @@ impl AgentPool { agent.desired_model = Some(model_id.to_string()); agent.model_overridden = true; + // 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); IdleSwitchResult::Switched } @@ -1046,17 +1085,94 @@ async fn create_session_and_apply_model( agent.model_capabilities = Some(AgentModelCapabilities { config_options_raw: extract_model_config_options(&resp.raw), available_models_raw: extract_model_state(&resp.raw), + thought_level_config_id: extract_thought_level_config_id(&resp.raw), }); } - // Apply desired_model if set, matching against the fresh session/new response. - // Track whether the switch succeeded so session_config_captured reflects - // the post-switch state (not the pre-switch desired state). - let switch_succeeded = if let Some(ref desired) = agent.desired_model { + // Apply desired_model if set, matching against the fresh session/new + // response. `post_switch_snapshot` drives everything downstream: + // `Some(value)` → a switch applied; `value` is the adapter's post-switch + // RPC response, whose `configOptions` describe the target + // model. Effort resolution and the Desktop capture both + // read it so they converge on the model the session is + // actually running, not the pre-switch default. + // `None` → no switch, or the adapter rejected/does-not-know the + // model; the session/new snapshot is cached as-is and + // `switch_succeeded` stays false. + let post_switch_snapshot: Option = if let Some(ref desired) = + agent.desired_model + { + // Consume the busy-path pending-ack once for this apply: only the + // `Applied` arm turns it into a positive terminal; the rejection and + // unsupported arms already emit their own correlated failure frame, so + // taking it here keeps a leftover flag from firing a spurious success + // on some later unrelated session. + let pending_ack = std::mem::take(&mut agent.desired_model_pending_ack); match resolve_model_switch_method(&resp.raw, desired) { Some(method) => { - apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; - true + match apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await? + { + ModelSwitchOutcome::Applied(switch_result) => { + // The adapter rebuilds `session.configOptions` for the + // target model and echoes them here. Refresh capabilities + // from that authoritative snapshot when present so the + // idle-switch guard and the panel reflect the target + // model; drop to `None` (re-derive next session) when the + // adapter returned no options so a pre-switch snapshot is + // never mistaken for the target model's. + if switch_result + .get("configOptions") + .is_some_and(|v| !v.is_null()) + { + agent.model_capabilities = Some(AgentModelCapabilities { + config_options_raw: extract_model_config_options(&switch_result), + available_models_raw: extract_model_state(&switch_result), + thought_level_config_id: extract_thought_level_config_id( + &switch_result, + ), + }); + } else { + agent.model_capabilities = None; + } + // Busy-path deferred switch: emit a positive terminal so + // the Desktop confirms success from a real frame instead + // of inferring it from timeout silence. Gated on the + // pending-ack flag so the idle path (which already acked + // `switched` immediately) does not double-emit. + if pending_ack { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "switched", + "modelId": desired, + "requestId": agent.desired_model_request_id, + }), + ); + } + Some(switch_result) + } + ModelSwitchOutcome::Rejected => { + // The adapter explicitly rejected the switch: the session + // is still on its default model. Surface a terminal + // failure so the Desktop ModelPicker rejects the live pick + // instead of falsely reporting success, and preserve the + // pre-switch capabilities the session is really running. + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "failure", + "modelId": desired, + // Echo the pick's request_id so the Desktop can + // correlate this late frame to the operation + // that fired it, and ignore replayed results. + "requestId": agent.desired_model_request_id, + }), + ); + None + } + } } None => { tracing::warn!( @@ -1073,26 +1189,64 @@ async fn create_session_and_apply_model( "type": "switch_model", "status": "unsupported_model", "modelId": desired, + // Echo the pick's request_id (see the failure arm). + "requestId": agent.desired_model_request_id, }), ); - false + None } } } else { - false + None }; + let switch_succeeded = post_switch_snapshot.is_some(); + + // Apply the worker's spawn-scoped startup effort, if configured and the + // running model advertises a `thought_level` option. Runs on every session + // creation (config options are per-session), mirroring the model-switch + // application above. The held value comes from `BUZZ_ACP_EFFORT_LEVEL` and + // never mutates — there is no pool-level effort state and no live switching. + // Reads the post-switch snapshot so the configId is discovered on the model + // the session is actually running; computed BEFORE the capture emission so + // the cached configOptions tell the truth about the running session. + let effort_snapshot = post_switch_snapshot.as_ref().unwrap_or(&resp.raw); + let effort_outcome = apply_startup_effort(agent, effort_snapshot, &resp.session_id).await?; // Emit session config for desktop consumption (config bridge tier 1b). // Emitted AFTER desired_model resolution so the desktop caches the // post-switch state. modelOverridden reflects whether the switch actually - // applied — false on the unsupported arm so the panel doesn't show a - // stale override badge. + // applied — false on the rejected/unsupported arms so the panel doesn't show + // a stale override badge. + // + // configOptions come from the post-switch snapshot on a successful switch + // (the target model's option set) and the session/new snapshot otherwise. + // Truthful capture: after a successful effort application the snapshot still + // carries the pre-set `currentValue`, so patch the applied option to the + // value the session is actually running. A rejected effort or a model with + // no `thought_level` option leaves the snapshot untouched. + let config_options_for_cache = { + let mut opts = effort_snapshot + .get("configOptions") + .cloned() + .unwrap_or(serde_json::Value::Null); + if let Some(StartupEffortOutcome::Applied { config_id, value }) = &effort_outcome { + patch_config_option_current_value(&mut opts, config_id, value); + } + opts + }; agent.acp.observe( "session_config_captured", serde_json::json!({ - "configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null), + "configOptions": config_options_for_cache, "modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null), - "models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null), + // `models` must come from the SAME snapshot as configOptions — the + // post-switch snapshot on a successful switch, session/new otherwise. + // Taking it from `resp.raw` here would emit the target model's option + // set alongside the pre-switch model identity, so the desktop panel + // would report the old model as live after an applied switch. When a + // successful target response omits `models`, this emits Null rather + // than falling back to the pre-switch `resp.raw.models`. + "models": effort_snapshot.get("models").cloned().unwrap_or(serde_json::Value::Null), "modelOverridden": agent.model_overridden && switch_succeeded, // Pair identity for the desktop session-config cache, which is // keyed by (agent, relay) like the lifecycle frames. @@ -1141,18 +1295,35 @@ fn mcp_servers_with_git_origin( servers } +/// Outcome of a live model-switch RPC returned by [`apply_model_switch`]. +/// +/// `Applied` and `Rejected` are distinct outcomes and must not be collapsed: +/// the caller needs to know whether the session is now on the target model +/// before deciding what capabilities to cache and whether to surface a failure. +#[derive(Debug)] +enum ModelSwitchOutcome { + /// The adapter accepted the switch. Carries the RPC response value, which + /// may include refreshed `configOptions` for the target model. + Applied(serde_json::Value), + /// The adapter returned an application-level error (e.g. JSON error, + /// unrecognised model). The session is still on its default model; + /// pre-switch capabilities must be preserved. + Rejected, +} + /// Send the appropriate ACP model-switch request with a timeout. /// -/// On timeout or error, logs a warning and returns — the caller proceeds -/// with the agent's default model. This is intentionally non-fatal: a stale -/// response from a timed-out request is safely ignored by `read_until_response` -/// (non-matching JSON-RPC IDs are skipped). +/// Transport-class errors propagate as `Err` so the caller respawns the agent +/// rather than reuse a poisoned stdio stream. An application-level rejection is +/// non-fatal but distinct from success: it returns [`ModelSwitchOutcome::Rejected`] +/// so the caller preserves pre-switch capabilities and tells Desktop the pick +/// failed instead of silently claiming the switch landed. async fn apply_model_switch( acp: &mut AcpClient, session_id: &str, desired: &str, method: &ModelSwitchMethod, -) -> Result<(), AcpError> { +) -> Result { let method_label = match method { ModelSwitchMethod::ConfigOption { config_id, .. } => { format!("configOption (configId={config_id})") @@ -1177,11 +1348,15 @@ async fn apply_model_switch( .await; match result { - Ok(Ok(_)) => { + // Return the RPC result so the caller can consume the post-switch + // capability snapshot the adapter echoes (claude-agent-acp rebuilds + // `session.configOptions` on a model change and returns them here). + Ok(Ok(value)) => { tracing::info!( target: "pool::model", "applied model {desired} via {method_label} on session {session_id}" ); + Ok(ModelSwitchOutcome::Applied(value)) } // Transport-class errors may have corrupted the stdio stream — propagate // so the caller can respawn the agent instead of reusing a poisoned one. @@ -1194,14 +1369,18 @@ async fn apply_model_switch( target: "pool::model", "fatal error setting model {desired} via {method_label}: {e}" ); - return Err(e); + Err(e) } - // Application-level errors (Json, etc.) — agent is fine, just uses default model. + // Application-level errors (Json, etc.) — the adapter explicitly + // rejected the switch; the session is still on its default model. + // Distinct from a successful switch that returned no configOptions: + // the caller must preserve pre-switch capabilities here. Ok(Err(e)) => { tracing::warn!( target: "pool::model", "failed to set model {desired} via {method_label}: {e} — proceeding with agent default" ); + Ok(ModelSwitchOutcome::Rejected) } Err(_) => { // Outer timeout fired — the inner send_request may have left the @@ -1210,10 +1389,123 @@ async fn apply_model_switch( target: "pool::model", "model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal" ); - return Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)); + Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)) + } + } +} + +/// Outcome of applying a worker's spawn-scoped startup effort at session creation. +/// +/// Drives truthful capture: only `Applied` patches the cached `currentValue`. +/// `Rejected` (adapter refused) and the `None` return (model advertises no +/// `thought_level` option, or no effort was configured) leave the session/new +/// snapshot untouched so the panel reflects the session's real state. +enum StartupEffortOutcome { + Applied { config_id: String, value: String }, + Rejected, +} + +/// Apply the worker's held `startup_effort` via `session/set_config_option`, if +/// set and the current model advertises a `thought_level` option. +/// +/// Returns `Ok(None)` when there is nothing to apply (no configured effort, or +/// the model has no `thought_level` option) or `Ok(Some(_))` describing whether +/// the adapter accepted the value. Transport-class errors propagate as `Err` so +/// the caller respawns the worker rather than reuse a poisoned stream — mirroring +/// [`apply_model_switch`]'s classification. Application-level rejection is +/// non-fatal: the session proceeds on the model's default effort. +async fn apply_startup_effort( + agent: &mut OwnedAgent, + session_new_result: &serde_json::Value, + session_id: &str, +) -> Result, AcpError> { + let Some(value) = agent.startup_effort.clone() else { + return Ok(None); + }; + let Some(config_id) = extract_thought_level_config_id(session_new_result) else { + tracing::info!( + target: "pool::effort", + "startup effort {value} configured but model advertises no thought_level option — leaving agent default" + ); + return Ok(None); + }; + + let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async { + agent + .acp + .session_set_config_option(session_id, &config_id, &value) + .await + }) + .await; + + match result { + Ok(Ok(_)) => { + tracing::info!( + target: "pool::effort", + "applied startup effort {value} via configId={config_id} on session {session_id}" + ); + Ok(Some(StartupEffortOutcome::Applied { config_id, value })) + } + // Transport-class errors may have corrupted the stdio stream — propagate + // so the caller can respawn the agent instead of reusing a poisoned one. + Ok(Err(e @ AcpError::Io(_))) + | Ok(Err(e @ AcpError::WriteTimeout(_))) + | Ok(Err(e @ AcpError::Timeout(_))) + | Ok(Err(e @ AcpError::Protocol(_))) + | Ok(Err(e @ AcpError::AgentExited)) => { + tracing::error!( + target: "pool::effort", + "fatal error applying startup effort {value} via configId={config_id}: {e}" + ); + Err(e) + } + // Application-level rejection (e.g. Json) — agent is fine, uses default effort. + Ok(Err(e)) => { + tracing::warn!( + target: "pool::effort", + "adapter rejected startup effort {value} via configId={config_id}: {e} — proceeding with agent default" + ); + Ok(Some(StartupEffortOutcome::Rejected)) + } + Err(_) => { + // Outer timeout fired — the inner send_request may have left the + // stream in an unknown state. Treat as transport error. + tracing::error!( + target: "pool::effort", + "startup effort {value} via configId={config_id} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal" + ); + Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)) + } + } +} + +/// Patch the `currentValue` of the configOption whose `configId`/`id` matches +/// `config_id` in a session/new `configOptions` array, in place. +/// +/// Used by truthful capture: a successful `session/set_config_option` is not +/// reflected in the original session/new snapshot, so the accepted value is +/// written back before the snapshot is cached. A no-op when `options` is not an +/// array or no entry matches (the id came from the same array, so a match is +/// expected in practice). +fn patch_config_option_current_value( + options: &mut serde_json::Value, + config_id: &str, + value: &str, +) { + let Some(arr) = options.as_array_mut() else { + return; + }; + for opt in arr { + let matches = opt + .get("configId") + .or_else(|| opt.get("id")) + .and_then(|v| v.as_str()) + == Some(config_id); + if matches { + opt["currentValue"] = serde_json::Value::String(value.to_string()); + return; } } - Ok(()) } /// Set the session permission mode via `session/set_config_option`. @@ -1367,8 +1659,8 @@ fn workspace_section(cwd: &str) -> Option { "[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \ files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \ `OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \ - here. This is where you already are; do not search `$HOME` or other \ - directories for them." + here. This is where you already are, so start here rather than scanning \ + `$HOME`. Any specific path the user names is fine to read." )) } else { None @@ -2218,9 +2510,15 @@ pub async fn run_prompt_task( // `desired_model` here means the fresh session created by the // requeued turn (busy) or the next turn (already-completed) // applies the new model. Runtime-only — never persisted. - if let ControlSignal::SwitchModel(ref model_id) = control_signal { + if let ControlSignal::SwitchModel { model_id, request_id } = &control_signal { agent.desired_model = Some(model_id.clone()); agent.model_overridden = true; + agent.desired_model_request_id = request_id.clone(); + // Busy path: the real apply is deferred to the requeued + // session. Arm the positive-terminal emit so that apply + // reports success explicitly rather than the Desktop + // inferring it from timeout silence. + agent.desired_model_pending_ack = true; } // Control signal received. Guard against Race 1: the turn may // have completed naturally just as cancel fired. @@ -2311,7 +2609,7 @@ pub async fn run_prompt_task( // MUST send a PromptResult or the main loop deadlocks. if matches!( control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ControlSignal::Rotate | ControlSignal::SwitchModel { .. } ) { tracing::debug!( target: "pool::prompt", @@ -3693,7 +3991,7 @@ fn requeue_cancelled_batch( ) -> Option { let reason = match signal { ControlSignal::Steer => CancelReason::Steer, - ControlSignal::Interrupt | ControlSignal::SwitchModel(_) => CancelReason::Interrupt, + ControlSignal::Interrupt | ControlSignal::SwitchModel { .. } => CancelReason::Interrupt, // Cancel/Rotate discard the batch — no merged re-prompt. ControlSignal::Cancel | ControlSignal::Rotate => return None, }; @@ -4413,6 +4711,40 @@ mod tests { } } + // MINOR (#2884): the permission-mode RPC is gated on agent_supports_mode. + // An advertised mode issues set_config_option; an absent one is skipped so + // the harness falls back to per-tool auto-approval. Pin both edges directly. + #[test] + fn agent_supports_mode_advertised_auto_is_true() { + let session_new = json!({ + "modes": { "availableModes": [{ "id": "default" }, { "id": "auto" }] } + }); + assert!(agent_supports_mode( + &session_new, + PermissionMode::Auto.as_wire_str() + )); + } + + #[test] + fn agent_supports_mode_absent_auto_is_false() { + let session_new = json!({ + "modes": { "availableModes": [{ "id": "default" }] } + }); + assert!(!agent_supports_mode( + &session_new, + PermissionMode::Auto.as_wire_str() + )); + } + + #[test] + fn agent_supports_mode_missing_modes_field_is_false() { + let session_new = json!({ "sessionId": "sess-1" }); + assert!(!agent_supports_mode( + &session_new, + PermissionMode::Auto.as_wire_str() + )); + } + #[test] fn public_session_forwards_channel_origin_to_mcp() { let channel_id = Uuid::new_v4(); @@ -5653,6 +5985,9 @@ done"# model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -5747,6 +6082,9 @@ done"# model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -5919,6 +6257,9 @@ done"# model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -6069,6 +6410,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -6482,7 +6826,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, &PromptSource::Channel(ch_a), - &ControlSignal::SwitchModel("gpt-5".into()), + &ControlSignal::SwitchModel { + model_id: "gpt-5".into(), + request_id: None, + }, ); assert!(!s.has_channel_state(&ch_a)); @@ -6522,7 +6869,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" (ControlSignal::Steer, Some(CancelReason::Steer)), (ControlSignal::Interrupt, Some(CancelReason::Interrupt)), ( - ControlSignal::SwitchModel("gpt-5".into()), + ControlSignal::SwitchModel { + model_id: "gpt-5".into(), + request_id: None, + }, Some(CancelReason::Interrupt), ), (ControlSignal::Cancel, None), @@ -6638,7 +6988,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Case { name: "CancelDrainTimeout + SwitchModel preserves batch with Interrupt reason", error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), - signal: ControlSignal::SwitchModel("gpt-5".to_string()), + signal: ControlSignal::SwitchModel { + model_id: "gpt-5".to_string(), + request_id: None, + }, expected_outcome: "CancelDrainTimeout", batch_preserved: true, expected_reason: Some(CancelReason::Interrupt), @@ -7056,6 +7409,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, @@ -7114,6 +7470,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, @@ -7549,7 +7908,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" ); } - fn make_prompt_context_no_owner() -> PromptContext { + pub(super) fn make_prompt_context_no_owner() -> PromptContext { let agent_keys = nostr::Keys::generate(); make_prompt_context_impl(&agent_keys, None) } @@ -8141,3 +8500,805 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" server.abort(); } } + +#[cfg(test)] +mod startup_effort_tests { + use super::*; + use crate::acp::AcpClient; + use tests::make_prompt_context_no_owner; + + /// Build a protocol-v2, non-goose agent whose only ACP requests will be + /// `session/new` (id 0) then the startup-effort `session/set_config_option` + /// (id 1). `startup_effort` is the held spawn-scoped value under test. + fn effort_agent(acp: AcpClient, startup_effort: Option<&str>) -> OwnedAgent { + 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: startup_effort.map(str::to_string), + agent_name: "effort-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + /// Spawn a scripted ACP that answers `session/new` (request #1) with the + /// given configOptions, then replies to the effort `set_config_option` + /// (request #2) with `effort_reply` (a JSON-RPC `result`/`error` body, minus + /// the id which is filled in). Any later request gets `{"ok":true}`. + async fn spawn_effort_acp(session_new_config_options: &str, effort_reply: &str) -> AcpClient { + let script = format!( + r#"count=0 +while IFS= read -r line; do + count=$((count + 1)) + id=$((count - 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_config_options}}}}}' + elif [ "$count" -eq 2 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{effort_reply}}}' + else + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}' + fi +done"# + ); + AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn effort ACP script") + } + + fn captured_config_options(obs: &observer::ObserverHandle) -> serde_json::Value { + obs.snapshot() + .into_iter() + .find(|e| e.kind == "session_config_captured") + .expect("session_config_captured emitted") + .payload["configOptions"] + .clone() + } + + fn effort_current_value(options: &serde_json::Value) -> Option { + options + .as_array()? + .iter() + .find(|o| o["category"] == "thought_level") + .and_then(|o| o["currentValue"].as_str()) + .map(str::to_string) + } + + const OPTS_WITH_EFFORT_DEFAULT_LOW: &str = r#"[{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]"#; + + #[tokio::test] + async fn test_applied_effort_patches_captured_current_value_to_high() { + let acp = spawn_effort_acp(OPTS_WITH_EFFORT_DEFAULT_LOW, r#""result":{"ok":true}"#).await; + let mut agent = effort_agent(acp, Some("high")); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let opts = captured_config_options(&obs); + assert_eq!( + effort_current_value(&opts).as_deref(), + Some("high"), + "applied effort must overwrite the pre-set currentValue in the capture" + ); + } + + #[tokio::test] + async fn test_rejected_effort_retains_captured_current_value() { + // Adapter answers the effort set with a JSON-RPC error → AgentError → + // application-level rejection: non-fatal, capture keeps the default. + let acp = spawn_effort_acp( + OPTS_WITH_EFFORT_DEFAULT_LOW, + r#""error":{"code":-32602,"message":"unsupported effort value"}"#, + ) + .await; + let mut agent = effort_agent(acp, Some("high")); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("rejection is non-fatal; session creation still succeeds"); + + let opts = captured_config_options(&obs); + assert_eq!( + effort_current_value(&opts).as_deref(), + Some("low"), + "a rejected effort must not falsify the capture — keep the running value" + ); + } + + #[tokio::test] + async fn test_no_thought_level_model_leaves_capture_unpatched() { + // Model advertises only a `model` option — no thought_level. The held + // effort is silently ignored and no set_config_option is sent. + let opts_no_effort = r#"[{"configId":"model","category":"model","currentValue":"m-a","options":[{"value":"m-a"}]}]"#; + let acp = spawn_effort_acp(opts_no_effort, r#""result":{"ok":true}"#).await; + let mut agent = effort_agent(acp, Some("high")); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let opts = captured_config_options(&obs); + assert_eq!( + opts, + serde_json::from_str::(opts_no_effort).unwrap(), + "no thought_level option → capture is the untouched session/new snapshot" + ); + } + + #[tokio::test] + async fn test_no_startup_effort_leaves_capture_unpatched() { + // No held effort at all: the set_config_option is never sent and the + // default currentValue survives into the capture. + let acp = spawn_effort_acp(OPTS_WITH_EFFORT_DEFAULT_LOW, r#""result":{"ok":true}"#).await; + let mut agent = effort_agent(acp, None); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let opts = captured_config_options(&obs); + assert_eq!( + effort_current_value(&opts).as_deref(), + Some("low"), + "with no configured effort the capture reflects the model default" + ); + } + + #[tokio::test] + async fn test_transport_error_on_effort_propagates_for_respawn() { + // Adapter exits after answering session/new but before the effort set → + // AgentExited (transport class) → Err so the caller respawns the worker + // instead of reusing a possibly-poisoned stream. + let script = format!( + r#"IFS= read -r _new +printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{OPTS_WITH_EFFORT_DEFAULT_LOW}}}}}' +IFS= read -r _effort +exit 0"# + ); + let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn transport-exit ACP script"); + let mut agent = effort_agent(acp, Some("high")); + + let ctx = make_prompt_context_no_owner(); + let err = create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect_err("transport-class effort failure must propagate as Err"); + assert!( + matches!(err, AcpError::AgentExited | AcpError::Io(_)), + "process exit mid-effort is a transport error, got {err:?}" + ); + } + + #[test] + fn test_patch_config_option_current_value_matches_by_id_key() { + // The `id` key (claude-agent-acp) must also match, not just `configId`. + let mut opts = serde_json::json!([ + { "id": "effort", "category": "thought_level", "currentValue": "low" } + ]); + patch_config_option_current_value(&mut opts, "effort", "high"); + assert_eq!(opts[0]["currentValue"], "high"); + } + + #[test] + fn test_patch_config_option_current_value_noop_on_non_array() { + let mut opts = serde_json::Value::Null; + patch_config_option_current_value(&mut opts, "effort", "high"); + assert!(opts.is_null(), "a null snapshot must stay null"); + } +} + +#[cfg(test)] +mod model_switch_tests { + use super::*; + use crate::acp::AcpClient; + use tests::make_prompt_context_no_owner; + + /// A protocol-v2 agent with a live `desired_model` override and no startup + /// effort. `model_overridden` is set so the capture's `modelOverridden` + /// reflects only whether the switch actually landed. + fn switching_agent(acp: AcpClient, desired_model: &str) -> OwnedAgent { + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: Some(desired_model.to_string()), + model_overridden: true, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "switch-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + /// Scripted ACP: `session/new` (request #1) returns `session_new_options`, + /// then the model-switch `set_config_option` (request #2) replies with + /// `switch_reply` (a JSON-RPC `result`/`error` body minus the id). Any later + /// request gets `{"ok":true}`. + async fn spawn_switch_acp(session_new_options: &str, switch_reply: &str) -> AcpClient { + let script = format!( + r#"count=0 +while IFS= read -r line; do + count=$((count + 1)) + id=$((count - 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_options}}}}}' + elif [ "$count" -eq 2 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}' + else + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}' + fi +done"# + ); + AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn switch ACP script") + } + + fn capture(obs: &observer::ObserverHandle) -> serde_json::Value { + obs.snapshot() + .into_iter() + .find(|e| e.kind == "session_config_captured") + .expect("session_config_captured emitted") + .payload + } + + fn control_results(obs: &observer::ObserverHandle) -> Vec { + obs.snapshot() + .into_iter() + .filter(|e| e.kind == "control_result") + .map(|e| e.payload) + .collect() + } + + // A `model`-category option offering the default model plus the target the + // 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 test_applied_switch_refreshes_capabilities_from_post_switch_snapshot() { + // The adapter accepts the switch and echoes the target model's rebuilt + // configOptions — including a thought_level option the default model + // never advertised. Capabilities and the capture must reflect the target + // model, not the pre-switch default. + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"medium","options":[{"value":"low"},{"value":"medium"}]}]}"#; + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, switch_reply).await; + let mut agent = switching_agent(acp, "model-b"); + // Busy path: this switch was delivered to an in-flight turn and its apply + // is deferred to this requeued session. Arm the pending-ack and carry the + // pick's correlator so the Applied arm emits a correlated positive + // terminal instead of leaving the Desktop to infer success from silence. + agent.desired_model_pending_ack = true; + agent.desired_model_request_id = Some("req-busy-1".into()); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let caps = agent + .model_capabilities + .as_ref() + .expect("capabilities refreshed from the post-switch snapshot"); + assert_eq!( + caps.thought_level_config_id.as_deref(), + Some("effort"), + "the target model's thought_level option must be discovered post-switch" + ); + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], true, + "an applied switch must report modelOverridden true" + ); + assert!( + cap["configOptions"] + .as_array() + .is_some_and(|a| a.iter().any(|o| o["category"] == "thought_level")), + "the cached configOptions must be the target model's post-switch set" + ); + // The deferred apply must emit exactly one correlated positive terminal + // so the Desktop learns success from a real frame, not timeout silence. + let results = control_results(&obs); + assert_eq!( + results.len(), + 1, + "a busy-path applied switch emits exactly one positive terminal" + ); + assert_eq!(results[0]["status"], "switched"); + assert_eq!(results[0]["modelId"], "model-b"); + assert_eq!( + results[0]["requestId"], "req-busy-1", + "the positive terminal must carry the pick's correlator" + ); + assert!( + !agent.desired_model_pending_ack, + "the pending-ack is consumed once so it cannot re-fire on a later session" + ); + } + + #[tokio::test] + async fn test_rejected_switch_preserves_capabilities_and_emits_failure() { + // The adapter refuses the switch with a JSON-RPC error. The session is + // still on its default model: pre-switch capabilities survive, the + // capture reports modelOverridden false, and a terminal `failure` + // control_result tells Desktop the pick did not land. + let acp = spawn_switch_acp( + OPTS_MODEL_A_AND_B, + r#""error":{"code":-32602,"message":"model not accepted"}"#, + ) + .await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("an application-level rejection is non-fatal"); + + let caps = agent + .model_capabilities + .as_ref() + .expect("pre-switch capabilities must be preserved on rejection"); + assert!( + caps.config_options_raw + .iter() + .any(|o| o["currentValue"] == "model-a"), + "capabilities must still describe the default model the session runs" + ); + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], false, + "a rejected switch must not claim an override" + ); + let results = control_results(&obs); + assert_eq!(results.len(), 1, "exactly one control_result on rejection"); + assert_eq!(results[0]["status"], "failure"); + assert_eq!(results[0]["modelId"], "model-b"); + } + + #[tokio::test] + async fn test_busy_path_rejection_emits_only_failure_and_consumes_pending_ack() { + // K1 delayed-rejection at the Rust seam: a busy-path switch is armed + // (pending_ack), its apply is deferred to this requeued session, and the + // adapter then refuses it. The rejection arm must emit exactly one + // `failure` (no spurious positive `switched`) and consume the pending-ack + // so no later session can fire a phantom success. + let acp = spawn_switch_acp( + OPTS_MODEL_A_AND_B, + r#""error":{"code":-32602,"message":"model not accepted"}"#, + ) + .await; + let mut agent = switching_agent(acp, "model-b"); + agent.desired_model_pending_ack = true; + agent.desired_model_request_id = Some("req-busy-reject".into()); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("an application-level rejection is non-fatal"); + + let results = control_results(&obs); + assert_eq!( + results.len(), + 1, + "a busy-path rejection emits exactly one terminal — no phantom success" + ); + assert_eq!(results[0]["status"], "failure"); + assert_eq!(results[0]["requestId"], "req-busy-reject"); + assert!( + !agent.desired_model_pending_ack, + "the pending-ack is consumed even on rejection so it cannot re-fire" + ); + } + + #[tokio::test] + async fn test_applied_switch_without_options_drops_capabilities() { + // A successful switch whose response carries no configOptions (older + // adapter, or a model with no options): the pre-switch snapshot cannot + // be trusted for the target model, so capabilities drop to None to be + // re-derived on the next session — but the switch still counts as an + // override with no failure surfaced. + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{"ok":true}"#).await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + assert!( + agent.model_capabilities.is_none(), + "an optionless successful switch must drop stale capabilities" + ); + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], true, + "the switch still applied even with no echoed options" + ); + assert!( + control_results(&obs).is_empty(), + "a successful switch emits no failure control_result" + ); + } + + #[tokio::test] + async fn test_unsupported_model_emits_unsupported_without_switch_rpc() { + // The desired model is absent from the session/new catalog: no switch + // RPC is sent, the capture reports no override, and an + // `unsupported_model` control_result rejects the live pick. + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{"ok":true}"#).await; + let mut agent = switching_agent(acp, "model-z"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("an unresolvable model is non-fatal"); + + let cap = capture(&obs); + assert_eq!(cap["modelOverridden"], false); + let results = control_results(&obs); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["status"], "unsupported_model"); + assert_eq!(results[0]["modelId"], "model-z"); + } + + /// Scripted ACP whose `session/new` (request #1) returns a full result body + /// `session_new_result` (a JSON object minus the outer envelope), and whose + /// model-switch `set_config_option` (request #2) replies with `switch_reply` + /// (a JSON-RPC `result`/`error` body minus the id). Lets a test control the + /// `models` block in both the pre-switch and post-switch snapshots. + async fn spawn_switch_acp_full(session_new_result: &str, switch_reply: &str) -> AcpClient { + let script = format!( + r#"count=0 +while IFS= read -r line; do + count=$((count + 1)) + id=$((count - 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{session_new_result}}}' + elif [ "$count" -eq 2 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}' + else + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}' + fi +done"# + ); + AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn switch ACP script") + } + + /// F3: an applied switch must cache `models` from the POST-switch snapshot, + /// not the pre-switch `session/new` response. The pre-switch snapshot reports + /// the default model as current; the target response reports the target as + /// current. The emitted capture must carry the target's models block. The + /// Desktop-parsing half of this contract lives in `agent_config_tests.rs` + /// (`live_switch_models_from_post_switch_snapshot_parses_target_current`). + #[tokio::test] + async fn test_applied_switch_caches_target_model_not_pre_switch() { + // session/new: model-a is current. switch reply: model-b is current, + // and it echoes rebuilt configOptions so capabilities refresh cleanly. + let session_new = r#"{"sessionId":"sess-1","configOptions":[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-a","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#; + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-b","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#; + let acp = spawn_switch_acp_full(session_new, switch_reply).await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert_eq!( + cap["models"]["currentModelId"], "model-b", + "an applied switch must cache the target model, not the pre-switch model-a" + ); + } + + /// F3: an applied switch whose target response omits `models` must cache + /// Null — never fall back to the pre-switch `resp.raw.models`. Otherwise the + /// panel would report the pre-switch model as live after a successful switch. + #[tokio::test] + async fn test_applied_switch_without_models_does_not_leak_pre_switch_model() { + // session/new advertises model-a as current; the successful switch reply + // echoes configOptions (so the switch is Applied) but NO models block. + let session_new = r#"{"sessionId":"sess-1","configOptions":[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-a","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#; + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]}]}"#; + let acp = spawn_switch_acp_full(session_new, switch_reply).await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert!( + cap["models"].is_null(), + "an optionless-models successful switch must emit Null, not the pre-switch models" + ); + } + + /// Like `switching_agent` but also holds a spawn-scoped startup effort, so a + /// single session creation both switches the model AND applies startup + /// effort — the interaction F5.6 pins. + fn switching_agent_with_effort( + acp: AcpClient, + desired_model: &str, + startup_effort: &str, + ) -> OwnedAgent { + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: Some(desired_model.to_string()), + model_overridden: true, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: Some(startup_effort.to_string()), + agent_name: "switch-effort-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + fn effort_option_current_value(cap: &serde_json::Value) -> Option { + cap["configOptions"] + .as_array()? + .iter() + .find(|o| o["category"] == "thought_level") + .and_then(|o| o["currentValue"].as_str()) + .map(str::to_string) + } + + /// F5.6: startup effort resolves against the TARGET model's option set. The + /// pre-switch model-a advertises no `thought_level`; only the post-switch + /// model-b does. `apply_startup_effort` reads the post-switch snapshot, so + /// the held `high` applies against model-b's option and the cached + /// configOptions show it at `high`. Had it read the pre-switch snapshot the + /// effort would find no option and silently no-op. + #[tokio::test] + async fn test_startup_effort_resolves_against_post_switch_target_options() { + // session/new: model-a, model option only — NO thought_level. + let session_new = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#; + // switch reply: model-b current AND a target-only thought_level option. + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]}"#; + let acp = spawn_switch_acp(session_new, switch_reply).await; + let mut agent = switching_agent_with_effort(acp, "model-b", "high"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert_eq!( + effort_option_current_value(&cap).as_deref(), + Some("high"), + "startup effort must apply against the target model's thought_level option" + ); + } + + /// F5.6: an applied switch whose target response echoes NO options must not + /// apply the held startup effort against the STALE pre-switch options. The + /// pre-switch model-a advertised a `thought_level` option; the optionless + /// target response means the effort has no target option and must be + /// skipped — so the cached configOptions are Null, never the pre-switch + /// model-a options with a falsely patched `high`. + #[tokio::test] + async fn test_startup_effort_skips_stale_options_on_optionless_switch() { + // session/new: model-a WITH a thought_level option. + let session_new = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]"#; + // switch reply: applied, but NO echoed options. + let switch_reply = r#""result":{"ok":true}"#; + let acp = spawn_switch_acp(session_new, switch_reply).await; + let mut agent = switching_agent_with_effort(acp, "model-b", "high"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], true, + "the switch still applied even with no echoed options" + ); + assert!( + cap["configOptions"].is_null(), + "an optionless switch caches the target's (empty) options, never the pre-switch model-a options with a patched effort" + ); + } +} diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index fc3a16ddb9..17a818867d 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -410,37 +410,6 @@ impl RestClient { .await } - /// Fetch the relay's own signing pubkey from the public NIP-11 `/info` - /// document (the `self` field, hex, normalized to lowercase). - /// - /// Used by the inbound author gate to recognize relay-signed workflow - /// messages (`buzz:workflow`-tagged kind:9 events authored by the relay - /// keypair) and gate them on their *attributed* author instead. - /// - /// Returns `None` when the document is unreachable, unparseable, or has - /// no valid `self` field (e.g. the relay runs with an ephemeral key). - /// Callers must treat `None` as "no relay-signed exemption" — fail closed - /// to the plain author gate, never guess a pubkey. - pub async fn fetch_relay_self(&self) -> Option { - let url = format!("{}/info", self.base_url); - let resp = self - .http - .get(&url) - .header("Accept", "application/nostr+json") - .send() - .await - .ok()?; - if !resp.status().is_success() { - return None; - } - let doc: Value = resp.json().await.ok()?; - let self_hex = doc.get("self")?.as_str()?; - if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) { - return None; - } - Some(self_hex.to_ascii_lowercase()) - } - /// Query events via the HTTP bridge: `POST /query` with NIP-98 auth. /// /// Accepts a slice of `nostr::Filter` (serialized as JSON array). diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 29f4bd310f..733eb48ab5 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -227,6 +227,7 @@ impl Llm { tracing::info!( model = effective_model, provider = ?cfg.provider, + thinking_effort = ?cfg.thinking_effort, duration_ms, input_tokens = ?response.input_tokens, cached_input_tokens = ?response.cached_input_tokens, diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs index 7e06b9bf68..6c48215c34 100644 --- a/crates/buzz-agent/src/model_capabilities.rs +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -375,22 +375,47 @@ pub fn databricks_v2_known_models() -> &'static [String] { } /// Curated display label for a Databricks endpoint id, or `None` when no exact -/// record covers it. Read-only accessor over the same `databricks_v2` exact -/// records `resolve()` consults, with the same case-insensitive id match; used -/// by discovery to curate `ModelEntry.name` (the Databricks API returns no -/// display name of its own). Scoped to `databricks_v2` records only, so it can -/// never surface a curated label for a non-Databricks provider. +/// record covers it. Exact raw-id hits preserve the resolver's current behavior. +/// On an exact miss, aliases share a label only when stripping the manifest's +/// existing family-token prefix from the query and record keys yields exactly one +/// `databricks_v2` record; no or ambiguous stripped matches deliberately remain +/// uncurated. This accessor is discovery-only, so `resolve()` retains its exact- +/// record label contract. pub fn databricks_registry_label(raw_model_id: &str) -> Option<&'static str> { + let m = manifest(); + registry_label_for_databricks_records(raw_model_id, &m.exact_records, &m.family_tokens) +} + +fn registry_label_for_databricks_records<'a>( + raw_model_id: &str, + records: &'a [ExactRecord], + family_tokens: &[String], +) -> Option<&'a str> { if raw_model_id.trim().is_empty() { return None; } - manifest() - .exact_records - .iter() - .find(|rec| { - rec.provider == "databricks_v2" && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) - }) - .map(|rec| rec.registry_label.as_str()) + + if let Some(rec) = records.iter().find(|rec| { + rec.provider == "databricks_v2" && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) + }) { + return Some(&rec.registry_label); + } + + let query_lower = raw_model_id.to_ascii_lowercase(); + let stripped_query = strip_catalog_prefix(&query_lower, family_tokens); + if stripped_query == query_lower { + return None; + } + let mut matching_record = None; + for rec in records.iter().filter(|rec| rec.provider == "databricks_v2") { + let record_lower = rec.raw_model_id.to_ascii_lowercase(); + if strip_catalog_prefix(&record_lower, family_tokens) == stripped_query + && matching_record.replace(rec).is_some() + { + return None; + } + } + matching_record.map(|rec| rec.registry_label.as_str()) } /// Semantic invariants that strict typed parsing cannot express. Structural @@ -571,6 +596,16 @@ mod tests { Q::Vector { id: "dbv2-goose-opus-5-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-opus-5", note: Some("Probes a goose- prefix over a bare code-name segment with no leading claude.") }, Q::Section { group: "Resolver-contract probes (plan v4 §Resolver contract)", note: None }, Q::Vector { id: "resolver-exact-raw-id-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes a raw id that has an exact record.") }, + Q::Vector { id: "dbv2-claude-fable-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-fable-5", note: Some("Probes the canonical Databricks Fable 5 endpoint record.") }, + Q::Vector { id: "dbv2-goose-claude-fable-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes a prefixed alias of the Databricks Fable 5 endpoint.") }, + Q::Vector { id: "dbv2-claude-opus-4-8-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-8", note: Some("Probes the canonical Databricks Opus 4.8 endpoint record.") }, + Q::Vector { id: "dbv2-goose-claude-opus-4-8-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-opus-4-8", note: Some("Probes a prefixed alias of the Databricks Opus 4.8 endpoint.") }, + Q::Vector { id: "dbv2-claude-opus-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-5", note: Some("Probes the canonical Databricks Opus 5 endpoint record.") }, + Q::Vector { id: "dbv2-goose-claude-opus-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-opus-5", note: Some("Probes a prefixed alias of the Databricks Opus 5 endpoint.") }, + Q::Vector { id: "dbv2-claude-sonnet-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-sonnet-5", note: Some("Probes the canonical Databricks Sonnet 5 endpoint record.") }, + Q::Vector { id: "dbv2-goose-claude-sonnet-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-sonnet-5", note: Some("Probes a prefixed alias of the Databricks Sonnet 5 endpoint.") }, + Q::Vector { id: "dbv2-kimi-k3-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-kimi-k3", note: Some("Probes the canonical Databricks Kimi K3 endpoint record.") }, + Q::Vector { id: "dbv2-goose-kimi-k3-alias-probe", provider: "databricks_v2", raw_model_id: "goose-kimi-k3", note: Some("Probes a prefixed alias of the Databricks Kimi K3 endpoint.") }, Q::Vector { id: "resolver-prefixed-alias-probe", provider: "databricks_v2", raw_model_id: "team-x-databricks-gpt-5-4-mini", note: Some("Probes a prefixed alias of an exact-record id (raw exact key differs).") }, Q::Vector { id: "resolver-cross-provider-probe", provider: "openai", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes the same raw id under a different provider (exact records are provider-scoped).") }, Q::Vector { id: "resolver-exact-record-with-family-route-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-sol", note: Some("Exact-vs-family route-axis probe (raw exact key with a covering family rule).") }, @@ -884,17 +919,77 @@ mod tests { #[test] fn test_databricks_registry_label_lookup() { - // Known id → curated label; case-insensitive on the id, matching resolve(). - assert_eq!( - databricks_registry_label("databricks-gpt-5-5"), - Some("GPT-5.5") - ); + // Exact raw id remains case-insensitive and unchanged. assert_eq!( databricks_registry_label("DATABRICKS-GPT-5-5"), Some("GPT-5.5") ); - // Unknown id and blank input → no label. + // Exact raw ids preserve their canonical labels. + for (model, label) in [ + ("databricks-claude-opus-5", "Claude Opus 5"), + ("databricks-claude-sonnet-5", "Claude Sonnet 5"), + ("databricks-kimi-k3", "Kimi K3"), + ] { + assert_eq!( + databricks_registry_label(model), + Some(label), + "model={model}" + ); + } + // Aliases reuse the existing family-token stripper. + assert_eq!( + databricks_registry_label("goose-gpt-5-6-sol"), + Some("GPT-5.6 Sol") + ); + assert_eq!( + databricks_registry_label("goose-claude-fable-5"), + Some("Claude Fable 5") + ); + for (alias, label) in [ + ("goose-claude-opus-4-8", "Claude Opus 4.8"), + ("goose-claude-opus-5", "Claude Opus 5"), + ("goose-claude-sonnet-5", "Claude Sonnet 5"), + ("goose-kimi-k3", "Kimi K3"), + ] { + assert_eq!( + databricks_registry_label(alias), + Some(label), + "alias={alias}" + ); + } + // Unknown ids, bare family ids, and blanks remain uncurated. assert_eq!(databricks_registry_label("custom-unlisted-endpoint"), None); + assert_eq!(databricks_registry_label("gpt-5"), None); assert_eq!(databricks_registry_label(" "), None); } + + #[test] + fn registry_label_alias_collision_returns_none() { + let record = |raw_model_id: &str, registry_label: &str| ExactRecord { + provider: "databricks_v2".to_string(), + raw_model_id: raw_model_id.to_string(), + registry_label: registry_label.to_string(), + thinking_mode: ThinkingMode::None, + supported_efforts: vec![ThinkingEffort::Medium], + default_effort: Some(ThinkingEffort::Medium), + databricks_v2_wire_route: DatabricksV2Route::MlflowChat, + normalization_policy: NormalizationPolicy::None, + provenance: None, + source: None, + source_alt: None, + reconciliation: None, + reconciliation_note: None, + reconciliation_doc: None, + }; + let records = vec![ + record("databricks-gpt-5-6", "Databricks GPT-5.6"), + record("partner-gpt-5-6", "Partner GPT-5.6"), + ]; + let family_tokens = vec!["gpt-".to_string()]; + + assert_eq!( + registry_label_for_databricks_records("goose-gpt-5-6", &records, &family_tokens), + None + ); + } } diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs index badff621e8..5fc27ab905 100644 --- a/crates/buzz-backend-kubernetes/src/env.rs +++ b/crates/buzz-backend-kubernetes/src/env.rs @@ -389,6 +389,91 @@ mod tests { assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet"); } + /// F2 provider seam: the desktop strips both model keys from a Claude + /// launch.env and rides the canonical model on policy_env alone. This test + /// pins the final `build_env` output for that shape: the canonical + /// ANTHROPIC_MODEL survives (tier 1, no tier-2 key to overwrite it) and + /// BUZZ_ACP_MODEL is absent — so the remote process has exactly one model + /// authority. Same-value and conflicting-value collisions are both moot + /// because the desktop already removed the launch.env keys. + #[test] + fn claude_launch_yields_single_model_authority_through_build_env() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "claude", + "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + // Desktop stripped both model keys from launch.env for claude. + "env": {"KEEP_ME": "yes"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!( + env["ANTHROPIC_MODEL"], "claude-opus-4", + "canonical model must survive as the single authority" + ); + assert!( + !env.contains_key("BUZZ_ACP_MODEL"), + "no second model authority may reach the remote process" + ); + assert_eq!(env["KEEP_ME"], "yes"); + } + + /// F2 provider seam, adversarial: even if a launch.env somehow still carries + /// model keys (older desktop, tampering), tier 2 later-wins over tier 1 — + /// which is exactly why the desktop must strip them. This documents the + /// hazard the desktop fix prevents: a launch.env ANTHROPIC_MODEL overrides + /// the canonical, and a launch.env BUZZ_ACP_MODEL introduces a second + /// authority. Neither key is authoritative in k8s, so the provider cannot + /// defend against it — the desktop strip is the only guard. + #[test] + fn launch_env_model_keys_would_win_over_policy_env_documenting_the_hazard() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "claude", + "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + "env": {"ANTHROPIC_MODEL": "user-haiku", "BUZZ_ACP_MODEL": "user-sonnet"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!( + env["ANTHROPIC_MODEL"], "user-haiku", + "launch.env later-wins — proving the desktop must strip it" + ); + assert_eq!( + env["BUZZ_ACP_MODEL"], "user-sonnet", + "a leftover BUZZ_ACP_MODEL would be a second authority — desktop strips it" + ); + } + + /// F2 provider seam, same-value collision: a leftover launch.env + /// ANTHROPIC_MODEL that happens to match the canonical policy_env value is + /// still a second authority structurally — tier 2 later-wins, so the value + /// the remote process sees comes from launch.env, not the canonical tier. + /// It is only benign because the strings coincide; the desktop strip is what + /// guarantees the canonical tier is authoritative regardless of the leftover + /// value. Pinning the same-value case proves `build_env` cannot itself + /// distinguish a matching leftover from a conflicting one. + #[test] + fn launch_env_same_value_model_key_still_rides_tier_two_through_build_env() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "claude", + "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + // Same value as the canonical policy_env entry. + "env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!( + env["ANTHROPIC_MODEL"], "claude-opus-4", + "value coincides, but it is tier 2 (launch.env) that wins — the \ + provider cannot tell a matching leftover from a conflicting one" + ); + } + /// `launch.env` already contains the merged user env, so re-merging the /// legacy field would undo a layering the desktop already resolved. #[test] diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a2dcdce6d2..8f8db4d289 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -34,6 +34,7 @@ buzz messages send --channel --content "Reply" --reply-to --br buzz messages send --channel --content - < message.md # read body from stdin buzz messages get --channel --limit 20 buzz messages thread --channel --event +buzz messages thread --link 'buzz://message?channel=&id=&thread=' buzz messages search --query "architecture" buzz messages search --author --since buzz messages edit --event --content "Updated text" diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 77234b7faa..81ed36f62b 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -216,8 +216,11 @@ echo 'Body with `backticks` and $vars stays literal.' \ buzz messages get --channel "$CHANNEL_ID" | jq . buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq . -# messages thread +# messages thread from the root, a reply, and a canonical link buzz messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq . +buzz messages thread --channel "$CHANNEL_ID" --event "$REPLY_ID" | jq . +buzz messages thread \ + --link "buzz://message?channel=$CHANNEL_ID&id=$REPLY_ID&thread=$EVENT_ID" | jq . # messages search buzz messages search --query "Hello" | jq . diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 5cc745d7b9..7ad051ef9f 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -161,6 +161,7 @@ struct ChannelSummary { about: Option, topic: Option, purpose: Option, + ttl_seconds: Option, } impl ChannelSummary { @@ -176,6 +177,7 @@ impl ChannelSummary { let mut about: Option = None; let mut topic: Option = None; let mut purpose: Option = None; + let mut ttl_seconds: Option = None; for tag in tags { let Some(tag_arr) = tag.as_array() else { @@ -194,6 +196,7 @@ impl ChannelSummary { "about" => about = val.map(str::to_string), "topic" => topic = val.map(str::to_string), "purpose" => purpose = val.map(str::to_string), + "ttl" => ttl_seconds = val.and_then(|value| value.parse().ok()), "archived" => archived = val == Some("true"), _ => {} } @@ -208,6 +211,7 @@ impl ChannelSummary { about, topic, purpose, + ttl_seconds, }) } } @@ -1215,6 +1219,7 @@ mod tests { ["about", "About text"], ["topic", "Composer work"], ["purpose", "Track UI for the composer"], + ["ttl", "3600"], ])); let s = ChannelSummary::from_event(&ev).expect("parse"); assert_eq!(s.channel_id, "11111111-1111-1111-1111-111111111111"); @@ -1225,6 +1230,7 @@ mod tests { assert_eq!(s.about.as_deref(), Some("About text")); assert_eq!(s.topic.as_deref(), Some("Composer work")); assert_eq!(s.purpose.as_deref(), Some("Track UI for the composer")); + assert_eq!(s.ttl_seconds, Some(3600)); } #[test] diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..5651c0b4c7 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -54,68 +54,69 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option { /// - Nested reply: `root` is the parent's own root marker; `parent` is unchanged. /// /// Ensures CLI-sent replies thread correctly using the same NIP-10 logic. +async fn fetch_event(client: &BuzzClient, event_id: &str) -> Result { + let filter = serde_json::json!({ "ids": [event_id], "limit": 1 }); + let raw = client.query(&filter).await?; + let events: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?; + events + .as_array() + .and_then(|events| events.first()) + .cloned() + .ok_or_else(|| CliError::NotFound(format!("event {event_id} not found"))) +} + async fn resolve_thread_ref( client: &BuzzClient, parent_event_id: &str, ) -> Result { - let parent_eid = parse_event_id(parent_event_id)?; - let filter = serde_json::json!({ "ids": [parent_event_id], "limit": 1 }); - let raw = client.query(&filter).await?; - let events: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?; - let event = events - .as_array() - .and_then(|a| a.first()) - .ok_or_else(|| CliError::Other(format!("parent event {parent_event_id} not found")))?; + let event = fetch_event(client, parent_event_id).await?; + thread_ref_from_event(parent_event_id, &event) +} + +fn thread_ref_from_event(event_id: &str, event: &serde_json::Value) -> Result { + let parent_eid = parse_event_id(event_id)?; let tags = event .get("tags") .cloned() .unwrap_or(serde_json::Value::Null); - - let root_eid = match find_root_from_tags(&tags) { - Some(root_hex) if root_hex != parent_event_id => parse_event_id(&root_hex)?, + let root_event_id = match find_root_from_tags(&tags) { + Some(root_hex) if root_hex != event_id => parse_event_id(&root_hex)?, _ => parent_eid, }; - Ok(ThreadRef { - root_event_id: root_eid, + root_event_id, parent_event_id: parent_eid, }) } /// Resolve the channel UUID for an event by querying for it via POST /query. /// Extracts the `h` tag value from the returned event's tags. -async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result { - let filter = serde_json::json!({ - "ids": [event_id] - }); - let raw = client.query(&filter).await?; - let events: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?; - let arr = events - .as_array() - .ok_or_else(|| CliError::Other("query response is not an array".into()))?; - let event = arr - .first() - .ok_or_else(|| CliError::Other(format!("event {event_id} not found")))?; +fn channel_id_from_event(event_id: &str, event: &serde_json::Value) -> Result { let tags = event .get("tags") - .and_then(|t| t.as_array()) + .and_then(|tags| tags.as_array()) .ok_or_else(|| CliError::Other("event missing 'tags' field".into()))?; - for tag in tags { - if let Some(arr) = tag.as_array() { - if arr.first().and_then(|v| v.as_str()) == Some("h") { - if let Some(uuid_str) = arr.get(1).and_then(|v| v.as_str()) { - return Uuid::parse_str(uuid_str).map_err(|_| { - CliError::Other(format!("event h-tag is not a valid UUID: {uuid_str}")) - }); - } - } - } - } - Err(CliError::Other(format!( - "event {event_id} has no h-tag — cannot determine channel" - ))) + tags.iter() + .filter_map(|tag| tag.as_array()) + .find(|tag| tag.first().and_then(|value| value.as_str()) == Some("h")) + .and_then(|tag| tag.get(1)) + .and_then(|value| value.as_str()) + .ok_or_else(|| { + CliError::Other(format!( + "event {event_id} has no h-tag — cannot determine channel" + )) + }) + .and_then(|channel_id| { + Uuid::parse_str(channel_id).map_err(|_| { + CliError::Other(format!("event h-tag is not a valid UUID: {channel_id}")) + }) + }) +} + +async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result { + let event = fetch_event(client, event_id).await?; + channel_id_from_event(event_id, &event) } fn resolve_names_to_pubkeys( @@ -391,37 +392,71 @@ pub async fn cmd_get_messages( Ok(()) } +pub fn resolve_thread_target( + expected_channel_id: Uuid, + event_id: &str, + expected_root_id: Option<&str>, + selected_event: &serde_json::Value, +) -> Result { + let actual_channel_id = channel_id_from_event(event_id, selected_event)?; + if actual_channel_id != expected_channel_id { + return Err(CliError::Usage(format!( + "event {event_id} does not belong to channel {expected_channel_id}" + ))); + } + let root_event_id = thread_ref_from_event(event_id, selected_event)? + .root_event_id + .to_hex(); + if expected_root_id.is_some_and(|expected| expected != root_event_id) { + return Err(CliError::Usage( + "Buzz message link thread root does not match the selected message".into(), + )); + } + Ok(root_event_id) +} + pub async fn cmd_get_thread( client: &BuzzClient, channel_id: &str, event_id: &str, + expected_root_id: Option<&str>, limit: Option, depth_limit: Option, format: &crate::OutputFormat, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; + let expected_channel_id = parse_uuid(channel_id)?; validate_hex64(event_id)?; + let selected_event = fetch_event(client, event_id).await?; + let root_event_id = resolve_thread_target( + expected_channel_id, + event_id, + expected_root_id, + &selected_event, + )?; let limit = limit.unwrap_or(100).min(500); - // Two filters ORed in a single HTTP call: - // 1. Replies referencing this event via e-tag (no kind restriction) - // 2. The root event itself by ID let mut reply_filter = serde_json::json!({ "kinds": [9, 40002, 40003, 40008, 45003], "#h": [channel_id], - "#e": [event_id], + "#e": [root_event_id.as_str()], "limit": limit }); if let Some(d) = depth_limit { reply_filter["depth_limit"] = serde_json::json!(d); } let root_filter = serde_json::json!({ - "ids": [event_id], + "ids": [root_event_id.as_str()], + "#h": [channel_id], "limit": 1 }); let resp = client.query_multi(&[reply_filter, root_filter]).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + events.sort_by_key(|event| { + event + .get("created_at") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }); let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -965,9 +1000,35 @@ pub async fn dispatch( MessagesCmd::Thread { channel, event, + link, limit, depth_limit, - } => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await, + } => { + let (channel, event, expected_root) = + match link { + Some(link) => { + let parsed = crate::links::parse_message_link(&link)?; + (parsed.channel_id, parsed.message_id, parsed.thread_root_id) + } + None => match (channel, event) { + (Some(channel), Some(event)) => (channel, event, None), + _ => return Err(CliError::Usage( + "messages thread requires either --link or both --channel and --event" + .into(), + )), + }, + }; + cmd_get_thread( + client, + &channel, + &event, + expected_root.as_deref(), + limit, + depth_limit, + format, + ) + .await + } MessagesCmd::Search { query, author, @@ -993,13 +1054,15 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, + channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags, + match_profiles_by_name, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, + resolve_thread_target, thread_ref_from_event, BuzzClient, CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; + use nostr::Keys; use serde_json::json; const ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -1012,6 +1075,92 @@ mod tests { const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68"; + #[tokio::test] + async fn malformed_channel_is_rejected_before_thread_fetch() { + let client = + BuzzClient::new("http://127.0.0.1:1".into(), Keys::generate(), None, None).unwrap(); + let error = cmd_get_thread( + &client, + "not-a-uuid", + ID_A, + None, + None, + None, + &crate::OutputFormat::Json, + ) + .await + .unwrap_err(); + + assert!(matches!(error, CliError::Usage(_))); + assert!(error.to_string().contains("invalid UUID")); + } + + #[test] + fn selected_event_derives_authoritative_channel_and_root() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + let event = json!({ + "tags": [ + ["h", channel], + ["e", ID_A, "", "root"], + ["e", ID_B, "", "reply"], + ] + }); + + assert_eq!( + channel_id_from_event(ID_B, &event).unwrap().to_string(), + channel + ); + assert_eq!( + thread_ref_from_event(ID_B, &event) + .unwrap() + .root_event_id + .to_hex(), + ID_A + ); + } + + #[test] + fn selected_event_requires_a_valid_channel_tag() { + let missing = json!({"tags": []}); + let malformed = json!({"tags": [["h", "not-a-uuid"]]}); + assert!(channel_id_from_event(ID_A, &missing).is_err()); + assert!(channel_id_from_event(ID_A, &malformed).is_err()); + } + + #[test] + fn thread_target_rejects_wrong_channel_or_root_hint() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + let other_channel = "123e4567-e89b-12d3-a456-426614174001"; + let selected = json!({ + "tags": [["h", channel], ["e", ID_A, "", "root"]] + }); + + assert!(resolve_thread_target( + Uuid::parse_str(other_channel).unwrap(), + ID_B, + Some(ID_A), + &selected, + ) + .is_err()); + assert!(resolve_thread_target( + Uuid::parse_str(channel).unwrap(), + ID_B, + Some(ID_B), + &selected, + ) + .is_err()); + assert_eq!( + resolve_thread_target( + Uuid::parse_str(channel).unwrap(), + ID_B, + Some(ID_A), + &selected, + ) + .unwrap(), + ID_A + ); + } + #[test] fn root_marker_wins_over_reply_marker() { let tags = json!([ diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 2b041da57b..5cac8c941e 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -480,14 +480,20 @@ pub enum MessagesCmd { #[arg(long)] kinds: Option, }, - /// Get a message thread (replies to a root message) + /// Get the containing thread for a message or Buzz message link + #[command( + after_help = "Examples:\n buzz messages thread --channel --event \n buzz messages thread --link 'buzz://message?channel=&id=&thread='" + )] Thread { - /// Channel UUID - #[arg(long)] - channel: String, - /// Root message event ID (64-char hex) - #[arg(long)] - event: String, + /// Channel UUID; required unless --link is supplied + #[arg(long, required_unless_present = "link", conflicts_with = "link")] + channel: Option, + /// Message event ID (64-char hex); required unless --link is supplied + #[arg(long, required_unless_present = "link", conflicts_with = "link")] + event: Option, + /// Canonical buzz://message deep link; uses the configured relay and identity + #[arg(long, conflicts_with_all = ["channel", "event"])] + link: Option, /// Maximum number of results to return #[arg(long)] limit: Option, @@ -582,8 +588,9 @@ pub enum ChannelsCmd { /// Channel description #[arg(long)] description: Option, - /// Make the channel ephemeral: lifetime in seconds. The relay archives - /// it once this many seconds pass without a new message. + /// Make the channel temporary/ephemeral: idle lifetime in seconds. If + /// omitted, the channel is permanent. The relay archives it once this + /// many seconds pass without a new message. #[arg(long, value_name = "SECONDS")] ttl: Option, /// Apply a desktop-local channel template by name (case-insensitive): @@ -2119,6 +2126,47 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn messages_thread_accepts_link_or_explicit_identifiers() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + let event = "a".repeat(64); + let link = format!("buzz://message?channel={channel}&id={event}"); + + assert!( + Cli::try_parse_from(["buzz", "messages", "thread", "--link", link.as_str(),]).is_ok() + ); + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "thread", + "--channel", + channel, + "--event", + event.as_str(), + ]) + .is_ok()); + } + + #[test] + fn messages_thread_rejects_partial_or_mixed_targets() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + let event = "a".repeat(64); + let link = format!("buzz://message?channel={channel}&id={event}"); + + assert!(Cli::try_parse_from(["buzz", "messages", "thread"]).is_err()); + assert!(Cli::try_parse_from(["buzz", "messages", "thread", "--channel", channel]).is_err()); + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "thread", + "--link", + link.as_str(), + "--event", + event.as_str(), + ]) + .is_err()); + } + #[test] fn set_status_clear_rejects_text_and_emoji() { for extra in [["--text", "busy"], ["--emoji", "🎶"]] { diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs index 7d512710d4..c724860c49 100644 --- a/crates/buzz-cli/src/links.rs +++ b/crates/buzz-cli/src/links.rs @@ -1,10 +1,10 @@ -//! Canonical `buzz://` deep links for Buzz-hosted git entities. +//! Canonical `buzz://` deep links for Buzz entities. //! //! Buzz Desktop renders these links as rich preview cards in chat and //! navigates in-app when they are clicked. The desktop parser lives in -//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must -//! stay format-compatible (see `golden_format_matches_desktop` below and -//! the mirror test in `entityLink.test.mjs`). +//! `desktop/src/shared/lib/entityLink.ts` for git entities and +//! `desktop/src/features/messages/lib/messageLink.ts` for messages. The +//! implementations must stay format-compatible. //! //! Callers are expected to validate inputs first (`validate_hex64`, //! `validate_repo_id`); the identifier charsets need no URL encoding. @@ -15,6 +15,94 @@ //! (overview); the parameter exists for the desktop's tab-aware copy-link //! button. +use crate::error::CliError; + +/// A validated `buzz://message` deep link. +#[derive(Debug, PartialEq, Eq)] +pub struct MessageLink { + pub channel_id: String, + pub message_id: String, + pub thread_root_id: Option, +} + +/// Parse a `buzz://message?channel=&id=[&thread=]` link. +/// +/// The link chooses only the channel and event within the relay already +/// configured for this CLI process. It cannot override the relay or identity. +pub fn parse_message_link(input: &str) -> Result { + let url = url::Url::parse(input.trim()) + .map_err(|_| CliError::Usage("invalid Buzz message link".into()))?; + + if url.scheme() != "buzz" + || url.host_str() != Some("message") + || !matches!(url.path(), "" | "/") + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(CliError::Usage( + "expected a buzz://message link without credentials or a fragment".into(), + )); + } + + let mut channel = None; + let mut message = None; + let mut thread = None; + for (key, value) in url.query_pairs() { + let slot = match key.as_ref() { + "channel" => &mut channel, + "id" => &mut message, + "thread" => &mut thread, + _ => { + return Err(CliError::Usage( + "Buzz message link contains an unsupported query parameter".into(), + )) + } + }; + if slot.replace(value.into_owned()).is_some() { + return Err(CliError::Usage(format!( + "Buzz message link contains more than one {key} parameter" + ))); + } + } + + let channel = channel + .filter(|value| !value.is_empty()) + .ok_or_else(|| CliError::Usage("Buzz message link is missing channel".into()))?; + let message = message + .filter(|value| !value.is_empty()) + .ok_or_else(|| CliError::Usage("Buzz message link is missing id".into()))?; + if thread.as_deref() == Some("") { + return Err(CliError::Usage( + "Buzz message link contains an empty thread parameter".into(), + )); + } + + let channel_id = uuid::Uuid::parse_str(&channel) + .map_err(|_| CliError::Usage("Buzz message link contains an invalid channel UUID".into()))? + .to_string(); + let message_id = canonical_event_id(&message, "id")?; + let thread_root_id = thread + .as_deref() + .map(|value| canonical_event_id(value, "thread")) + .transpose()?; + + Ok(MessageLink { + channel_id, + message_id, + thread_root_id, + }) +} + +fn canonical_event_id(value: &str, parameter: &str) -> Result { + if value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) { + return Err(CliError::Usage(format!( + "Buzz message link contains an invalid {parameter} event ID" + ))); + } + Ok(value.to_ascii_lowercase()) +} + /// Whether a d-tag can be expressed in a `buzz://` link. /// /// Project slugs accept up to 1024 bytes of arbitrary UTF-8, but the link @@ -58,6 +146,10 @@ mod tests { use super::*; use serde_json::Value; + const CHANNEL: &str = "123e4567-e89b-12d3-a456-426614174000"; + const MESSAGE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const THREAD: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + fn golden() -> Value { serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json")) .expect("valid entity-links golden fixture") @@ -101,4 +193,64 @@ mod tests { } assert!(!is_linkable_dtag(&"a".repeat(65))); } + + #[test] + fn parses_message_link_with_thread_root() { + let parsed = parse_message_link(&format!( + "buzz://message?channel={CHANNEL}&id={MESSAGE}&thread={THREAD}" + )) + .unwrap(); + + assert_eq!( + parsed, + MessageLink { + channel_id: CHANNEL.into(), + message_id: MESSAGE.into(), + thread_root_id: Some(THREAD.into()), + } + ); + } + + #[test] + fn parses_message_link_without_thread_root() { + let parsed = + parse_message_link(&format!("buzz://message?channel={CHANNEL}&id={MESSAGE}")).unwrap(); + assert_eq!(parsed.thread_root_id, None); + } + + #[test] + fn normalizes_message_link_identifiers() { + let parsed = parse_message_link(&format!( + "buzz://message?channel={}&id={}", + CHANNEL.to_ascii_uppercase(), + MESSAGE.to_ascii_uppercase() + )) + .unwrap(); + + assert_eq!(parsed.channel_id, CHANNEL); + assert_eq!(parsed.message_id, MESSAGE); + } + + #[test] + fn rejects_message_link_that_could_change_connection_context() { + for link in [ + format!("buzz://message?channel={CHANNEL}&id={MESSAGE}&relay=other"), + format!("buzz://user:secret@message?channel={CHANNEL}&id={MESSAGE}"), + format!("buzz://message?channel={CHANNEL}&id={MESSAGE}#fragment"), + ] { + assert!(parse_message_link(&link).is_err(), "accepted {link}"); + } + } + + #[test] + fn rejects_duplicate_or_malformed_message_link_identifiers() { + for link in [ + format!("buzz://message?channel={CHANNEL}&channel={CHANNEL}&id={MESSAGE}"), + format!("buzz://message?channel=not-a-uuid&id={MESSAGE}"), + format!("buzz://message?channel={CHANNEL}&id=not-an-event"), + format!("buzz://message?channel={CHANNEL}&id={MESSAGE}&thread="), + ] { + assert!(parse_message_link(&link).is_err(), "accepted {link}"); + } + } } diff --git a/crates/buzz-dev-mcp/src/paths.rs b/crates/buzz-dev-mcp/src/paths.rs index 1770d562fa..2c75a112f7 100644 --- a/crates/buzz-dev-mcp/src/paths.rs +++ b/crates/buzz-dev-mcp/src/paths.rs @@ -1,8 +1,10 @@ //! Path resolution and file I/O shared across dev-mcp tools. //! //! `resolve_path` resolves and canonicalizes a user-supplied path against a -//! workspace root. No containment enforcement — the resolved path may land -//! anywhere on the filesystem (consistent with the `shell` tool's posture). +//! workspace root. A leading `~` expands to the user's home directory (bare +//! `~` or `~/...`), matching the shell tool. No containment enforcement — the +//! resolved path may land anywhere on the filesystem (consistent with the +//! `shell` tool's posture). //! //! `read_text_file` builds on `resolve_path` to provide the full //! resolve → stat → size-check → read → UTF-8 decode pipeline shared by @@ -28,6 +30,17 @@ pub(crate) fn resolve_path(root: &Path, path: &str) -> Result { #[cfg(windows)] let path = &msys_to_windows(path); + // Expand a leading `~` (bare or `~/...`) to the user's home directory, + // matching the shell tool's tilde semantics. Without this, a user-named + // path like `~/.claude/skills/x` takes the relative branch and resolves + // under the workspace root (`/~/.claude/...`), which never exists. + // We deliberately do NOT handle `~user` (another user's home): that needs + // a passwd lookup and is out of scope, mirroring the conservative posture + // for un-mappable MSYS forms above. `~user...` falls through untouched and + // fails with the clear `path not accessible` error rather than mis-mapping. + let expanded = expand_tilde(path, home_dir().as_deref()); + let path: &str = expanded.as_deref().unwrap_or(path); + let raw = Path::new(path); let candidate: PathBuf = if raw.is_absolute() { raw.to_path_buf() @@ -41,6 +54,88 @@ pub(crate) fn resolve_path(root: &Path, path: &str) -> Result { Ok(resolved) } +/// Expand a leading `~` to the user's home directory, returning `Some(expanded)` +/// when a rewrite happened and `None` when the input should be used unchanged. +/// +/// Handles the two shell forms that map deterministically to a home directory: +/// - bare `~` -> `home` +/// - `~/rest` (or `~\rest` on Windows) -> `/rest` +/// +/// A leading `~` followed by anything else (`~user`, `~+`, `~foo`) is a form we +/// cannot resolve without extra state, so it is left untouched — consistent with +/// how `msys_to_windows` leaves un-mappable inputs alone. Returns `None` when +/// `home` is `None` (unset) so the caller falls back to the raw path. Kept pure +/// (home passed in) so it is testable without mutating process environment. +fn expand_tilde(path: &str, home: Option<&str>) -> Option { + let rest = path.strip_prefix('~')?; + // Only a bare `~` or a `~` immediately followed by a path separator is a + // home-relative reference. Anything else (`~user`) is left to the caller. + let is_sep = |c: char| c == '/' || (cfg!(windows) && c == '\\'); + if !rest.is_empty() && !rest.starts_with(is_sep) { + return None; + } + + let home = home?; + if home.is_empty() { + return None; + } + + if rest.is_empty() { + // Bare `~` -> home directory. + return Some(home.to_string()); + } + // `~/rest` -> `/rest`. `rest` begins with a separator, so strip it to + // avoid an absolute-looking join and let `Path` re-add the separator. + let tail = rest.trim_start_matches(is_sep); + let joined = Path::new(home).join(tail); + Some(joined.to_string_lossy().into_owned()) +} + +/// The user's home directory from the environment. Reads `$HOME` first, falling +/// back to `%USERPROFILE%` on Windows, then hands the raw values to `select_home` +/// (pure, so it is testable without mutating process env). Returns `None` if no +/// usable value is set or the value is not UTF-8. +fn home_dir() -> Option { + let home = std::env::var_os("HOME").and_then(|v| v.into_string().ok()); + #[cfg(windows)] + let userprofile = std::env::var_os("USERPROFILE").and_then(|v| v.into_string().ok()); + #[cfg(not(windows))] + let userprofile: Option = None; + select_home(home.as_deref(), userprofile.as_deref()) +} + +/// Choose the home directory from the two env candidates, preferring `$HOME`. +/// +/// `$HOME` is preferred because that is exactly what bash — and therefore the +/// `shell` tool — expands `~` against, and `HOME` is passed through to the MCP +/// child on every platform (see `buzz-agent`'s `PASSTHROUGH_ENV`). Picking +/// `USERPROFILE` first on Windows would diverge from the shell tool whenever the +/// two differ (a git-bash `HOME=/c/Users/x`, or an `mcpServers[].env` override), +/// which is precisely the "match the shell tool" contract this fix exists for. +/// `USERPROFILE` is only a Windows fallback for when `HOME` is unset. +/// +/// On Windows the chosen value is passed through `msys_to_windows` so an MSYS +/// `HOME` (`/c/Users/x`) becomes a native path (`C:\Users\x`) — `~` expansion +/// happens after `msys_to_windows` in `resolve_path`, so the spliced-in home +/// would otherwise never be translated and `canonicalize` would reject it. An +/// MSYS form with no Windows equivalent (`/home/x`) falls through untranslated +/// and fails with the clear `path not accessible` error, the correct outcome. +/// Empty strings are treated as unset. +fn select_home(home: Option<&str>, userprofile: Option<&str>) -> Option { + fn non_empty(v: Option<&str>) -> Option<&str> { + v.filter(|s| !s.is_empty()) + } + let chosen = non_empty(home).or_else(|| non_empty(userprofile))?; + #[cfg(windows)] + { + Some(msys_to_windows(chosen)) + } + #[cfg(not(windows))] + { + Some(chosen.to_string()) + } +} + /// Translate the MSYS/Cygwin absolute path forms bash would accept into a /// native Windows path, matching `cygpath -w` semantics so the file tools /// resolve the same inputs the `shell` tool does. Anything that is not a @@ -208,6 +303,80 @@ mod tests { assert!(p.ends_with("file.txt")); } + // `expand_tilde` is pure (home is passed in), so these cases need no env + // mutation and cannot race parallel tests. + #[test] + fn expand_tilde_forms() { + let home = "/home/agent"; + + // Non-tilde inputs are never rewritten. + assert_eq!(expand_tilde("file.txt", Some(home)), None); + assert_eq!(expand_tilde("/abs/path", Some(home)), None); + assert_eq!(expand_tilde("sub/~notleading", Some(home)), None); + + // `~user` and other non-separator suffixes are left for the caller. + assert_eq!(expand_tilde("~user/x", Some(home)), None); + assert_eq!(expand_tilde("~foo", Some(home)), None); + + // Bare `~` and `~/rest` expand against the supplied home. + assert_eq!(expand_tilde("~", Some(home)), Some(home.to_string())); + let expanded = expand_tilde("~/.claude/skills/x", Some(home)).expect("expands"); + assert_eq!( + expanded, + Path::new(home).join(".claude/skills/x").to_string_lossy() + ); + + // Unset or empty home -> no rewrite, caller falls back to the raw path. + assert_eq!(expand_tilde("~/rest", None), None); + assert_eq!(expand_tilde("~", None), None); + assert_eq!(expand_tilde("~/rest", Some("")), None); + } + + // `select_home` is pure (both env candidates passed in), so it exercises the + // HOME-first preference and empty/unset handling without mutating process + // env or racing parallel tests. `select_home` itself does not gate the + // fallback by platform — `home_dir` is what only supplies `userprofile` on + // Windows — so these assertions hold identically on every platform. + #[test] + fn select_home_prefers_home() { + // $HOME wins when both are set. + assert_eq!( + select_home(Some("/home/agent"), Some("/other")), + Some("/home/agent".to_string()) + ); + // Empty $HOME is treated as unset -> fall back to the second candidate. + assert_eq!( + select_home(Some(""), Some("/other")), + Some("/other".to_string()) + ); + // No usable candidate -> None. + assert_eq!(select_home(None, None), None); + assert_eq!(select_home(Some(""), Some("")), None); + } + + // End-to-end through `resolve_path`, exercising the real `home_dir()` env + // read: a `~/...` path resolves against the actual home directory, not the + // workspace root. Uses a temp file created under the real home so it does + // not mutate the environment. + #[test] + fn resolve_path_expands_tilde_against_home() { + let home = match home_dir() { + Some(h) if !h.is_empty() => h, + _ => return, // No home in this environment (e.g. minimal CI) — skip. + }; + let marker = format!(".dev-mcp-tilde-test-{}", std::process::id()); + let target = Path::new(&home).join(&marker); + fs::write(&target, b"z").expect("write under home"); + + let workspace = tempdir().expect("tempdir"); + let resolved = resolve_path(workspace.path(), &format!("~/{marker}")) + .expect("tilde path resolves against home, not workspace"); + let want = std::fs::canonicalize(&target).expect("canon"); + assert_eq!(resolved, want); + + let _ = fs::remove_file(&target); + } + // Windows MSYS-absolute path translation. These test `msys_to_windows` // directly (the pure rewrite) rather than `resolve_path`, because the latter // canonicalizes against the real filesystem and we want deterministic @@ -239,6 +408,39 @@ mod tests { assert_eq!(msys_to_windows(r"C:\Users\x"), r"C:\Users\x"); } + // Windows `select_home` behavior: HOME still wins over USERPROFILE, and + // an MSYS-form HOME is translated to a native path so the value spliced + // in during `~` expansion (which runs after `msys_to_windows`) resolves. + // Both candidates are passed in, so this needs no process-env mutation + // and does not silently no-op the way a real-env read would when HOME is + // unset on CI. + #[test] + fn select_home_translates_msys_home_and_prefers_it() { + // Divergent HOME/USERPROFILE: HOME wins, and its MSYS cygdrive form + // is translated to the native path so canonicalize can use it. + assert_eq!( + select_home(Some("/c/Users/agent"), Some(r"C:\Users\other")), + Some(r"C:\Users\agent".to_string()) + ); + // A native-form HOME is preferred and passes through unchanged. + assert_eq!( + select_home(Some(r"C:\Users\agent"), Some(r"C:\Users\other")), + Some(r"C:\Users\agent".to_string()) + ); + // HOME unset -> fall back to USERPROFILE (already native). + assert_eq!( + select_home(None, Some(r"C:\Users\other")), + Some(r"C:\Users\other".to_string()) + ); + // An MSYS HOME with no Windows equivalent (`/home/x`) is left + // untranslated; it fails downstream with a clear error rather than + // being mis-mapped — the intended conservative outcome. + assert_eq!( + select_home(Some("/home/agent"), None), + Some("/home/agent".to_string()) + ); + } + #[test] fn relative_path_passes_through_unchanged() { // No leading slash — left for the caller's `root.join`. diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs index 14ce4afe1e..5abbea6f58 100644 --- a/crates/buzz-media/src/error.rs +++ b/crates/buzz-media/src/error.rs @@ -72,8 +72,10 @@ pub enum MediaError { /// Video duration exceeds the 600-second limit. #[error("video too long: duration exceeds 600 seconds")] DurationTooLong, - /// Video resolution exceeds 3840×2160. - #[error("video resolution too high: maximum is 3840x2160")] + /// Video resolution exceeds the 2160 short-edge / 3840 long-edge envelope. + #[error( + "video resolution too high: maximum is 2160 on the short edge and 3840 on the long edge" + )] ResolutionTooHigh, /// MP4 moov atom appears after mdat — not fast-start. #[error("moov atom not at front of file (not fast-start)")] diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index dfc61c4275..706c354d04 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -294,7 +294,7 @@ pub fn validate_content(bytes: &[u8], config: &MediaConfig) -> Result Result 3840 || height > 2160 { + let short_edge = width.min(height); + let long_edge = width.max(height); + if short_edge > 2160 || long_edge > 3840 { return Err(MediaError::ResolutionTooHigh); } @@ -2547,6 +2551,43 @@ mod tests { ); } + #[test] + fn test_validate_video_accepts_portrait_resolution() { + let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2160, 3840, false); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &mp4_bytes).unwrap(); + + let meta = validate_video_file(tmp.path(), &test_config()) + .expect("portrait video within the 2160x3840 envelope should be accepted"); + assert_eq!((meta.width, meta.height), (2160, 3840)); + } + + #[test] + fn test_validate_video_rejects_resolution_above_short_edge_limit() { + let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2161, 3840, false); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &mp4_bytes).unwrap(); + + let result = validate_video_file(tmp.path(), &test_config()); + assert!( + matches!(result, Err(MediaError::ResolutionTooHigh)), + "expected ResolutionTooHigh, got {result:?}" + ); + } + + #[test] + fn test_validate_video_rejects_resolution_above_long_edge_limit() { + let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2160, 3841, false); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &mp4_bytes).unwrap(); + + let result = validate_video_file(tmp.path(), &test_config()); + assert!( + matches!(result, Err(MediaError::ResolutionTooHigh)), + "expected ResolutionTooHigh, got {result:?}" + ); + } + #[test] fn test_validate_video_resolution_too_high() { let config = test_config(); diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 9da920483f..40c5464783 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -512,6 +512,196 @@ mod tests { TenantContext::resolved(CommunityId::from_uuid(id), host) } + #[tokio::test] + async fn archival_snapshot_advances_timestamp_for_rapid_state_replacement() { + let Some(pool) = test_pool().await else { + return; + }; + if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1") + .execute(&pool) + .await + .is_err() + { + return; + } + let Some(state) = test_state(pool.clone()).await else { + return; + }; + let tenant = seed_test_community(&pool).await; + let target_hex = Keys::generate().public_key().to_hex(); + let request_id = "a".repeat(64); + + state + .db + .archive( + tenant.community(), + &target_hex, + "self", + &target_hex, + None, + None, + &request_id, + ) + .await + .expect("archive identity"); + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish archived snapshot"); + let archived_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query archived snapshot") + .into_iter() + .next() + .expect("archived snapshot exists"); + + state + .db + .unarchive(tenant.community(), &target_hex) + .await + .expect("unarchive identity"); + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish unarchived snapshot"); + let final_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query final snapshot") + .into_iter() + .next() + .expect("final snapshot exists"); + + assert!( + final_snapshot.event.created_at > archived_snapshot.event.created_at, + "replacement snapshots must not rely on random same-second event-id ordering" + ); + assert!( + !final_snapshot.event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.first().map(String::as_str) == Some("p") + && fields.get(1).map(String::as_str) == Some(target_hex.as_str()) + }), + "final snapshot must reflect the canonical empty archive set" + ); + } + + /// Carl review 4954871389 test (b): a stale (pre-unarchive) publisher whose + /// canonical read predates the unarchive must not strand `target` in the + /// authoritative 13535. Deterministic via the `publish_test_hooks` barrier: + /// the stale publisher is held right after it reads `{target}`; the + /// unarchive and the compliant `{}` publish then run; only then is the stale + /// publisher released to attempt its write. Its post-insert + /// `snapshot_is_current` drift check sees canonical `{}` ≠ its `{target}` + /// snapshot, so it rebuilds and converges. RED-on-revert: replace that guard + /// with `let snapshot_is_current = true;` and the released stale publisher + /// commits `{target}` last, stranding the unarchived identity. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_archival_publishers_converge_on_canonical_state() { + let Some(pool) = test_pool().await else { + return; + }; + if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1") + .execute(&pool) + .await + .is_err() + { + return; + } + let Some(state) = test_state(pool.clone()).await else { + return; + }; + let tenant = seed_test_community(&pool).await; + let target_hex = Keys::generate().public_key().to_hex(); + let request_id = "b".repeat(64); + + // canonical -> {target} + state + .db + .archive( + tenant.community(), + &target_hex, + "self", + &target_hex, + None, + None, + &request_id, + ) + .await + .expect("archive identity"); + + // Arm the barrier, then spawn the stale publisher. It reads the + // `{target}` view, reaches the hook, and blocks until released. + let (reached_hook, release) = + crate::handlers::side_effects::publish_test_hooks::arm(tenant.community()); + let stale_tenant = tenant.clone(); + let stale_state = state.clone(); + let stale_publisher = + tokio::spawn( + async move { publish_nipia_archival_list(&stale_tenant, &stale_state).await }, + ); + // Deterministically wait until the stale publisher has read `{target}`. + reached_hook + .await + .expect("stale publisher reached the post-list_archived hook"); + + // canonical -> {} while the stale publisher holds its `{target}` view. + state + .db + .unarchive(tenant.community(), &target_hex) + .await + .expect("unarchive identity"); + // Production publishes after every archive-state mutation; do the same. + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish after unarchive"); + + // Release the stale publisher: it must detect drift and converge on `{}`. + release.notify_one(); + stale_publisher + .await + .expect("join stale publisher") + .expect("stale publisher converges without error"); + + let final_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query final snapshot") + .into_iter() + .next() + .expect("final snapshot exists"); + + assert!( + !final_snapshot.event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.first().map(String::as_str) == Some("p") + && fields.get(1).map(String::as_str) == Some(target_hex.as_str()) + }), + "a stale publisher must converge on the canonical empty set, never \ + strand the unarchived identity in the authoritative 13535" + ); + } + #[tokio::test] async fn owner_archive_rejects_stale_request_after_live_kind0_owner_flip() { let Some(pool) = test_pool().await else { diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 282ea77657..f2b58937ab 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3116,6 +3116,61 @@ pub async fn reconcile_channel_events( Ok(()) } +/// Test-only barrier hooks for [`publish_nipia_archival_list`]. Lets a test +/// hold one publisher after it has read canonical archive state and before it +/// replaces the head, making the stale-read/late-write race deterministic. +/// Compiled only under `cfg(test)`; the production call site is `#[cfg(test)]`. +/// +/// The gate is scoped to a `CommunityId`: only a publisher whose tenant matches +/// the armed community is held. Publishers from other tenants — the rapid +/// archive/unarchive or owner-archive regressions running in parallel under the +/// Rust test runner — pass straight through and never consume the gate armed +/// for the concurrent-publisher test's unique tenant. +#[cfg(test)] +pub(crate) mod publish_test_hooks { + use buzz_core::tenant::CommunityId; + use std::sync::{Arc, Mutex}; + use tokio::sync::{oneshot, Notify}; + + struct Gate { + community: CommunityId, + arrived: oneshot::Sender<()>, + release: Arc, + } + + static GATE: Mutex> = Mutex::new(None); + + /// Arm a one-shot barrier for `community`. Await the returned receiver to + /// learn when the held publisher has reached the hook (i.e. has read + /// canonical state); call `notify_one` on the returned handle to let it + /// proceed. Only the first publisher of the matching community to reach the + /// hook after arming is held; every other publisher passes. + pub(crate) fn arm(community: CommunityId) -> (oneshot::Receiver<()>, Arc) { + let (tx, rx) = oneshot::channel(); + let release = Arc::new(Notify::new()); + *GATE.lock().unwrap() = Some(Gate { + community, + arrived: tx, + release: release.clone(), + }); + (rx, release) + } + + pub(super) async fn after_list_archived(community: CommunityId) { + let gate = { + let mut slot = GATE.lock().unwrap(); + match slot.as_ref() { + Some(gate) if gate.community == community => slot.take(), + _ => None, + } + }; + if let Some(gate) = gate { + let _ = gate.arrived.send(()); + gate.release.notified().await; + } + } +} + /// Publish a kind:13535 archived identities list event (NIP-IA). /// /// Queries all current archived identities and emits a relay-signed, @@ -3124,29 +3179,76 @@ pub async fn publish_nipia_archival_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - let archived = state.db.list_archived(tenant.community()).await?; - let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + const MAX_REPLACEMENT_ATTEMPTS: usize = 8; + let relay_pubkey = state.relay_keypair.public_key(); + let relay_pubkey_hex = relay_pubkey.to_hex(); - let mut tags: Vec = Vec::with_capacity(archived.len() + 1); - tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + // A concurrent archive mutation can race between reading the current head and + // replacing it. Rebuild from canonical state on rejection so an older snapshot + // can never strand the final archive set. + for _ in 0..MAX_REPLACEMENT_ATTEMPTS { + let archived = state.db.list_archived(tenant.community()).await?; + // Test-only barrier: lets a test hold a stale publisher here — after it + // has read canonical state, before it replaces the head — so the + // stale-read/late-write ordering the drift check must catch is + // deterministic, not scheduler-dependent. Inert in production. + #[cfg(test)] + publish_test_hooks::after_list_archived(tenant.community()).await; + let mut tags: Vec = Vec::with_capacity(archived.len() + 1); + tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); - for identity in &archived { - tags.push( - Tag::parse(["p", &identity.pubkey]) - .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, - ); - } + for identity in &archived { + tags.push( + Tag::parse(["p", &identity.pubkey]) + .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, + ); + } - let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") - .tags(tags) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; + // NIP-16 resolves same-second replacements by event id. Force this + // canonical snapshot strictly past the current head instead of letting a + // rapid archive→unarchive randomly preserve the stale archive state. + let now = nostr::Timestamp::now().as_secs(); + let previous = state + .db + .query_events(&buzz_db::event::EventQuery { + kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(relay_pubkey.to_bytes().to_vec()), + limit: Some(1), + global_only: true, + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + let created_at = previous + .first() + .map(|event| (event.event.created_at.as_secs() + 1).max(now)) + .unwrap_or(now); + + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; + + let (stored, was_inserted) = state + .db + .replace_addressable_event(tenant.community(), &event, None) + .await?; + if !was_inserted { + continue; + } + + let current_archived = state.db.list_archived(tenant.community()).await?; + let snapshot_is_current = + archived + .iter() + .map(|identity| identity.pubkey.as_str()) + .eq(current_archived + .iter() + .map(|identity| identity.pubkey.as_str())); + if !snapshot_is_current { + continue; + } - let (stored, was_inserted) = state - .db - .replace_addressable_event(tenant.community(), &event, None) - .await?; - if was_inserted { dispatch_persistent_event( tenant, state, @@ -3156,13 +3258,16 @@ pub async fn publish_nipia_archival_list( None, ) .await; + info!( + archived_count = archived.len(), + "NIP-IA archived identities list published" + ); + return Ok(()); } - info!( - archived_count = archived.len(), - "NIP-IA archived identities list published" - ); - Ok(()) + anyhow::bail!( + "failed to publish kind:{KIND_IA_ARCHIVED_LIST} after {MAX_REPLACEMENT_ATTEMPTS} concurrent replacements" + ) } /// NIP-DV: publish the relay-signed, per-viewer DM visibility snapshot for diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index e056ff736a..97c31c2561 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -255,9 +255,6 @@ 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 - // - `buzz:workflow-owner` tag names the workflow owner explicitly, - // so consumers (e.g. the ACP inbound author gate) can attribute - // the message without inferring ownership from `p`-tag order // - one `p` tag per `@Name` that resolves to a channel member, // so mentioned agents are woken (wake is `p`-tag gated) let mut tags = vec![ @@ -267,8 +264,6 @@ 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 `@Name` mentions to channel-member pubkeys and append a @@ -712,18 +707,5 @@ mod integration_tests { p_tag_targets.contains(&agent_hex.as_str()), "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); - - let owner_tag = stored - .event - .tags - .iter() - .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow-owner")) - .and_then(|t| t.as_slice().get(1).map(|s| s.as_str())); - assert_eq!( - owner_tag, - Some(author_hex.as_str()), - "workflow owner must be named explicitly via buzz:workflow-owner \ - so consumers never infer ownership from p-tag order" - ); } } diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index fe8b477ba4..5c17b34e10 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -887,6 +887,7 @@ async fn should_fire_workflow( ) -> bool { if let TriggerDef::ReactionAdded { emoji: Some(ref expected), + .. } = def.trigger { if &trigger_ctx.emoji != expected { @@ -900,33 +901,13 @@ async fn should_fire_workflow( } } - if let TriggerDef::MessagePosted { - filter: Some(ref expr), - } = def.trigger - { - match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await { - Ok(true) => {} - Ok(false) => { - tracing::debug!( - workflow_id = %workflow_id, - "Trigger filter evaluated false — skipping workflow" - ); - return false; - } - Err(e) => { - tracing::warn!( - workflow_id = %workflow_id, - "Trigger filter error: {e} — skipping workflow" - ); - return false; - } - } - } - - if let TriggerDef::DiffPosted { - filter: Some(ref expr), - } = def.trigger - { + let filter = match &def.trigger { + TriggerDef::MessagePosted { filter } + | TriggerDef::ReactionAdded { filter, .. } + | TriggerDef::DiffPosted { filter } => filter.as_ref(), + TriggerDef::Schedule { .. } | TriggerDef::Webhook => None, + }; + if let Some(expr) = filter { match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await { Ok(true) => {} Ok(false) => { @@ -1364,7 +1345,10 @@ steps: #[test] fn trigger_matches_reaction() { - let trigger = TriggerDef::ReactionAdded { emoji: None }; + let trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; assert!(trigger_matches_event( &trigger, buzz_core::kind::KIND_REACTION @@ -1375,6 +1359,36 @@ steps: )); } + #[tokio::test] + async fn reaction_filter_matches_target_message() { + let yaml = r#" +name: "React to one message" +trigger: + on: reaction_added + filter: 'trigger_message_id == "target-message"' +steps: + - id: wait + action: delay + duration: 1s +"#; + let (def, _) = WorkflowEngine::parse_yaml(yaml).expect("parse failed"); + let mut trigger_ctx = executor::TriggerContext { + message_id: "target-message".to_owned(), + ..Default::default() + }; + + assert!( + should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await, + "reaction to the selected message should fire" + ); + + trigger_ctx.message_id = "different-message".to_owned(); + assert!( + !should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await, + "reaction to a different message should be filtered out" + ); + } + #[test] fn schedule_trigger_never_matches_events() { let trigger = TriggerDef::Schedule { @@ -1421,7 +1435,10 @@ steps: #[test] fn reaction_added_matches_kind_7_only() { - let trigger = TriggerDef::ReactionAdded { emoji: None }; + let trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; // Must match KIND_REACTION = 7. assert!(trigger_matches_event(&trigger, 7)); // Must NOT match stream message (kind 9). @@ -1436,6 +1453,7 @@ steps: // trigger_matches_event only checks the kind number. let trigger = TriggerDef::ReactionAdded { emoji: Some("thumbsup".to_owned()), + filter: None, }; assert!(trigger_matches_event(&trigger, 7)); assert!(!trigger_matches_event(&trigger, 9)); @@ -1458,7 +1476,10 @@ steps: // before calling trigger_matches_event, but verify the function itself // also returns false for these kinds. let msg_trigger = TriggerDef::MessagePosted { filter: None }; - let react_trigger = TriggerDef::ReactionAdded { emoji: None }; + let react_trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; for kind in buzz_core::kind::KIND_WORKFLOW_TRIGGERED ..=buzz_core::kind::KIND_WORKFLOW_APPROVAL_DENIED @@ -1478,7 +1499,10 @@ steps: fn trigger_matches_event_kind_zero_matches_nothing() { // Kind 0 is a profile event — no trigger should match it. let msg_trigger = TriggerDef::MessagePosted { filter: None }; - let react_trigger = TriggerDef::ReactionAdded { emoji: None }; + let react_trigger = TriggerDef::ReactionAdded { + emoji: None, + filter: None, + }; let sched_trigger = TriggerDef::Schedule { cron: None, interval: Some("1h".to_owned()), @@ -1715,7 +1739,11 @@ steps: async fn setup_db() -> buzz_db::Db { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + // Local-only test default; this is not a production credential. + .unwrap_or_else(|_| { + let local_test_database = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + local_test_database.to_owned() + }); buzz_db::Db::new(&buzz_db::DbConfig { database_url, ..Default::default() diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 9bc79aa48b..afee730d60 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -47,6 +47,9 @@ pub enum TriggerDef { /// Optional: only fire for this specific emoji. #[serde(default)] emoji: Option, + /// Optional evalexpr filter over the reaction context. + #[serde(default)] + filter: Option, }, /// Fires when a diff message (kind:40008) is posted in the workflow's channel. DiffPosted { @@ -300,11 +303,12 @@ mod tests { #[test] fn parse_reaction_added_trigger() { - let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n"; + let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\n filter: 'trigger_message_id == \"abc123\"'\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n"; let (def, _) = parse_yaml(yaml).expect("parse failed"); match &def.trigger { - TriggerDef::ReactionAdded { emoji } => { + TriggerDef::ReactionAdded { emoji, filter } => { assert_eq!(emoji.as_deref(), Some("clipboard")); + assert_eq!(filter.as_deref(), Some("trigger_message_id == \"abc123\"")); } other => panic!("unexpected trigger: {other:?}"), } @@ -488,8 +492,9 @@ mod tests { let yaml = "name: Any Reaction\ntrigger:\n on: reaction_added\nsteps:\n - id: s1\n action: add_reaction\n emoji: eyes\n"; let (def, _) = parse_yaml(yaml).expect("parse failed"); match &def.trigger { - TriggerDef::ReactionAdded { emoji } => { + TriggerDef::ReactionAdded { emoji, filter } => { assert!(emoji.is_none(), "emoji should default to None"); + assert!(filter.is_none(), "filter should default to None"); } other => panic!("unexpected trigger: {other:?}"), } diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da91..b1a2d5623b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -73,6 +73,9 @@ export default defineConfig({ "**/relay-reconnect.spec.ts", "**/relay-reconnect-affordance.spec.ts", "**/workflows.spec.ts", + "**/workflow-reaction-picker.spec.ts", + "**/workflow-local-controls.spec.ts", + "**/workflow-title-stability.spec.ts", "**/identity-archive.spec.ts", "**/identity-archive-hide.spec.ts", "**/relay-connectivity.spec.ts", @@ -118,6 +121,7 @@ export default defineConfig({ "**/drafts-all-fix-screenshots.spec.ts", "**/inbox-refactor-screenshots.spec.ts", "**/buzz-theme-screenshots.spec.ts", + "**/appearance-previews.spec.ts", "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", "**/deep-link-invite.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 081e345edb..5bd19ef2b6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1097,6 +1097,7 @@ dependencies = [ "buzz-sdk", "buzz-terminal", "buzz-voice", + "buzz-ws-client", "bytes", "bzip2 0.6.1", "chrono", @@ -1253,6 +1254,20 @@ dependencies = [ "tokenizers", ] +[[package]] +name = "buzz-ws-client" +version = "0.1.0" +dependencies = [ + "futures-util", + "nostr 0.44.7", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.29.0", + "tracing", + "url", +] + [[package]] name = "by_address" version = "1.2.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 01504852b6..db089fc13f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -108,6 +108,7 @@ buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } +buzz_ws_client_pkg = { package = "buzz-ws-client", path = "../../crates/buzz-ws-client" } portable-pty = "0.9" iroh = { version = "1.0.2", optional = true } mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } diff --git a/desktop/src-tauri/src/app_menu.rs b/desktop/src-tauri/src/app_menu.rs index e6d7944a10..71c0360ec5 100644 --- a/desktop/src-tauri/src/app_menu.rs +++ b/desktop/src-tauri/src/app_menu.rs @@ -5,23 +5,19 @@ //! `close_window` item in both the File and Window submenus, and muda gives //! that item a Cmd+W key equivalent bound to `performClose:`. //! -//! Two consequences, both wrong for Buzz: +//! That default cannot express Buzz's context-dependent behavior: //! //! 1. `CloseRequested` on the main window is intercepted in `lib.rs` and turned -//! into hide-to-tray, so Cmd+W never closed a window -- it hid the whole -//! app. That is already redundant with Cmd+H (Hide), which stays. +//! into hide-to-tray. Cmd+W should take that path in normal Buzz mode. //! 2. macOS resolves a menu key equivalent before the webview receives any key //! event, so Buzz Term could never bind Cmd+W to "close this terminal tab" //! while the accelerator was claimed here. //! //! So this module builds the standard menu minus both `close_window` items. -//! Everything else matches `Menu::default()` deliberately: the goal is to drop -//! one item, not to design a menu. -//! -//! If hide-on-Cmd+W is ever wanted back in Buzz mode, the revisit path is to -//! restore the item and disable it while the terminal owns input (a disabled -//! item does not consume its key equivalent) -- at the cost of an owner->Rust -//! IPC hop this approach does not need. +//! Everything else matches `Menu::default()` deliberately. The webview routes +//! Cmd+W conditionally instead: Buzz Term consumes it in capture phase while +//! it owns input, and `useCloseWindowShortcut` closes the current window in +//! normal Buzz mode. #[cfg(target_os = "macos")] use tauri::menu::{ diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index d1784c01fa..5f5019cd20 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,14 +2,13 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16, AtomicU8}, + atomic::{AtomicBool, AtomicU16, AtomicU64, AtomicU8}, Arc, Mutex, }, }; use nostr::{Keys, ToBech32}; use tauri::{AppHandle, Manager}; -#[cfg(feature = "mesh-llm")] use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; @@ -32,12 +31,10 @@ pub struct AppState { /// response (surfaced as an error) so the auth token never leaves the /// validated relay origin. pub media_fetch_client: reqwest::Client, - /// Workspace-provided relay URL override. Set by `apply_workspace` on app - /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, - /// Set during backend setup when managed agents are eligible for launch - /// restore. `apply_workspace` consumes it after installing the workspace - /// relay and identity, so agents never start against the fallback relay. + pub workspace_apply_lock: Arc>, + pub workspace_apply_generation: AtomicU64, + /// Defers managed-agent restore until `apply_workspace` installs relay and identity. pub managed_agent_restore_pending: AtomicBool, /// Disabled by agent-managed profiles so agent profile updates survive start/restore. pub managed_agent_profile_reconcile_enabled: AtomicBool, @@ -206,6 +203,8 @@ pub fn build_app_state() -> AppState { header across origins (redirect-hop SSRF)", ), relay_url_override: Mutex::new(None), + workspace_apply_lock: Arc::new(AsyncMutex::new(())), + workspace_apply_generation: AtomicU64::new(0), managed_agent_restore_pending: AtomicBool::new(false), managed_agent_profile_reconcile_enabled: AtomicBool::new(true), shutdown_started: AtomicBool::new(false), @@ -268,33 +267,6 @@ impl AppState { } } - /// Record that `channel_id` was just created by `creator_pubkey` and its - /// kind:39002 owner membership has not yet been observed. - pub fn mark_pending_owned_channel(&self, creator_pubkey: &str, channel_id: &str) { - if let Ok(mut set) = self.pending_owned_channels.lock() { - set.insert((creator_pubkey.to_string(), channel_id.to_string())); - } - } - - /// Whether `channel_id` is still awaiting `my_pubkey`'s kind:39002 entry. - /// Bound to `my_pubkey` so an in-process identity swap never inherits - /// another identity's pending-owner entry for the same channel id. - pub fn is_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) -> bool { - self.pending_owned_channels - .lock() - .map(|set| set.contains(&(my_pubkey.to_string(), channel_id.to_string()))) - .unwrap_or(false) - } - - /// Drop the `(my_pubkey, channel_id)` entry from the pending-owner - /// overlay once that identity's real kind:39002 membership has been - /// observed. - pub fn clear_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) { - if let Ok(mut set) = self.pending_owned_channels.lock() { - set.remove(&(my_pubkey.to_string(), channel_id.to_string())); - } - } - /// Return the active identity keys if they are in a signable state. /// /// Returns `Err` when the identity is in a lost state (`identity_lost` @@ -392,6 +364,9 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( mod keyring_config; pub(crate) use keyring_config::keyring_service; +#[path = "app_state_pending_channels.rs"] +mod pending_channels; + /// Keyring key name for the human identity nsec. const IDENTITY_KEY_NAME: &str = "identity"; diff --git a/desktop/src-tauri/src/app_state_pending_channels.rs b/desktop/src-tauri/src/app_state_pending_channels.rs new file mode 100644 index 0000000000..ec4516b2e9 --- /dev/null +++ b/desktop/src-tauri/src/app_state_pending_channels.rs @@ -0,0 +1,55 @@ +//! Pending-owner channel overlay for [`AppState`]. +//! +//! A channel this identity just created via `create_channel` is relay-signed +//! (kind:39000), so its kind:39002 owner membership does not land immediately. +//! Until it does, the `(creator_pubkey, channel_id)` overlay keeps the channel +//! classified `is_member=true` without an all-open directory scan (#1761). The +//! set is keyed by pubkey so an in-process identity swap never inherits another +//! identity's entry, and entries clear once real membership is observed. + +use crate::app_state::AppState; + +impl AppState { + /// Record that `channel_id` was just created by `creator_pubkey` and its + /// kind:39002 owner membership has not yet been observed. + pub fn mark_pending_owned_channel(&self, creator_pubkey: &str, channel_id: &str) { + if let Ok(mut set) = self.pending_owned_channels.lock() { + set.insert((creator_pubkey.to_string(), channel_id.to_string())); + } + } + + /// Whether `channel_id` is still awaiting `my_pubkey`'s kind:39002 entry. + /// Bound to `my_pubkey` so an in-process identity swap never inherits + /// another identity's pending-owner entry for the same channel id. + pub fn is_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) -> bool { + self.pending_owned_channels + .lock() + .map(|set| set.contains(&(my_pubkey.to_string(), channel_id.to_string()))) + .unwrap_or(false) + } + + /// Channel ids `my_pubkey` created whose kind:39002 membership has not yet + /// been observed. The member-only channel poll unions these with the real + /// member set so a just-created channel stays visible without an all-open + /// directory scan (#1761). + pub fn pending_owned_channel_ids(&self, my_pubkey: &str) -> Vec { + self.pending_owned_channels + .lock() + .map(|set| { + set.iter() + .filter(|(owner, _)| owner == my_pubkey) + .map(|(_, channel_id)| channel_id.clone()) + .collect() + }) + .unwrap_or_default() + } + + /// Drop the `(my_pubkey, channel_id)` entry from the pending-owner + /// overlay once that identity's real kind:39002 membership has been + /// observed. + pub fn clear_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) { + if let Ok(mut set) = self.pending_owned_channels.lock() { + set.remove(&(my_pubkey.to_string(), channel_id.to_string())); + } + } +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 9f1458e96f..81bc213352 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -22,6 +22,7 @@ mod metric_store; mod pipeline; pub mod store; mod store_migrations; +pub mod sync; use pipeline::{commit_archive, plan_archive, query_buckets}; @@ -150,8 +151,20 @@ pub async fn archive_events( state: State<'_, AppState>, candidates: Vec, ) -> Result { - let identity_pk = identity_pubkey(&state)?; - let relay_url = relay_ws_url_with_override(&state); + archive_candidates(&state, candidates).await +} + +/// The body of [`archive_events`], callable without a command invocation. +/// +/// The native sync task archives through this directly: routing its batches +/// back out to the renderer just to have the renderer invoke the command would +/// reintroduce the IPC round trip the move exists to delete. +pub(crate) async fn archive_candidates( + state: &AppState, + candidates: Vec, +) -> Result { + let identity_pk = identity_pubkey(state)?; + let relay_url = relay_ws_url_with_override(state); let now = now_secs(); // ── Phase 1: plan (blocking SQLite) ───────────────────────────────────── @@ -163,8 +176,7 @@ pub async fn archive_events( .await?; // ── Phase 2: relay queries (async) ─────────────────────────────────────── - let state_ref: &AppState = &state; - let bucket_results = query_buckets(plan.buckets, state_ref).await; + let bucket_results = query_buckets(plan.buckets, state).await; // ── Phase 3: persist (blocking SQLite) ────────────────────────────────── let owner_keys = { @@ -286,6 +298,7 @@ fn validate_ephemeral_frame( #[tauri::command] pub async fn create_save_subscription( state: State<'_, AppState>, + sync_state: State<'_, sync::ArchiveSyncState>, scope_type: ScopeType, scope_value: String, kinds: Vec, @@ -333,7 +346,9 @@ pub async fn create_save_subscription( &scope_value, &kinds_json, now, - ) + )?; + sync_state.notify_subscriptions_changed().await; + Ok(()) } /// Probe: the current user has access to `channel_id` (kind 39002 lists them). @@ -426,6 +441,7 @@ async fn probe_event_readable(state: &AppState, event_id: &str) -> Result<(), St #[tauri::command] pub async fn merge_save_subscription_kinds( state: State<'_, AppState>, + sync_state: State<'_, sync::ArchiveSyncState>, kind: u32, ) -> Result<(), String> { if kind > u32::from(u16::MAX) { @@ -439,7 +455,9 @@ pub async fn merge_save_subscription_kinds( run_archive_db_task(move |conn| { store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now) }) - .await + .await?; + sync_state.notify_subscriptions_changed().await; + Ok(()) } // ── remove_save_subscription_kind ──────────────────────────────────────────── @@ -459,6 +477,7 @@ pub async fn merge_save_subscription_kinds( #[tauri::command] pub async fn remove_save_subscription_kind( state: State<'_, AppState>, + sync_state: State<'_, sync::ArchiveSyncState>, kind: u32, ) -> Result<(), String> { if kind > u32::from(u16::MAX) { @@ -471,7 +490,9 @@ pub async fn remove_save_subscription_kind( run_archive_db_task(move |conn| { store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind) }) - .await + .await?; + sync_state.notify_subscriptions_changed().await; + Ok(()) } // ── list_save_subscriptions ────────────────────────────────────────────────── @@ -496,12 +517,13 @@ pub async fn list_save_subscriptions( #[tauri::command] pub async fn delete_save_subscription( state: State<'_, AppState>, + sync_state: State<'_, sync::ArchiveSyncState>, scope_type: ScopeType, scope_value: String, ) -> Result { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - run_archive_db_task(move |conn| { + let removed = run_archive_db_task(move |conn| { store::delete_save_subscription( conn, &identity_pk, @@ -510,7 +532,11 @@ pub async fn delete_save_subscription( &scope_value, ) }) - .await + .await?; + if removed { + sync_state.notify_subscriptions_changed().await; + } + Ok(removed) } // ── read_archived_events ───────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/archive/sync.rs b/desktop/src-tauri/src/archive/sync.rs new file mode 100644 index 0000000000..4fe4452c81 --- /dev/null +++ b/desktop/src-tauri/src/archive/sync.rs @@ -0,0 +1,615 @@ +//! Rust archive sync task — the backend replacement for the renderer's +//! `archiveSyncManager`. +//! +//! Opens one live relay subscription per saved archive config and forwards +//! matched events to the existing archive pipeline in debounced batches. The +//! renderer no longer sees archive traffic at all: previously every matched +//! event crossed the IPC boundary twice (relay -> renderer, renderer -> +//! `archive_events`) purely to be written to a SQLite file the backend owns. +//! +//! # Start gate +//! +//! The task is NOT self-starting. Kind 24200 is relay-*ephemeral*: frames that +//! arrive before the listener opens are permanently lost, so the renderer must +//! finish observer reconciliation (which seeds kind 24200 into the owner_p +//! subscription) before any listener opens. That ordering is the whole reason +//! `useArchiveSync` gated on `observerReconciled`, and it survives the move as +//! an explicit `start_archive_sync` command issued after the same gate. + +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration}; + +use nostr::JsonUtil; +use serde_json::json; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::{ + sync::{mpsc, Mutex, Notify}, + time::Instant, +}; +use tokio_util::sync::CancellationToken; + +use super::{ + store::SaveSubscription, ArchiveBatchResult, ArchiveCandidate, MatchedScope, ScopeType, +}; +use crate::app_state::AppState; +use crate::native_relay_client::{MatchedEvent, NativeRelayClient, RelaySession, Subscription}; + +/// Flush once this many events are buffered. Parity with the renderer manager. +const FLUSH_BATCH_SIZE: usize = 25; +/// Maximum time an event waits in the buffer before being flushed. +/// +/// This is a deadline measured from the FIRST buffered event, not an idle +/// timer that each arrival extends. The renderer constant was named +/// `FLUSH_IDLE_MS`, but its `scheduleFlush` returned early when a timer was +/// already pending, so a steady trickle still flushed every 2s rather than +/// never. The behavior is preserved; the name is corrected. +const FLUSH_DEADLINE: Duration = Duration::from_millis(2_000); + +/// Emitted after a batch persists new agent-metric rows, so the renderer can +/// invalidate its usage queries. Replaces the in-process `notifyAgentMetrics +/// Changed()` call the manager made on the JS side of that same batch. +const AGENT_METRICS_CHANGED_EVENT: &str = "archive-agent-metrics-changed"; + +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Everything the sync loop needs from the outside world. +/// +/// Injected rather than reached for so the loop's batching, demultiplexing, +/// and reload behavior are testable without a relay, a database, or a Tauri +/// app handle. +pub(crate) trait ArchiveSyncIo: Send + Sync + 'static { + fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>>; + fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()>; + fn archive( + &self, + candidates: Vec, + ) -> BoxFuture<'_, Result>; + fn notify_agent_metrics_changed(&self); +} + +// ── Subscription planning ──────────────────────────────────────────────────── + +/// The relay subscription set for `subscriptions`, plus the scope each +/// subscription id maps back to when its events arrive. +/// +/// The id encodes scope AND kinds, so a kinds change produces a different id: +/// the session then closes the old subscription and opens the new one instead +/// of leaving a stale filter live. Same reason the renderer keyed on both. +fn plan_subscriptions( + subscriptions: &[SaveSubscription], +) -> (Vec, HashMap) { + let mut planned = Vec::new(); + let mut scopes = HashMap::new(); + + for sub in subscriptions { + let Some(scope_type) = parse_scope_type(&sub.scope_type) else { + eprintln!( + "buzz-desktop: archive sync: unknown scope_type {:?}, skipping", + sub.scope_type + ); + continue; + }; + // A malformed `kinds` column decodes as empty, matching the renderer + // decoder. The resulting filter matches nothing, which is the correct + // failure for a row we cannot interpret: archive nothing, drop nothing. + let kinds: Vec = serde_json::from_str(&sub.kinds).unwrap_or_default(); + let id = subscription_id(&scope_type, &sub.scope_value, &kinds); + if scopes.contains_key(&id) { + continue; + } + planned.push(Subscription { + id: id.clone(), + filter: build_filter(&scope_type, &sub.scope_value, &kinds), + }); + scopes.insert( + id, + MatchedScope { + scope_type, + scope_value: sub.scope_value.clone(), + }, + ); + } + + (planned, scopes) +} + +fn parse_scope_type(raw: &str) -> Option { + match raw { + "channel_h" => Some(ScopeType::ChannelH), + "owner_p" => Some(ScopeType::OwnerP), + "referenced_e" => Some(ScopeType::ReferencedE), + _ => None, + } +} + +/// `limit: 0` — live tail only. Stored events are archived by the explicit +/// backfill paths, so a non-zero limit would re-deliver history on every +/// reconnect. +fn build_filter(scope_type: &ScopeType, scope_value: &str, kinds: &[u64]) -> serde_json::Value { + let tag = match scope_type { + ScopeType::ChannelH => "#h", + ScopeType::OwnerP => "#p", + ScopeType::ReferencedE => "#e", + }; + json!({ "kinds": kinds, "limit": 0, tag: [scope_value] }) +} + +fn subscription_id(scope_type: &ScopeType, scope_value: &str, kinds: &[u64]) -> String { + let mut sorted = kinds.to_vec(); + sorted.sort_unstable(); + let kinds = sorted + .iter() + .map(|k| k.to_string()) + .collect::>() + .join(","); + format!("archive:{}:{scope_value}:{kinds}", scope_type.as_str()) +} + +// ── Batching ───────────────────────────────────────────────────────────────── + +/// Buffered candidates plus the deadline of the oldest one. +#[derive(Default)] +struct PendingBatch { + candidates: Vec, + /// Set when the buffer goes from empty to non-empty, cleared on take. The + /// deadline belongs to the oldest buffered event, so a steady trickle of + /// arrivals cannot postpone its flush indefinitely. + deadline: Option, +} + +impl PendingBatch { + fn push(&mut self, candidate: ArchiveCandidate) { + if self.candidates.is_empty() { + self.deadline = Some(Instant::now() + FLUSH_DEADLINE); + } + self.candidates.push(candidate); + } + + fn is_full(&self) -> bool { + self.candidates.len() >= FLUSH_BATCH_SIZE + } + + fn take(&mut self) -> Vec { + self.deadline = None; + std::mem::take(&mut self.candidates) + } +} + +// ── Sync loop ──────────────────────────────────────────────────────────────── + +/// Drives one archive sync session until `cancel` fires. +/// +/// Reload requests coalesce: `Notify::notify_one` stores at most one permit, so +/// any number of subscription changes arriving during a reload produce exactly +/// one follow-up pass — the same guarantee the renderer's single-flight +/// `reloadPending` loop provided, without the bookkeeping. +async fn run_sync( + io: &I, + reload: Arc, + mut events: mpsc::Receiver, + cancel: CancellationToken, +) { + let mut scopes: HashMap = HashMap::new(); + let mut pending = PendingBatch::default(); + + reconcile(io, &mut scopes).await; + + loop { + // `Instant::far_future()` is not public; a long sleep stands in for + // "no deadline" so the select arm can be unconditional. + let deadline = pending + .deadline + .unwrap_or_else(|| Instant::now() + Duration::from_secs(3600)); + + tokio::select! { + _ = cancel.cancelled() => break, + _ = reload.notified() => { + reconcile(io, &mut scopes).await; + } + _ = tokio::time::sleep_until(deadline), if pending.deadline.is_some() => { + flush(io, pending.take()).await; + } + received = events.recv() => { + let Some(event) = received else { break }; + // A subscription we already closed can still have events in + // flight; without its scope we cannot assert a match, and the + // backend re-verifies scope claims anyway, so drop it. + let Some(scope) = scopes.get(&event.subscription_id) else { continue }; + pending.push(ArchiveCandidate { + raw_event_json: event.event.as_json(), + matched_scope: MatchedScope { + scope_type: scope.scope_type.clone(), + scope_value: scope.scope_value.clone(), + }, + }); + if pending.is_full() { + flush(io, pending.take()).await; + } + } + } + } + + // Buffered events are already off the relay; dropping them on shutdown + // would lose them permanently for the ephemeral scope. + flush(io, pending.take()).await; +} + +/// Reloads the saved subscriptions and applies them to the session. +/// +/// A failed load leaves the previous set live rather than tearing everything +/// down: a transient SQLite error must not silently stop archiving. +async fn reconcile(io: &I, scopes: &mut HashMap) { + let subscriptions = match io.list_subscriptions().await { + Ok(subscriptions) => subscriptions, + Err(error) => { + eprintln!("buzz-desktop: archive sync: list_save_subscriptions failed: {error}"); + return; + } + }; + let (planned, next_scopes) = plan_subscriptions(&subscriptions); + io.set_subscriptions(planned).await; + *scopes = next_scopes; +} + +/// Awaited rather than spawned: back-pressure through the session's bounded +/// event channel is what keeps a catch-up storm from queueing unbounded +/// archive work. The renderer's fire-and-forget was a property of living in +/// an event loop it could not block, not a behavior worth porting. +async fn flush(io: &I, candidates: Vec) { + if candidates.is_empty() { + return; + } + match io.archive(candidates).await { + // The backend is authoritative: a duplicate-only batch or one with no + // kind-44200 events must not invalidate usage queries. + Ok(result) if result.persisted_agent_metrics > 0 => io.notify_agent_metrics_changed(), + Ok(_) => {} + Err(error) => eprintln!("buzz-desktop: archive sync: archive_events failed: {error}"), + } +} + +// ── Production wiring ──────────────────────────────────────────────────────── + +struct AppIo { + app: AppHandle, + session: Arc, +} + +impl ArchiveSyncIo for AppIo { + fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>> { + Box::pin(async move { + let state: State<'_, AppState> = self.app.state(); + let identity_pk = super::identity_pubkey(&state)?; + let relay_url = crate::relay::relay_ws_url_with_override(&state); + super::run_archive_db_task(move |conn| { + super::store::list_save_subscriptions(conn, &identity_pk, &relay_url) + }) + .await + }) + } + + fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()> { + Box::pin(async move { self.session.set_subscriptions(subscriptions).await }) + } + + fn archive( + &self, + candidates: Vec, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let state: State<'_, AppState> = self.app.state(); + super::archive_candidates(&state, candidates).await + }) + } + + fn notify_agent_metrics_changed(&self) { + let _ = self.app.emit(AGENT_METRICS_CHANGED_EVENT, ()); + } +} + +/// Managed handle for the running sync task. +#[derive(Default)] +pub struct ArchiveSyncState { + running: Mutex>, + /// Highest `(epoch, lease)` this process has seen from either command. + /// + /// The renderer allocates leases synchronously in effect order, so they are + /// the app's intent order — which the IPC completion order is not. Both + /// commands ignore anything older, which is what makes a stale cleanup + /// harmless and a delayed start unable to resurrect a stopped task. + /// + /// The epoch is minted here, not in the renderer, because a lease counter + /// only exists for as long as the JS realm that holds it. A renderer reload + /// (`useReloadShortcut`, `RootErrorBoundary`, `useCommunityInit`) resets the + /// counter to zero while this state persists in the Tauri process, so + /// without an epoch the first post-reload start looks older than what the + /// backend already saw and is rejected forever. Ordering lexicographically + /// on `(epoch, lease)` means a newer realm outranks the old one no matter + /// where its local counter restarted. + /// + /// Every boundary the intent-order authority crosses, and why it holds: + /// + /// - effect remount, same realm: leases strictly increase within a realm. + /// - IPC arrival order: the lease is minted before `invoke`, so intent + /// order is fixed before the calls can race. + /// - renderer realm reload: a new epoch from this authority outranks the + /// dead realm's, whatever its counter said. + /// - webview recreation of the owning window: same as reload. + /// - a second window: it does not participate, by ownership rule. Archive + /// sync is app-global and main-window-owned, exactly as the main window + /// remains the owner of microphone capture (see `huddle::window`). + /// Epochs order realms in time; a companion window is a second realm in + /// space, and newest-wins cannot model two concurrent owners — a + /// companion's cleanup would cancel the live main-window task. Secondary + /// realms therefore never announce and never issue lifecycle commands. + /// Any future second-realm mount must revisit this. + /// - Tauri process restart: both clocks die together, so there is nothing + /// to order against. + latest: Mutex<(u64, u64)>, +} + +struct RunningSync { + /// Identity + relay this task is bound to. A start request for the same + /// scope is a no-op, so a renderer remount does not churn the socket. + scope: (String, String), + cancel: CancellationToken, + reload: Arc, +} + +/// Proof that the holder is the current archive-sync owner, and the lock that +/// makes it true. Minted only by [`ArchiveSyncState::begin`], and required by +/// [`NativeRelayClient::archive_session`]. +/// +/// Acquiring the shared relay session has to happen *inside* the ownership +/// critical section, not after it. `NativeRelayClient::ensure_session` shuts +/// down the previous scope's socket and installs its own within its own lock, +/// and `attach_archive` replaces the session's archive event sender outright. +/// Both are destructive on entry, so a superseded start that merely +/// re-validated its mark *after* acquiring would already have torn down the +/// newer owner's session — with nothing to restore it from, since a session is +/// spawned rather than handed back. The damage is done by the call, so the +/// fence has to be around the call. +/// +/// Holding both guards across that acquisition is sound because it performs no +/// I/O: `ensure_session` and `attach_archive` await only mutex acquisitions, +/// `shutdown` is a synchronous cancel, and the socket connects on the task +/// `start_managed` spawns. If either half ever grows an awaited network +/// round-trip, this design must be revisited rather than quietly extended. +/// +/// The lock order through the whole unit is `latest` -> `running` -> `current` +/// -> `archive_events`, and nothing acquires in the reverse direction: +/// [`ArchiveSyncState::end`] and +/// [`ArchiveSyncState::notify_subscriptions_changed`] take the archive locks in +/// the same order and never reach into the session, and the session's own paths +/// (`session`, `fetch_events`, `run_session`) never reach back into archive +/// state. So there is no cycle to deadlock on. +/// +/// **What this token does not cover.** It serializes archive lifecycle against +/// archive lifecycle, and nothing else needs it to: [`NativeRelayClient::session`] +/// — the persona catalog's and unread catch-up's entry point — cannot replace +/// the installed scope at all. It shares the session only on an exact scope +/// match and otherwise leases a private one, so a finite request that arrives +/// while a different scope is installed can neither shut this session down nor +/// steal its archive sender. That is the only reason holding the token across +/// acquisition is sufficient rather than merely necessary; if a second +/// destructive path is ever added, it must take this token too. +/// +/// The fields are private and the type is un-constructible outside this module, +/// so the stale-start path is a compile error rather than a race to remember. +/// Dropping the token releases ownership, which is why the command holds it +/// until the sync task is spawned. +pub(crate) struct ArchiveOwnership<'a> { + /// Field order is the lock order `begin` and `end` both take: `latest`, + /// then `running`. Rust drops fields in declaration order, so releasing + /// mirrors acquiring and the two halves can never interleave. + _latest: tokio::sync::MutexGuard<'a, (u64, u64)>, + _running: tokio::sync::MutexGuard<'a, Option>, +} + +impl ArchiveSyncState { + /// Wakes the sync task so it reloads saved subscriptions. + /// + /// Called by the archive commands that mutate `save_subscriptions`. This + /// replaces the renderer's `onSubscriptionChange` notifier: the mutations + /// were already backend commands, so routing the signal through JS only + /// created a window where a write landed but nothing resubscribed. + pub(super) async fn notify_subscriptions_changed(&self) { + if let Some(running) = self.running.lock().await.as_ref() { + running.reload.notify_one(); + } + } + + /// Mints the epoch a renderer realm must hold before it may issue any + /// lifecycle command, and publishes it as the current mark in the same + /// critical section. + /// + /// A realm has to obtain this *before* its archive effect runs, and the + /// renderer awaits it. If announcing were just another unawaited `invoke` + /// beside the lifecycle calls, it would race them and recreate the + /// arrival-order bug one level up — the epoch would order announcements + /// rather than realms. + /// + /// Minting and publishing are one lock acquisition because announcing is + /// what supersedes the old realm. Holding the epoch counter separately — + /// so a mint could not block an in-flight lifecycle call — leaves a window + /// between mint and first use in which `latest` still names the dead realm, + /// and its delayed `start`/`stop` with any lease still wins. The new realm + /// cannot close that window itself: the reconciliation gate can keep its + /// first lifecycle call arbitrarily far behind its announcement. + /// + /// The published lease is `0`, the one value that outranks every mark the + /// previous realm can hold while still sitting below this realm's own first + /// lease. Publishing higher would reject the announcing realm's own start + /// and leave sync permanently unstarted. + async fn announce(&self) -> u64 { + let mut latest = self.latest.lock().await; + let epoch = latest.0 + 1; + *latest = (epoch, 0); + epoch + } + + /// Takes ownership for a start under `(epoch, lease)`, installing the task + /// when it wins. Returns `None` when the caller must not proceed — either + /// the mark is stale or an equivalent task is already running. + /// + /// The whole ownership policy lives here rather than in the command so the + /// regression tests drive the same code production does. A test that + /// re-implemented "claim, then install" would pass against a command that + /// had stopped calling either one. + /// + /// The mark must be strictly newer than anything seen: that rejects both a + /// start a newer start already superseded, and one delayed past its own + /// stop. Comparison is lexicographic on `(epoch, lease)`, so any call from + /// a superseded realm loses regardless of how far its lease counter ran. + /// + /// The winner receives an [`ArchiveOwnership`] that keeps both guards held, + /// and [`NativeRelayClient::archive_session`] cannot be called without one. + /// Acquiring the shared session is therefore inside this critical section + /// rather than after it — see [`ArchiveOwnership`] for why "revalidate the + /// mark afterwards" cannot work here. + async fn begin( + &self, + mark: (u64, u64), + scope: (String, String), + cancel: CancellationToken, + reload: Arc, + ) -> Option> { + let mut latest = self.latest.lock().await; + if mark <= *latest { + return None; + } + *latest = mark; + + let mut running = self.running.lock().await; + // A same-scope remount keeps its socket: reinstalling would tear down a + // healthy relay session to replace it with an identical one. + if running + .as_ref() + .is_some_and(|current| current.scope == scope) + { + return None; + } + if let Some(previous) = running.take() { + previous.cancel.cancel(); + } + *running = Some(RunningSync { + scope, + cancel, + reload, + }); + Some(ArchiveOwnership { + _latest: latest, + _running: running, + }) + } + + /// Releases ownership for a stop under `(epoch, lease)`, cancelling the + /// running task when it wins. + /// + /// Equality succeeds here, unlike [`Self::begin`]: a stop is the + /// counterpart of the start that minted its lease, so its own mark is + /// exactly the case it must act on. Advancing the mark is what stops a + /// start delayed past its own cleanup from resurrecting the task. + /// + /// The guard is held across the cancellation, exactly as [`Self::begin`] + /// holds it across the install. Releasing it first would reopen the very + /// window this ordering closes: a stop could clear its check, yield, let a + /// newer start install its task, and then cancel that task on resume — + /// stale cleanup stranding the newest owner. Both halves take `latest` then + /// `running`, so the two can never interleave and the order is deadlock-free. + async fn end(&self, mark: (u64, u64)) { + let mut latest = self.latest.lock().await; + if mark < *latest { + return; + } + *latest = mark; + + if let Some(running) = self.running.lock().await.take() { + running.cancel.cancel(); + } + } +} + +/// Announce a renderer realm and obtain its epoch. +/// +/// The renderer awaits this before its archive effect may issue any lifecycle +/// command; see [`ArchiveSyncState::latest`] for the boundaries this closes. +/// Only the main window announces — archive sync is app-global and +/// main-window-owned. +#[tauri::command] +pub async fn announce_archive_sync_epoch( + sync_state: State<'_, ArchiveSyncState>, +) -> Result { + Ok(sync_state.announce().await) +} + +/// Start archive sync for the current identity. +/// +/// Idempotent for the same identity + relay. Issued by the renderer only after +/// observer reconciliation completes — see the module docs for why that gate +/// cannot be moved into the backend. +/// +/// `epoch` identifies the calling realm and `lease` orders this call against +/// that realm's other lifecycle calls; see [`ArchiveSyncState::latest`]. +#[tauri::command] +pub async fn start_archive_sync( + app: AppHandle, + state: State<'_, AppState>, + sync_state: State<'_, ArchiveSyncState>, + relay_client: State<'_, NativeRelayClient>, + epoch: u64, + lease: u64, +) -> Result<(), String> { + let keys = state.signing_keys()?; + let relay_url = crate::relay::relay_ws_url_with_override(&state); + let scope = (keys.public_key().to_hex(), relay_url.clone()); + + // Only cheap handles before `begin`: a start that lost its mark, or a + // same-scope remount, must not open a relay socket just to drop it again. + let cancel = CancellationToken::new(); + let reload = Arc::new(Notify::new()); + let Some(ownership) = sync_state + .begin((epoch, lease), scope, cancel.clone(), Arc::clone(&reload)) + .await + else { + return Ok(()); + }; + + // No NIP-OA auth tag: this is the owner's own session, authenticated as + // the identity itself, exactly like the renderer's relay client. + // + // Inside the ownership critical section, holding `ownership`: acquiring the + // shared session is destructive to whatever scope holds it, so a superseded + // start must not be able to reach this line at all. See [`ArchiveOwnership`]. + let (session, events) = relay_client + .archive_session(relay_url, keys, &ownership) + .await; + + let io = AppIo { + app: app.clone(), + session: Arc::clone(&session), + }; + tauri::async_runtime::spawn(async move { + run_sync(&io, reload, events, cancel).await; + session.set_subscriptions(Vec::new()).await; + }); + Ok(()) +} + +/// Stop archive sync. Mirrors the renderer teardown that ran when the gate +/// closed (identity change, community switch, unmount). +/// +/// `(epoch, lease)` is the mark its own start allocated; a cleanup that has +/// been superseded is a no-op rather than cancelling a newer owner's task. +#[tauri::command] +pub async fn stop_archive_sync( + sync_state: State<'_, ArchiveSyncState>, + epoch: u64, + lease: u64, +) -> Result<(), String> { + sync_state.end((epoch, lease)).await; + Ok(()) +} + +#[cfg(test)] +#[path = "sync_tests.rs"] +mod sync_tests; diff --git a/desktop/src-tauri/src/archive/sync_tests.rs b/desktop/src-tauri/src/archive/sync_tests.rs new file mode 100644 index 0000000000..3a39b5d585 --- /dev/null +++ b/desktop/src-tauri/src/archive/sync_tests.rs @@ -0,0 +1,983 @@ +//! Tests for the native archive sync loop. +//! +//! The loop is driven through the real `run_sync` body with a fake +//! [`ArchiveSyncIo`] and a real event channel, so batching, demultiplexing, +//! reload coalescing, and shutdown flush are exercised as the production task +//! runs them — not as a struct poked directly. + +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use std::sync::Mutex as StdMutex; + +// ── Test doubles ───────────────────────────────────────────────────────────── + +#[derive(Default)] +struct FakeIo { + /// Successive results for `list_subscriptions`; the last one repeats so a + /// reload that outruns the script does not panic. + listings: StdMutex>>, + applied: StdMutex>>, + batches: StdMutex>>, + /// What `archive` returns; drives the notify-on-metrics assertion. + persisted_agent_metrics: StdMutex, + archive_fails: StdMutex, + metrics_notifications: StdMutex, +} + +impl FakeIo { + fn with_listings(listings: Vec>) -> Self { + Self { + listings: StdMutex::new(listings), + ..Default::default() + } + } + + fn applied(&self) -> Vec> { + self.applied.lock().unwrap().clone() + } + + /// Flattened candidates in delivery order, as `(scope_value, event_id)`. + fn archived(&self) -> Vec> { + self.batches + .lock() + .unwrap() + .iter() + .map(|batch| { + batch + .iter() + .map(|c| c.matched_scope.scope_value.clone()) + .collect() + }) + .collect() + } +} + +impl ArchiveSyncIo for FakeIo { + fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>> { + Box::pin(async move { + let mut listings = self.listings.lock().unwrap(); + if listings.is_empty() { + return Ok(Vec::new()); + } + if listings.len() == 1 { + return Ok(listings[0].clone()); + } + Ok(listings.remove(0)) + }) + } + + fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()> { + Box::pin(async move { + self.applied.lock().unwrap().push(subscriptions); + }) + } + + fn archive( + &self, + candidates: Vec, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.batches.lock().unwrap().push(candidates); + if *self.archive_fails.lock().unwrap() { + return Err("archive failed".to_string()); + } + Ok(ArchiveBatchResult { + persisted: 0, + persisted_agent_metrics: *self.persisted_agent_metrics.lock().unwrap(), + dropped: 0, + }) + }) + } + + fn notify_agent_metrics_changed(&self) { + *self.metrics_notifications.lock().unwrap() += 1; + } +} + +/// Yields until `condition` holds, then returns; fails the test if it never +/// does. An unbounded spin turns a broken flush into a HUNG test instead of a +/// failing one — and under a paused clock it also starves tokio's auto-advance, +/// so the deadline that would have masked the bug never even fires. +async fn wait_for(label: &str, mut condition: impl FnMut() -> bool) { + for _ in 0..10_000 { + if condition() { + return; + } + tokio::task::yield_now().await; + } + panic!("timed out waiting for {label}"); +} + +fn saved(scope_type: &str, scope_value: &str, kinds: &str) -> SaveSubscription { + SaveSubscription { + identity_pubkey: "owner".into(), + relay_url: "wss://relay.test".into(), + scope_type: scope_type.into(), + scope_value: scope_value.into(), + kinds: kinds.into(), + created_at: 0, + } +} + +fn matched(subscription_id: &str) -> MatchedEvent { + let event = EventBuilder::new(Kind::Custom(9), "hello") + .tags([Tag::parse(vec!["h", "channel-a"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + MatchedEvent { + subscription_id: subscription_id.to_string(), + event: Box::new(event), + } +} + +/// Runs `run_sync` on a task, handing back the controls the tests drive it +/// with. Every test cancels and joins, so a loop that fails to observe +/// cancellation hangs the test rather than passing silently. +fn spawn_sync( + io: Arc, +) -> ( + mpsc::Sender, + Arc, + CancellationToken, + tokio::task::JoinHandle<()>, +) { + let (tx, rx) = mpsc::channel(64); + let reload = Arc::new(Notify::new()); + let cancel = CancellationToken::new(); + let handle = { + let io = Arc::clone(&io); + let reload = Arc::clone(&reload); + let cancel = cancel.clone(); + tokio::spawn(async move { run_sync(io.as_ref(), reload, rx, cancel).await }) + }; + (tx, reload, cancel, handle) +} + +async fn stop(cancel: CancellationToken, handle: tokio::task::JoinHandle<()>) { + cancel.cancel(); + handle.await.expect("sync task panicked"); +} + +// ── Filter construction ────────────────────────────────────────────────────── + +#[test] +fn filters_match_the_renderer_shape_for_every_scope() { + // Verbatim parity with `buildFilter` in archiveSyncManager.ts: the tag key + // per scope and `limit: 0` are the contract with the relay, and a wrong + // tag key silently archives nothing. + let (planned, scopes) = plan_subscriptions(&[ + saved("channel_h", "channel-a", "[9,40002]"), + saved("owner_p", "owner-pk", "[24200]"), + saved("referenced_e", "event-id", "[1]"), + ]); + + let filters: Vec<_> = planned.iter().map(|s| s.filter.clone()).collect(); + assert_eq!( + filters, + vec![ + json!({ "kinds": [9, 40002], "limit": 0, "#h": ["channel-a"] }), + json!({ "kinds": [24200], "limit": 0, "#p": ["owner-pk"] }), + json!({ "kinds": [1], "limit": 0, "#e": ["event-id"] }), + ] + ); + assert_eq!(scopes.len(), 3); + let scope = &scopes[&planned[0].id]; + assert_eq!(scope.scope_type, ScopeType::ChannelH); + assert_eq!(scope.scope_value, "channel-a"); +} + +#[test] +fn subscription_id_changes_when_kinds_change() { + // The id doubles as the relay subscription id, so a kinds change MUST + // produce a different one — otherwise the session sees the same id with a + // new filter and the old filter can stay live. + let (before, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[9]")]); + let (after, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[9,40002]")]); + assert_ne!(before[0].id, after[0].id); +} + +#[test] +fn subscription_id_is_stable_across_kind_ordering() { + // Same set written in a different order is the same subscription; without + // the sort it would churn the socket on every reload. + let (a, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[40002,9]")]); + let (b, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[9,40002]")]); + assert_eq!(a[0].id, b[0].id); +} + +#[test] +fn unknown_scope_type_is_skipped_not_guessed() { + let (planned, scopes) = plan_subscriptions(&[ + saved("wat", "x", "[9]"), + saved("channel_h", "channel-a", "[9]"), + ]); + assert_eq!(planned.len(), 1); + assert_eq!(scopes.len(), 1); + assert_eq!(scopes[&planned[0].id].scope_value, "channel-a"); +} + +#[test] +fn malformed_kinds_column_yields_a_matchless_filter() { + // Mirrors the renderer decoder: a row we cannot interpret archives + // nothing rather than subscribing to everything. + let (planned, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "not json")]); + assert_eq!( + planned[0].filter, + json!({ "kinds": [], "limit": 0, "#h": ["channel-a"] }) + ); +} + +#[test] +fn duplicate_rows_produce_one_subscription() { + let (planned, _) = plan_subscriptions(&[ + saved("channel_h", "channel-a", "[9]"), + saved("channel_h", "channel-a", "[9]"), + ]); + assert_eq!(planned.len(), 1); +} + +// ── Loop behavior ──────────────────────────────────────────────────────────── + +#[tokio::test] +async fn subscribes_to_saved_configs_on_start() { + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "channel_h", + "channel-a", + "[9]", + )]])); + let (_tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + + // The first reconcile races the cancel; wait for it to land. + wait_for("initial subscribe", || !io.applied().is_empty()).await; + assert_eq!(io.applied()[0].len(), 1); + stop(cancel, handle).await; +} + +#[tokio::test] +async fn reload_signal_resubscribes_with_the_new_set() { + let io = Arc::new(FakeIo::with_listings(vec![ + vec![saved("channel_h", "channel-a", "[9]")], + vec![ + saved("channel_h", "channel-a", "[9]"), + saved("owner_p", "owner-pk", "[24200]"), + ], + ])); + let (_tx, reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + + wait_for("initial subscribe", || !io.applied().is_empty()).await; + reload.notify_one(); + wait_for("resubscribe", || io.applied().len() >= 2).await; + + assert_eq!(io.applied()[1].len(), 2); + stop(cancel, handle).await; +} + +#[tokio::test(start_paused = true)] +async fn flushes_when_the_batch_size_is_reached() { + // Paused clock: the deadline can never fire, so a flush here is the size + // bound and nothing else. Without this the test passes on an off-by-one + // `is_full` — the deadline flushes the same 25 events 2s later and the + // assertion cannot tell the two apart. + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "channel_h", + "channel-a", + "[9]", + )]])); + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + let id = io.applied()[0][0].id.clone(); + + for _ in 0..(FLUSH_BATCH_SIZE - 1) { + tx.send(matched(&id)).await.unwrap(); + } + // One short of the bound: nothing may flush. + wait_for("loop to drain the channel", || { + tx.capacity() == tx.max_capacity() + }) + .await; + assert!( + io.archived().is_empty(), + "flushed before reaching the batch size" + ); + + tx.send(matched(&id)).await.unwrap(); + wait_for("flush", || !io.archived().is_empty()).await; + + // Exactly one batch of exactly FLUSH_BATCH_SIZE — a flush at the wrong + // boundary shows up here as a split or an oversized batch. + let archived = io.archived(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].len(), FLUSH_BATCH_SIZE); + assert!(archived[0].iter().all(|scope| scope == "channel-a")); + stop(cancel, handle).await; +} + +#[tokio::test(start_paused = true)] +async fn flushes_a_partial_batch_after_the_deadline() { + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "channel_h", + "channel-a", + "[9]", + )]])); + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + let id = io.applied()[0][0].id.clone(); + + tx.send(matched(&id)).await.unwrap(); + // Just under the deadline: still buffered. + tokio::time::sleep(FLUSH_DEADLINE - Duration::from_millis(1)).await; + assert!(io.archived().is_empty(), "flushed before the deadline"); + + tokio::time::sleep(Duration::from_millis(2)).await; + wait_for("flush", || !io.archived().is_empty()).await; + assert_eq!(io.archived()[0].len(), 1); + stop(cancel, handle).await; +} + +#[tokio::test(start_paused = true)] +async fn a_trickle_cannot_postpone_the_deadline_indefinitely() { + // The deadline belongs to the OLDEST buffered event. An idle timer reset + // on each arrival would leave a steady trickle unflushed forever. + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "channel_h", + "channel-a", + "[9]", + )]])); + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + let id = io.applied()[0][0].id.clone(); + + for _ in 0..4 { + tx.send(matched(&id)).await.unwrap(); + tokio::time::sleep(FLUSH_DEADLINE / 2).await; + } + wait_for("flush", || !io.archived().is_empty()).await; + assert!(!io.archived().is_empty()); + stop(cancel, handle).await; +} + +#[tokio::test] +async fn events_for_an_unknown_subscription_are_dropped() { + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "channel_h", + "channel-a", + "[9]", + )]])); + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + + // An event from a subscription we already closed: no scope, no archive. + tx.send(matched("archive:channel_h:gone:[9]")) + .await + .unwrap(); + cancel.cancel(); + handle.await.unwrap(); + + assert!( + io.archived().is_empty(), + "archived an event with no known scope" + ); +} + +#[tokio::test] +async fn buffered_events_flush_on_shutdown() { + // Ephemeral kind 24200 cannot be re-fetched, so a buffered event dropped + // at teardown is lost permanently. + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "owner_p", "owner-pk", "[24200]", + )]])); + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + let id = io.applied()[0][0].id.clone(); + + tx.send(matched(&id)).await.unwrap(); + // Wait until the loop has actually taken the event off the channel; + // cancelling first would test a race, not the shutdown flush. + wait_for("loop to drain the channel", || { + tx.capacity() == tx.max_capacity() + }) + .await; + cancel.cancel(); + handle.await.unwrap(); + + let archived = io.archived(); + assert_eq!(archived.len(), 1, "shutdown did not flush the buffer"); + assert_eq!(archived[0], vec!["owner-pk".to_string()]); +} + +#[tokio::test] +async fn notifies_agent_metrics_only_when_the_backend_persisted_some() { + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "owner_p", "owner-pk", "[44200]", + )]])); + *io.persisted_agent_metrics.lock().unwrap() = 2; + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + let id = io.applied()[0][0].id.clone(); + + for _ in 0..FLUSH_BATCH_SIZE { + tx.send(matched(&id)).await.unwrap(); + } + wait_for("flush", || !io.archived().is_empty()).await; + tokio::time::sleep(Duration::from_millis(10)).await; + assert_eq!(*io.metrics_notifications.lock().unwrap(), 1); + stop(cancel, handle).await; +} + +#[tokio::test] +async fn does_not_notify_when_nothing_was_persisted() { + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "owner_p", "owner-pk", "[44200]", + )]])); + // persisted_agent_metrics stays 0: a duplicate-only batch must not + // invalidate the renderer's usage queries. + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + let id = io.applied()[0][0].id.clone(); + + for _ in 0..FLUSH_BATCH_SIZE { + tx.send(matched(&id)).await.unwrap(); + } + wait_for("flush", || !io.archived().is_empty()).await; + tokio::time::sleep(Duration::from_millis(10)).await; + assert_eq!(*io.metrics_notifications.lock().unwrap(), 0); + stop(cancel, handle).await; +} + +#[tokio::test] +async fn a_failed_archive_call_does_not_notify_or_stop_the_loop() { + let io = Arc::new(FakeIo::with_listings(vec![vec![saved( + "channel_h", + "channel-a", + "[9]", + )]])); + *io.archive_fails.lock().unwrap() = true; + let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io)); + wait_for("initial subscribe", || !io.applied().is_empty()).await; + let id = io.applied()[0][0].id.clone(); + + for _ in 0..(FLUSH_BATCH_SIZE * 2) { + tx.send(matched(&id)).await.unwrap(); + } + wait_for("second flush", || io.archived().len() >= 2).await; + assert_eq!(*io.metrics_notifications.lock().unwrap(), 0); + stop(cancel, handle).await; +} + +#[tokio::test] +async fn a_failed_listing_leaves_the_previous_subscriptions_live() { + // A transient SQLite error must not silently stop archiving. + struct FailingList(Arc, StdMutex); + impl ArchiveSyncIo for FailingList { + fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>> { + Box::pin(async move { + let mut failed = self.1.lock().unwrap(); + if *failed { + return Err("db is busy".into()); + } + *failed = true; + Ok(vec![saved("channel_h", "channel-a", "[9]")]) + }) + } + fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()> { + self.0.set_subscriptions(subscriptions) + } + fn archive( + &self, + candidates: Vec, + ) -> BoxFuture<'_, Result> { + self.0.archive(candidates) + } + fn notify_agent_metrics_changed(&self) { + self.0.notify_agent_metrics_changed(); + } + } + + let inner = Arc::new(FakeIo::default()); + let io = Arc::new(FailingList(Arc::clone(&inner), StdMutex::new(false))); + let (tx, rx) = mpsc::channel(8); + let reload = Arc::new(Notify::new()); + let cancel = CancellationToken::new(); + let handle = { + let io = Arc::clone(&io); + let reload = Arc::clone(&reload); + let cancel = cancel.clone(); + tokio::spawn(async move { run_sync(io.as_ref(), reload, rx, cancel).await }) + }; + + wait_for("initial subscribe", || !inner.applied().is_empty()).await; + let id = inner.applied()[0][0].id.clone(); + reload.notify_one(); + tokio::time::sleep(Duration::from_millis(10)).await; + + // The failed reload applied nothing, and the original scope still + // demultiplexes — so events keep being archived. + assert_eq!(inner.applied().len(), 1); + for _ in 0..FLUSH_BATCH_SIZE { + tx.send(matched(&id)).await.unwrap(); + } + wait_for("flush", || !inner.archived().is_empty()).await; + stop(cancel, handle).await; +} + +// ── Lifecycle ownership ────────────────────────────────────────────────────── +// +// The renderer fires `start_archive_sync` and `stop_archive_sync` without +// awaiting them, and Tauri commands may complete in any order. These tests +// drive `ArchiveSyncState` through the same `claim_start`/`claim_stop`/ +// `install`/`stop` seam the commands use, applying the halves in a chosen +// order — which is the one thing the mounted-hook test cannot do, because its +// mock `invoke` resolves immediately and can only observe call order. + +/// Runs the ownership half of `start_archive_sync` under `lease`, returning the +/// installed task's cancel token. `None` means the start did not take +/// ownership. Mirrors the command minus the relay session and spawned loop, +/// which the ordering invariant does not involve. +/// +/// The ownership token is dropped before returning, so these tests apply the +/// halves sequentially as before. The test that needs it held across an +/// acquisition calls [`ArchiveSyncState::begin`] directly. +/// +/// The token is the task's identity: two starts produce distinct tokens, so a +/// test can name WHICH task survived an interleaving rather than only that one +/// did. "Something is running" is satisfiable by the stale start's task. +async fn start_half( + state: &ArchiveSyncState, + mark: (u64, u64), + scope: (&str, &str), +) -> Option { + let cancel = CancellationToken::new(); + state + .begin( + mark, + (scope.0.to_string(), scope.1.to_string()), + cancel.clone(), + Arc::new(Notify::new()), + ) + .await + .is_some() + .then_some(cancel) +} + +/// Runs `stop_archive_sync` under `mark`. +async fn stop_half(state: &ArchiveSyncState, mark: (u64, u64)) { + state.end(mark).await; +} + +async fn is_running(state: &ArchiveSyncState) -> bool { + state.running.lock().await.is_some() +} + +/// Whether the installed task is the one `cancel` belongs to. +/// +/// `CancellationToken` is not `PartialEq`, so identity is checked through the +/// shared state the clones observe: cancelling the candidate must cancel the +/// installed task if and only if they are the same instance. The token is +/// consumed by the check, so callers assert identity last. +async fn running_is(state: &ArchiveSyncState, cancel: &CancellationToken) -> bool { + let installed = match state.running.lock().await.as_ref() { + Some(running) => running.cancel.clone(), + None => return false, + }; + cancel.cancel(); + installed.is_cancelled() +} + +const SCOPE: (&str, &str) = ("owner-pubkey", "wss://relay.example"); + +/// Wren's schedule: `start2` reaches the backend before the delayed `start1`, +/// then the old effect's cleanup runs. +/// +/// A backend-issued generation fails here — `start1` arrives last, so it mints +/// the newest token and hands it to the stalest caller, whose `stop` then +/// legitimately cancels the task the new effect depends on. The lease is +/// allocated in the renderer in effect order, so `start1` is stale on arrival. +#[tokio::test] +async fn a_start_that_arrives_after_a_newer_one_cannot_supersede_it() { + let state = ArchiveSyncState::default(); + + // Effect 2 wins the race to the backend. + let start2 = start_half(&state, (1, 2), SCOPE) + .await + .expect("start2 installs"); + // Effect 1's delayed start lands second and must not take ownership. + assert!( + start_half(&state, (1, 1), SCOPE).await.is_none(), + "a start older than the newest lease must not install" + ); + // Effect 1's cleanup, holding lease 1. + stop_half(&state, (1, 1)).await; + + assert!( + !start2.is_cancelled(), + "start2's task must not have been cancelled by lease 1's stop" + ); + // Identity, not survival: a lease mutant that keeps the WRONG task alive + // would satisfy "something is running", so name the instance. + assert!( + running_is(&state, &start2).await, + "the surviving task must be start2's instance — stale cleanup cancelled \ + the newest start, the exact stranding this lease prevents" + ); +} + +/// The other half of the invariant: a start delayed past its own cleanup must +/// not resurrect sync after the renderer gate closed. +#[tokio::test] +async fn a_start_that_arrives_after_its_own_stop_cannot_resurrect_sync() { + let state = ArchiveSyncState::default(); + + stop_half(&state, (1, 3)).await; + assert!( + start_half(&state, (1, 3), SCOPE).await.is_none(), + "a start whose own stop already ran must not install" + ); + + assert!( + !is_running(&state).await, + "sync was resurrected after its owner stopped" + ); +} + +/// The ordinary sequence still works: each remount's start takes ownership and +/// its own cleanup stops it. +#[tokio::test] +async fn ordered_start_and_stop_still_take_effect() { + let state = ArchiveSyncState::default(); + + let first = start_half(&state, (1, 1), SCOPE) + .await + .expect("first start"); + assert!(is_running(&state).await, "sync must be running after start"); + + stop_half(&state, (1, 1)).await; + assert!(!is_running(&state).await, "its own stop must take effect"); + assert!(first.is_cancelled(), "the stopped task must be cancelled"); + + let second = start_half(&state, (1, 2), SCOPE) + .await + .expect("newer start"); + assert!( + running_is(&state, &second).await, + "the newer start's own task must be the installed one" + ); + + stop_half(&state, (1, 2)).await; + assert!(!is_running(&state).await, "the newer stop must take effect"); +} + +/// A same-scope remount that reaches the backend in order is still a no-op at +/// the socket, so the lease does not undo the idempotence the port relies on. +#[tokio::test] +async fn a_same_scope_restart_does_not_churn_the_running_task() { + let state = ArchiveSyncState::default(); + + let first = start_half(&state, (1, 1), SCOPE) + .await + .expect("first start"); + assert!( + start_half(&state, (1, 2), SCOPE).await.is_none(), + "a newer start for the same scope must not reinstall" + ); + assert!( + !first.is_cancelled(), + "the original task must not be torn down" + ); + assert!( + running_is(&state, &first).await, + "the original task must still be the installed one" + ); +} + +/// An identity or relay change must replace the task rather than leaving the +/// old scope's socket live. +#[tokio::test] +async fn a_scope_change_replaces_the_running_task() { + let state = ArchiveSyncState::default(); + + let first = start_half(&state, (1, 1), SCOPE) + .await + .expect("first start"); + let second = start_half(&state, (1, 2), ("other-pubkey", SCOPE.1)) + .await + .expect("a different scope must install"); + + assert!( + first.is_cancelled(), + "the replaced task must be cancelled, not leaked" + ); + assert!( + running_is(&state, &second).await, + "the new scope's task must be the installed one" + ); +} + +/// A stop must hold its lease guard across the cancellation, not just across +/// the check. +/// +/// The other lifecycle tests apply the two halves sequentially, so they cannot +/// see this: they pass against an `end` that releases `latest_lease` before +/// taking `running`. That version leaves a window — a stop clears its lease +/// check, yields, a newer start installs its task, and the resuming stop +/// cancels it. Stale cleanup strands the newest owner, which is the exact +/// failure the lease exists to prevent. +/// +/// Rather than race it (unreliable either way), this observes the invariant +/// directly: hold `running` so a concurrent `end` must park after its lease +/// check, then ask whether `latest_lease` is still held. Held means the stop +/// and a competing start are mutually exclusive over the whole operation. +#[tokio::test] +async fn a_stop_holds_its_lease_guard_across_the_cancellation() { + let state = Arc::new(ArchiveSyncState::default()); + + start_half(&state, (1, 1), SCOPE) + .await + .expect("first start"); + + // Block the second half of `end` by owning the lock it must acquire. + let running_guard = state.running.lock().await; + + let stopper = tokio::spawn({ + let state = Arc::clone(&state); + async move { state.end((1, 2)).await } + }); + + // Let the stop run until it blocks on `running`. + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // The parked stop is past its lease check. If it still holds the lease + // guard, no concurrent start can install a task for it to cancel. + let lease_held = state.latest.try_lock().is_err(); + + drop(running_guard); + stopper.await.expect("stop task"); + + assert!( + lease_held, + "a stop parked mid-cancellation released its lease guard: a newer start \ + can install a task in that window, which this stop then cancels — \ + stale cleanup stranding the newest owner" + ); +} + +// ── Realm epochs ───────────────────────────────────────────────────────────── +// +// A lease counter lives and dies with its JS realm, but this state lives for +// the whole Tauri process. A renderer reload (useReloadShortcut, +// RootErrorBoundary, useCommunityInit) restarts the counter at zero, so +// ordering on the lease alone makes every post-reload call look stale. The +// epoch is minted here so successive realms can be ordered by an authority +// that outlives them. + +/// After a renderer reload, the new realm's first start must take ownership +/// even though its lease counter restarted below what the backend has seen. +/// +/// This is the regression for the reload boundary: seed the backend as if a +/// realm had already run, then have a fresh realm announce and start from +/// lease 1. Ordering on the lease alone rejects it forever — and because the +/// RootErrorBoundary reload is the recovery path for a renderer crash, that +/// would make crash recovery the thing that permanently kills archive sync. +#[tokio::test] +async fn a_realm_that_reloaded_owns_sync_despite_restarting_its_lease() { + let state = ArchiveSyncState::default(); + + // Realm 1 ran and got as far as lease 2 (StrictMode alone reaches this). + let first_epoch = state.announce().await; + start_half(&state, (first_epoch, 1), SCOPE) + .await + .expect("realm 1 start"); + stop_half(&state, (first_epoch, 2)).await; + + // The realm is destroyed by reload; the JS lease counter restarts at 1. + let second_epoch = state.announce().await; + assert!( + second_epoch > first_epoch, + "each announcing realm must outrank the last" + ); + let reloaded = start_half(&state, (second_epoch, 1), SCOPE) + .await + .expect("the first start after a reload must own sync"); + + assert!( + running_is(&state, &reloaded).await, + "the post-reload realm's task must be the installed one — ordering on \ + the lease alone leaves sync permanently absent after any reload" + ); +} + +/// A call from a realm that has already been superseded must lose from the +/// moment the new realm ANNOUNCES — not merely once the new realm has managed +/// to land a lifecycle call of its own. +/// +/// This is the stale-cleanup invariant replayed one level up: the dead realm's +/// in-flight cleanup arrives after the new realm has taken over, and a lease +/// comparison alone would let its larger counter win. +/// +/// The old calls are sent BEFORE any epoch-2 lifecycle call deliberately. +/// That gap is reachable in production and can be arbitrarily long: the new +/// realm awaits its announcement, then waits on observer reconciliation before +/// it may issue a start at all. A version that mints the epoch without +/// publishing it passes any schedule where the new realm calls first, because +/// the new call — not the announcement — is what advanced the mark. +#[tokio::test] +async fn a_delayed_call_from_a_superseded_realm_cannot_supersede_the_new_one() { + let state = ArchiveSyncState::default(); + + // The old realm ran and owns the installed task. + let old_epoch = state.announce().await; + let stale = start_half(&state, (old_epoch, 1), SCOPE) + .await + .expect("the old realm installs"); + + // The new realm announces. It has issued no lifecycle call yet. + let new_epoch = state.announce().await; + + // The dead realm's delayed start and cleanup, both with high leases, land + // in the gap between the new realm's announcement and its first call. + // + // The delayed start carries a DIFFERENT scope on purpose. Under `SCOPE` it + // would hit the same-scope remount no-op and return `None` whatever the + // mark said, so the assertion would hold against a backend that had stopped + // comparing marks entirely — a pass for a benign reason is not a pass. + assert!( + start_half(&state, (old_epoch, 99), ("stale-realm-pubkey", SCOPE.1)) + .await + .is_none(), + "a superseded realm's start must not install, whatever its lease — \ + announcing is what supersedes it, not the new realm's first call" + ); + stop_half(&state, (old_epoch, 99)).await; + assert!( + !stale.is_cancelled(), + "a superseded realm's cleanup must not cancel a task it no longer owns" + ); + + // And the announcement must not have locked the new realm out of its own + // start: publishing a mark too high is blocker 3 rebuilt from the far side. + // A different scope so the start installs rather than taking the + // same-scope no-op path, which would report `None` for a benign reason and + // blur what this assertion is for. + let current = start_half(&state, (new_epoch, 1), ("other-pubkey", SCOPE.1)) + .await + .expect("the announcing realm's own first start must still install"); + assert!( + running_is(&state, ¤t).await, + "the surviving task must be the new realm's instance" + ); +} + +/// Epochs are handed out strictly increasing, so an announcement can never tie +/// with or fall behind one already given out. +#[tokio::test] +async fn announced_epochs_strictly_increase() { + let state = ArchiveSyncState::default(); + + let mut previous = 0; + for _ in 0..5 { + let epoch = state.announce().await; + assert!( + epoch > previous, + "epoch {epoch} did not outrank its predecessor {previous}" + ); + previous = epoch; + } +} + +// ── Session acquisition ────────────────────────────────────────────────────── +// +// Ordering alone is not enough once a start has to acquire the shared relay +// session: `ensure_session` shuts down a different scope's socket and +// `attach_archive` replaces the archive sender, both destructively on entry. +// A start that checked its mark, yielded, and acquired afterwards would already +// have torn down the newer owner's session by the time it discovered it lost. +// +// Two things close that, and only one of them is testable here. That a +// superseded start cannot call `archive_session` at all is the token's job and +// is enforced by the compiler, not by a test — `ArchiveOwnership` is +// un-constructible outside this module, so the bypass does not compile. What +// this test pins is the property the token's usefulness rests on: while a +// winner holds it, no other start can claim. + +/// While a start holds its ownership token, a newer start cannot claim — so the +/// window in which the shared session is acquired is exclusive. +/// +/// This is the mutant that motivated the design and the one a test has to +/// catch: keeping the token but releasing the guards inside `begin`. That +/// compiles, keeps every other lifecycle test green, and restores exactly the +/// race — B claims, yields into `archive_session`, C claims and installs its +/// own session, then B's acquisition shuts C's socket down and attaches the +/// archive stream to a task whose token is already cancelled. +/// +/// The competing start uses a DIFFERENT scope on purpose: under `SCOPE` it +/// would take the same-scope remount no-op and report `None` whatever the locks +/// did, so the assertion would hold for a benign reason. +#[tokio::test] +async fn a_newer_start_cannot_claim_while_the_owner_holds_its_token() { + let state = Arc::new(ArchiveSyncState::default()); + + let first = CancellationToken::new(); + let ownership = state + .begin( + (1, 1), + (SCOPE.0.to_string(), SCOPE.1.to_string()), + first.clone(), + Arc::new(Notify::new()), + ) + .await + .expect("the first start claims ownership"); + + let second = CancellationToken::new(); + let claimed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let contender = tokio::spawn({ + let state = Arc::clone(&state); + let claimed = Arc::clone(&claimed); + let second = second.clone(); + async move { + let won = state + .begin( + (1, 2), + ("other-pubkey".to_string(), SCOPE.1.to_string()), + second, + Arc::new(Notify::new()), + ) + .await + .is_some(); + claimed.store(won, std::sync::atomic::Ordering::SeqCst); + } + }); + + // Give the contender every chance to claim while the owner still holds the + // token. Yield first so it is polled at all, then a real sleep so a slow + // scheduler cannot make this pass by never running it. + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!( + !claimed.load(std::sync::atomic::Ordering::SeqCst), + "a newer start claimed while the owner still held its token: the owner's \ + session acquisition is no longer exclusive, so a superseded start can \ + shut down the newer scope's socket" + ); + assert!( + !first.is_cancelled(), + "the owner's task was cancelled while it still held ownership" + ); + + // Releasing the token is what lets the newer start through — and it must + // then win, or this test would also pass against a `begin` that deadlocked. + drop(ownership); + contender.await.expect("contender task"); + assert!( + claimed.load(std::sync::atomic::Ordering::SeqCst), + "the newer start never claimed after the owner released its token" + ); + assert!( + first.is_cancelled(), + "the superseded task must be cancelled once the newer start installs" + ); + assert!( + running_is(&state, &second).await, + "the newer start's task must be the installed one" + ); +} diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 2dc0ba0d69..4df24e6e9b 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -13,9 +13,10 @@ use crate::{ }, }, current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, - known_acp_runtime, load_managed_agents, load_personas, save_managed_agents, - sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, - ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, + known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env, + save_managed_agents, sync_managed_agent_processes, AgentDefinition, BackendKind, + GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, + MAX_ENV_VALUE_BYTES, }, }; @@ -121,6 +122,7 @@ fn resolve_config_surface( runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, + claude_config_dir: Option<&std::path::Path>, ) -> RuntimeConfigSurface { // Linked instances are definition-authoritative: clear stale materialized // model/provider/prompt so they can never masquerade as BuzzExplicit and @@ -138,7 +140,13 @@ fn resolve_config_surface( global, ); - read_config_surface(&record, runtime_meta, session_cache, &tiers) + read_config_surface( + &record, + runtime_meta, + session_cache, + &tiers, + claude_config_dir, + ) } /// Get the file-layer config for a runtime — used by the Create/Edit/Persona @@ -288,12 +296,36 @@ pub async fn get_agent_config_surface( let session_cache = state.get_session_cache(&runtime_key); let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + // #3493: for claude agents, resolve the settings.json and .claude.json paths + // from the agent's effective CLAUDE_CONFIG_DIR env var (if set), falling + // back to ~/.claude/ and ~/.claude.json. We never provision this dir + // ourselves — we only respect what the user configured. + // + // Use resolve_effective_agent_env so the lookup covers all tiers (baked + // floor → definition → global → persona → record) and cannot diverge from + // what the spawned process actually sees. + let claude_config_dir: Option = if runtime_meta + .is_some_and(|m| m.id == "claude") + { + let effective_env = resolve_effective_agent_env(&record, &personas, runtime_meta, &global); + // Treat empty or blank CLAUDE_CONFIG_DIR as unset, matching Claude's + // `CLAUDE_CONFIG_DIR || homedir()` resolver semantics. + effective_env + .env + .get("CLAUDE_CONFIG_DIR") + .filter(|v| !v.trim().is_empty()) + .map(std::path::PathBuf::from) + } else { + None + }; + Ok(resolve_config_surface( record, &personas, runtime_meta, session_cache.as_ref(), &global, + claude_config_dir.as_deref(), )) } @@ -503,6 +535,44 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< (models, current_model) } +/// Persist the canonical startup effort level for a local managed agent. +/// +/// B5 (v4 direct-write): the panel's EffortPicker calls this directly to set the +/// effort a spawn will apply at next session start. The value is stored on the +/// record; at spawn `runtime.rs` injects it as `BUZZ_ACP_EFFORT_LEVEL` and the +/// harness applies it via `session/set_config_option` against the adapter's +/// advertised `thought_level` configId. Pass `None` to clear (adapter default). +/// +/// Rejects non-local backends: remote agents receive effort through `policy_env` +/// at deploy time (see `agents_deploy.rs`), never this local persistence path — +/// so an effort edit against a deployed agent is a caller error, not a silent +/// no-op that leaves the panel and the running agent disagreeing. +#[tauri::command] +pub fn persist_agent_effort_level( + pubkey: String, + effort_level: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + if record.backend != BackendKind::Local { + return Err(format!( + "agent {pubkey} is not a local agent; remote effort is set at deploy time" + )); + } + record.effort_level = effort_level; + record.updated_at = crate::util::now_iso(); + save_managed_agents(&app, &records) +} + #[cfg(test)] #[path = "agent_config_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 77e43f5d64..9c9aa58c1f 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -117,6 +117,7 @@ fn agent_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, @@ -182,6 +183,7 @@ fn linked_stale_record_model_never_outranks_persona_model() { Some(goose_runtime()), None, &Default::default(), + None, ); let model = surface.normalized.model.as_ref().expect("model resolved"); @@ -206,7 +208,14 @@ fn linked_blank_definition_model_falls_through_to_global_default() { ..Default::default() }; - let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &global, + None, + ); let model = surface.normalized.model.as_ref().expect("model resolved"); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -229,6 +238,7 @@ fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { Some(goose_runtime()), None, &Default::default(), + None, ); let model = surface.normalized.model.as_ref().expect("model resolved"); @@ -256,6 +266,7 @@ fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -284,6 +295,7 @@ fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -319,6 +331,7 @@ fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ) }); let model = surface.normalized.model.expect("model resolved"); @@ -347,6 +360,7 @@ fn persona_linked_live_switch_keeps_persona_default_secondary() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -382,6 +396,7 @@ fn global_default_live_switch_renders_global_model_as_secondary_global_default() Some(goose_runtime()), Some(&cache), &global, + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -666,3 +681,32 @@ fn baked_env_allowlist_is_case_insensitive() { // Unknown key → masked by default. assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY")); } + +/// F3 (Desktop-parsing half): the `models` block emitted by an applied live +/// switch — taken from the post-switch snapshot in `pool.rs` — must parse to the +/// target model as current. Pairs with the pool test +/// `test_applied_switch_caches_target_model_not_pre_switch`, which proves the +/// emitted block already carries `currentModelId=model-b`. +#[test] +fn live_switch_models_from_post_switch_snapshot_parses_target_current() { + let models = serde_json::json!({ + "currentModelId": "model-b", + "availableModels": [{"modelId": "model-a"}, {"modelId": "model-b"}], + }); + let (available, current) = parse_models(Some(&models)); + assert_eq!(current.as_deref(), Some("model-b")); + assert_eq!(available.len(), 2); +} + +/// F3 (Desktop-parsing half): a Null `models` block — emitted when a successful +/// switch's target response omits `models` — must parse to no current model, so +/// the pre-switch model is never revived in the cache. +#[test] +fn live_switch_null_models_parses_to_no_current_model() { + let (available, current) = parse_models(Some(&serde_json::Value::Null)); + assert!( + current.is_none(), + "Null models must not surface any current model" + ); + assert!(available.is_empty()); +} 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 d00969bf19..db0573acd7 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -9,6 +9,41 @@ use crate::{ const RELAY_DIRECTORY_PAGE_SIZE: usize = 500; const RELAY_FILTER_BATCH_SIZE: usize = 10; +/// Per-rebuild ceiling on directory-rebuild `/query` requests in flight at once. +/// The rebuild fans dozens of exact-author batches across the relay; issuing +/// them serially dominated agent-mention send latency (~6 s for ~100 +/// candidates). A bounded window collapses that to a few round trips while +/// keeping the request rate well under the relay's admission gate, which +/// back-pressures any 429 anyway. Each rebuild builds one semaphore and shares +/// it across every phase, so a single rebuild's runtime-directory and +/// owner-profile phases — which run concurrently under one `try_join!` — never +/// exceed it together. (Overlapping rebuilds each hold their own budget.) +const RELAY_DIRECTORY_MAX_CONCURRENCY: usize = 8; + +/// Run one `query_relay` request per `RELAY_FILTER_BATCH_SIZE` chunk of +/// `filters`, each acquiring a permit from `semaphore` so the total in-flight +/// request count stays within the shared ceiling even when several batch sets +/// run concurrently. Returned events are concatenated; order is unspecified — +/// every caller keys the events by pubkey downstream, so ordering is irrelevant. +async fn query_filter_batches( + state: &AppState, + semaphore: &tokio::sync::Semaphore, + filters: &[serde_json::Value], + error_label: &str, +) -> Result, String> { + let pages = futures_util::future::try_join_all(filters.chunks(RELAY_FILTER_BATCH_SIZE).map( + |batch| async move { + let _permit = semaphore.acquire().await.map_err(|error| { + format!("{error_label}: directory concurrency semaphore closed: {error}") + })?; + query_relay(state, batch) + .await + .map_err(|error| format!("{error_label}: {error}")) + }, + )) + .await?; + Ok(pages.into_iter().flatten().collect()) +} fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec { pubkeys @@ -77,15 +112,10 @@ async fn query_all_relay_pages( } } -fn owner_only_relay_directory() -> bool { - crate::managed_agents::owner_only_access_build() -} - -fn retain_verified_owner( - verified_owners: &mut std::collections::HashMap, - required_owner: &str, -) { - verified_owners.retain(|_, owner| owner.eq_ignore_ascii_case(required_owner)); +fn retain_agents_allowed_by_build(agents: &mut Vec, require_verified_owner: bool) { + if require_verified_owner { + agents.retain(|agent| agent.owner_pubkey.is_some()); + } } pub(crate) async fn list_relay_agents_for_state( @@ -100,7 +130,6 @@ async fn list_relay_agents_for_selection( channel_id: Option<&str>, ) -> Result, String> { let viewer_pubkey = current_user_pubkey(state)?; - let owner_only = owner_only_relay_directory(); let relay_pubkey = identity_archive::fetch_relay_self(state) .await? .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; @@ -129,60 +158,54 @@ async fn list_relay_agents_for_selection( return Ok(Vec::new()); } - let mut directory_events = Vec::new(); - let mut profile_events = Vec::new(); let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); let profile_filters = exact_author_filters(&candidate_pubkeys, 0); - for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) { - let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len()); - let (directory, profiles) = tokio::join!( - query_relay(state, &directory_filters[filter_offset..filter_end]), - query_relay(state, &profile_filters[filter_offset..filter_end]), - ); - directory_events.extend( - directory - .map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?, - ); - profile_events.extend( - profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?, - ); - } + // One semaphore per rebuild caps `/query` requests across this rebuild's + // phases, so its runtime-directory and owner-profile phases below stay + // within the ceiling even though `try_join!` runs them concurrently. + let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); + let (directory_events, profile_events) = tokio::try_join!( + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current // replaceable event, so forged 30177 coordinates cannot amplify or crowd // the authentic policy out of a bounded result page. - let mut verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); - // The internal capability narrows the remote directory to cryptographically - // verified agents owned by the active user. Same-owner siblings remain - // mentionable because they are inside the harness's owner-only boundary; - // all cross-owner coordinates are discarded before policy lookup. - if owner_only { - retain_verified_owner(&mut verified_owners, &viewer_pubkey); - } + let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); - let mut managed_agent_events = Vec::new(); - for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) { - managed_agent_events.extend( - query_relay(state, filters) - .await - .map_err(|error| format!("relay agent managed-policy query failed: {error}"))?, - ); - } + let managed_agent_events = query_filter_batches( + state, + &semaphore, + &managed_filters, + "relay agent managed-policy query failed", + ) + .await?; let mut agents = nostr_convert::relay_agents_from_directory_events( &directory_events, &managed_agent_events, &profile_events, ); - if owner_only { - agents.retain(|agent| { - agent - .owner_pubkey - .as_deref() - .is_some_and(|owner| owner.eq_ignore_ascii_case(&viewer_pubkey)) - }); - } + // Marked builds reject legacy directory records that lack a verified + // NIP-OA owner, but do not require that owner to equal the viewer. The + // verified owner's signed respond_to policy remains the authorization + // boundary for independently operated relay agents. + retain_agents_allowed_by_build( + &mut agents, + crate::managed_agents::owner_only_access_build(), + ); agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); for agent in &mut agents { agent.channel_ids = member_agent_channel_ids @@ -224,24 +247,66 @@ mod tests { use super::*; #[test] - fn owner_only_directory_keeps_only_verified_same_owner_coordinates() { - let viewer = "a".repeat(64); - let other_owner = "b".repeat(64); - let same_owner_agent = "c".repeat(64); - let other_owner_agent = "d".repeat(64); - let mut owners = std::collections::HashMap::from([ - (same_owner_agent.clone(), viewer.to_uppercase()), - (other_owner_agent, other_owner), - ]); + fn marked_build_requires_verified_owner_without_requiring_viewer_ownership() { + let cross_owner = "b".repeat(64); + let mut agents = vec![ + RelayAgentInfo { + pubkey: "a".repeat(64), + owner_pubkey: Some(cross_owner.clone()), + name: "Verified cross-owner".to_string(), + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "offline".to_string(), + respond_to: None, + respond_to_allowlist: Vec::new(), + }, + RelayAgentInfo { + pubkey: "c".repeat(64), + owner_pubkey: None, + name: "Ownerless legacy".to_string(), + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "online".to_string(), + respond_to: None, + respond_to_allowlist: Vec::new(), + }, + ]; - retain_verified_owner(&mut owners, &viewer); + retain_agents_allowed_by_build(&mut agents, true); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].name, "Verified cross-owner"); assert_eq!( - owners, - std::collections::HashMap::from([(same_owner_agent, viewer.to_uppercase())]) + agents[0].owner_pubkey.as_deref(), + Some(cross_owner.as_str()) ); } + #[test] + fn oss_build_preserves_ownerless_legacy_agents() { + let mut agents = vec![RelayAgentInfo { + pubkey: "a".repeat(64), + owner_pubkey: None, + name: "Ownerless legacy".to_string(), + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "online".to_string(), + respond_to: None, + respond_to_allowlist: Vec::new(), + }]; + + retain_agents_allowed_by_build(&mut agents, false); + + assert_eq!(agents.len(), 1); + assert!(agents[0].owner_pubkey.is_none()); + } + #[test] fn exact_author_queries_prevent_noisy_agent_crowd_out() { let pubkeys = vec!["a".repeat(64), "b".repeat(64)]; diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index a9e3b67775..df3849de4a 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -306,11 +306,15 @@ fn effective_discovery_provider_recovers_baked_provider_when_record_has_none() { } } +/// A provider env-var name no environment sets, so this test does not depend on +/// what the developer happens to have exported (e.g. `BUZZ_AGENT_PROVIDER`). +const UNSET_PROVIDER_VAR: &str = "BUZZ_TEST_UNSET_DISCOVERY_PROVIDER"; + #[test] fn effective_discovery_provider_is_none_without_an_explicit_or_env_provider() { let env = BTreeMap::new(); assert_eq!( - effective_discovery_provider(None, Some("BUZZ_AGENT_PROVIDER"), &env).as_deref(), + effective_discovery_provider(None, Some(UNSET_PROVIDER_VAR), &env).as_deref(), None ); // A runtime that takes no provider env var has nothing to recover from. @@ -318,10 +322,7 @@ fn effective_discovery_provider_is_none_without_an_explicit_or_env_provider() { effective_discovery_provider( None, None, - &BTreeMap::from([( - "BUZZ_AGENT_PROVIDER".to_string(), - "databricks_v2".to_string() - )]) + &BTreeMap::from([(UNSET_PROVIDER_VAR.to_string(), "databricks_v2".to_string())]) ) .as_deref(), None diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index d1667a72bb..33b6ae4462 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -26,6 +26,14 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result { Ok(keys.public_key().to_hex()) } +#[path = "agents_pending.rs"] +mod pending; +#[cfg(test)] +use pending::build_agent_archive_request; +pub(crate) use pending::{ + archive_managed_agent_pending, retain_managed_agent_pending, tombstone_managed_agent_pending, +}; + /// Build a summary from fresh disk state (personas, teams, global config). /// For one-shot command paths only — the 5s list poll calls /// `build_managed_agent_summary` directly with stores loaded once per call, @@ -48,174 +56,6 @@ pub(super) fn summarize_from_disk( ) } -/// Retain a freshly authored managed-agent event in the local store, flagged -/// for relay sync. MUST be called inside the `managed_agents_store_lock`-held -/// body after `save_managed_agents`, NEVER across an `.await`: it acquires -/// `state.keys` and a retention-db connection, both `std::sync` guards, and -/// drops them before returning. -/// -/// Owner-authored, mirroring `commands::personas::retain_persona_pending`: the -/// owner keys sign, the d_tag is the agent's pubkey, so the coordinate is -/// `30177::`. The event content is the opt-IN -/// [`agent_event_content`] projection — the retention upsert's content-equality -/// guard compares this projection, so an operational start/stop that mutates -/// only runtime fields produces an identical row and never re-enqueues a -/// publish. Best-effort: a failure here is logged and swallowed so a retention -/// hiccup never blocks the disk-authoritative write. -pub(super) fn retain_managed_agent_pending( - app: &AppHandle, - state: &AppState, - record: &ManagedAgentRecord, -) { - use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let conn = open_retention_db(&scope.db_path)?; - // Shared engine with the boot-time reconcile: projection content diff - // (no republish for runtime-only churn) + monotonic created_at bump - // past the retained head (NIP-AP step 3). - retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: agent-retain: {e}"); - } -} - -/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both -/// inside the `managed_agents_store_lock`-held delete body and NEVER across an -/// `.await`. -/// -/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at -/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can -/// never resurrect it after the tombstone publishes, then the kind:5 tombstone -/// is retained at its own `(5, owner, agent_pubkey)` coordinate with -/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a -/// failure is logged and swallowed so a retention hiccup never blocks the -/// disk-authoritative delete. -pub(super) fn tombstone_managed_agent_pending( - app: &AppHandle, - state: &AppState, - agent_pubkey: &str, -) { - use crate::managed_agents::{ - agent_events::build_agent_delete, - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; - - const KIND_DELETE: u32 = 5; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_delete(agent_pubkey, &owner_pubkey)? - .sign_with_keys(&scope.owner_keys) - .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; - let conn = open_retention_db(&scope.db_path)?; - delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey: owner_pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey), - 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: agent-tombstone: {e}"); - } -} - -/// Build an owner-authenticated NIP-IA `kind:9035` archive request for a deleted agent. -/// Definition-linked agents carry the persona id in `content`, where it survives the -/// kind:30177 tombstone as owner-signed historical alias data. The request uses the -/// same builder as the GUI Archive action and the NIP-IA `retired` reason. -pub(super) fn build_agent_archive_request( - keys: &nostr::Keys, - agent_pubkey: &str, - persona_id: Option<&str>, -) -> Result { - let auth_tag = if keys - .public_key() - .to_hex() - .eq_ignore_ascii_case(agent_pubkey) - { - None - } else { - let agent = nostr::PublicKey::from_hex(agent_pubkey) - .map_err(|e| format!("invalid agent pubkey: {e}"))?; - let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "") - .map_err(|e| format!("failed to build owner auth tag: {e}"))?; - let parts: Vec = serde_json::from_str(&tag_json) - .map_err(|e| format!("failed to parse owner auth tag: {e}"))?; - Some( - <[String; 4]>::try_from(parts) - .map_err(|_| "owner auth tag must have four elements".to_string())?, - ) - }; - let content = persona_id - .filter(|id| !id.trim().is_empty()) - .map(|id| serde_json::json!({ "persona_id": id }).to_string()) - .unwrap_or_default(); - crate::events::build_archive_identity_request( - agent_pubkey, - &content, - Some("retired"), - None, - auth_tag.as_ref(), - )? - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign archive request: {e}")) -} - -/// Durably enqueue the archive request next to the kind:5 tombstone. The flush -/// loop re-signs it with a relay-fresh timestamp. Best-effort and lock-scoped, -/// matching `tombstone_managed_agent_pending`. -pub(super) fn archive_managed_agent_pending( - app: &AppHandle, - state: &AppState, - agent_pubkey: &str, - persona_id: Option<&str>, -) { - use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; - use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; - use nostr::JsonUtil; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey, persona_id)?; - let conn = open_retention_db(&scope.db_path)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_IA_ARCHIVE_REQUEST, - pubkey: owner_pubkey, - d_tag: agent_pubkey.to_string(), - 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: agent-archive: {e}"); - } -} - fn normalize_relay_mesh( config: Option<&RelayMeshConfig>, backend: &BackendKind, @@ -366,8 +206,9 @@ pub(super) async fn start_local_agent_with_preflight( app: &AppHandle, state: &AppState, pubkey: &str, - owner_hex: &str, allow_fresh_create_start: bool, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, ) -> Result { let record_snapshot = { let _store_guard = state @@ -403,6 +244,24 @@ pub(super) async fn start_local_agent_with_preflight( ); ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; + // The mesh preflight above is the suspension window Projects callbacks + // capture their scope against: a community switch during that await + // would otherwise spawn this pair keyed to the *new* workspace relay. + // Read the workspace relay ONCE, assert the caller's captured scope + // against that exact read, and hand the same bound value to the spawn + // below — the check is tied to its use, so a switch landing after this + // point can no longer retarget the spawn (it only changes state this + // call no longer consults). + let workspace_relay_url = crate::relay::bind_expected_relay_scope( + expected_relay_url, + crate::relay::relay_ws_url_with_override(state), + )?; + // Bind the active owner after the same final await as the relay. A + // same-relay identity replacement during mesh preflight must not release + // the stale preflight owner to spawn. + let workspace_owner = + crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?; + let _store_guard = state .managed_agents_store_lock .lock() @@ -437,7 +296,13 @@ pub(super) async fn start_local_agent_with_preflight( } } } - start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?; + start_managed_agent_process( + app, + record, + &mut runtimes, + Some(workspace_owner.as_str()), + &workspace_relay_url, + )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { retain_managed_agent_pending(app, state, saved_record); @@ -551,10 +416,6 @@ pub async fn create_managed_agent( ); } - // Snapshot the workspace owner pubkey for the legacy-record auth_tag - // fallback. Computed outside the records lock to keep lock ordering simple. - let owner_hex = workspace_owner_hex(&state)?; - // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { let _store_guard = state @@ -779,7 +640,7 @@ pub async fn create_managed_agent( linked_persona.as_ref(), )?; - let record = crate::managed_agents::ManagedAgentRecord { + let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), persona_id: requested_persona_id.clone(), @@ -864,6 +725,7 @@ pub async fn create_managed_agent( } else { relay_mesh.clone() }, + effort_level: None, }; records.push(record); @@ -887,7 +749,7 @@ pub async fn create_managed_agent( // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; let agent = if input.spawn_after_create && input.backend == BackendKind::Local { - match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true).await { + match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await { Ok(agent) => agent, Err(error) => { let _store_guard = state @@ -951,7 +813,11 @@ pub async fn create_managed_agent( .ok_or_else(|| "agent disappeared".to_string())?; build_deploy_payload(&app, &state, rec)? }; - match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await { + match deploy_to_provider( + &app, &state, &pubkey, id, config, agent_json, None, None, None, + ) + .await + { Ok(()) => spawn_error, Err(e) => Some(e), } @@ -994,12 +860,39 @@ pub async fn create_managed_agent( #[tauri::command] pub async fn start_managed_agent( pubkey: String, + expected_relay_url: Option, + expected_signer_pubkey: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; + // Callers with a captured tenant scope (Projects agent sends) pass + // `expected_relay_url` / `expected_signer_pubkey`. Starting an agent + // activates the (agent, relay) pair — a channel/tool-capable side effect + // — so a stale callback must fail closed here before any spawn or deploy + // when the active community or identity changed while it was suspended. + // After the mesh-preflight awaits, the local path re-checks and BINDS + // the workspace relay (`bind_expected_relay_scope`) so the spawn consumes + // the checked value rather than re-reading mutable state; the provider + // path asserts against the relay embedded in the deploy payload before + // deploying. + crate::relay::assert_expected_relay_scope( + expected_relay_url.as_deref(), + &crate::relay::relay_api_base_url_with_override(&state), + )?; + crate::relay::assert_expected_signer(expected_signer_pubkey.as_deref(), &owner_hex)?; + // Pin the relay for the fire-and-forget profile reconciliation spawned + // after a successful start: one validated workspace-relay read, captured + // NOW. The background task may execute long after this command returns — + // resolving the relay at execution time would let a community switch + // landing in between retarget the kind:0 query/publish to the new + // tenant's relay under authorization the caller only gave for this one. + let reconcile_relay = crate::relay::bind_expected_relay_scope( + expected_relay_url.as_deref(), + relay_ws_url_with_override(&state), + )?; enum StartTarget { Local, Provider { @@ -1037,7 +930,14 @@ pub async fn start_managed_agent( // profile reconcile (the create-time snapshot may be empty or stale for // a persona-inherited harness). let reconcile_personas = load_personas(&app).unwrap_or_default(); - let reconcile = profile_reconcile_data(record, &reconcile_personas); + let mut reconcile = profile_reconcile_data(record, &reconcile_personas); + // Pin the startup relay (the bound, caller-validated read) so the + // fire-and-forget task can never resolve a post-switch workspace. + // Mirrors `load_pending_profile_reconciliations`. + reconcile.target_relay_url = Some(crate::relay::effective_agent_relay_url( + &record.relay_url, + reconcile_relay.as_str(), + )); let target = if record.backend == BackendKind::Local { StartTarget::Local @@ -1054,13 +954,25 @@ pub async fn start_managed_agent( let result = match target { StartTarget::Local => { - start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, false).await + start_local_agent_with_preflight( + &app, + &state, + &pubkey, + false, + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), + ) + .await } StartTarget::Provider { backend: BackendKind::Provider { id, config }, cached_binary_path, agent_json, } => { + // The caller's captured scope is asserted INSIDE deploy_to_provider + // against the payload rebuilt after the deploy lock — the exact + // payload invoked — so a switch racing the lock wait cannot deploy + // the agent into the new tenant on behalf of a stale callback. deploy_to_provider( &app, &state, @@ -1069,6 +981,8 @@ pub async fn start_managed_agent( &config, agent_json, cached_binary_path.as_deref(), + expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), ) .await?; @@ -1238,12 +1152,8 @@ pub async fn delete_managed_agent( return Err(format!("agent {pubkey} not found")); } save_managed_agents(&app, &records)?; - // Remove the agent's nsec from the keyring after the record is gone. crate::managed_agents::delete_agent_key(&pubkey); - // Tombstone-after-validation: only reached past the deployed-remote - // guard above and a confirmed removal — never orphan a live remote - // deployment's relay record. Inside the lock, before the block closes - // (no .await here). Every agent published, so every delete tombstones. + // Tombstone after confirmed removal (inside lock; every published agent tombstones). tombstone_managed_agent_pending(&app, &state, &pubkey); // NIP-IA: archive the deleted agent's identity on the relay so it // stops appearing in member pickers and autocomplete. Same diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 5b90500ee6..34c06d2591 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -98,6 +98,8 @@ pub(crate) async fn reconcile_on_workspace_apply( &config, agent_json, cached_binary_path.as_deref(), + None, + None, ) .await { diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index cdbdd787e7..bb56a67eaa 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -23,6 +23,15 @@ use super::build_deploy_payload; /// revocation semantics to the provider implementation (deferred to v2). /// Returns Ok(()) on success, Err(message) on failure. Either way the record is /// updated and saved before returning. +/// +/// Callers with a captured tenant scope (Projects agent starts) pass +/// `expected_relay_url` / `expected_signer_pubkey`; they are asserted against +/// the payload REBUILT after the deploy lock — the exact value invoked — so a +/// workspace or identity switch landing while this call waited behind another +/// deployment fails closed instead of deploying a stale start into the new +/// tenant under the new tenant's owner identity. `None` preserves the +/// unscoped behavior for callers without a tenant boundary. +#[allow(clippy::too_many_arguments)] pub(crate) async fn deploy_to_provider( app: &AppHandle, state: &AppState, @@ -31,6 +40,8 @@ pub(crate) async fn deploy_to_provider( _config: &serde_json::Value, _agent_json: serde_json::Value, _cached_binary_path: Option<&str>, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, ) -> Result<(), String> { let deploy_lock = { let mut locks = state @@ -68,6 +79,10 @@ pub(crate) async fn deploy_to_provider( build_deploy_payload(app, state, record)?, ) }; + // The rebuild above re-read the live workspace relay and owner identity. + // Assert the caller's captured scope against THIS payload — the exact + // value invoked below — not the pre-lock snapshot its caller validated. + assert_payload_scope(&agent_json, expected_relay_url, expected_signer_pubkey)?; // Resolve via discovered candidates only. Cached path must match BOTH // "is a discovered candidate" AND "belongs to this provider_id". A tampered // record cannot redirect deploys to a different provider's binary. @@ -106,6 +121,44 @@ pub(crate) async fn deploy_to_provider( result } +/// Assert a caller-captured tenant scope against the payload that will +/// actually be invoked. The relay lives at the payload's top-level +/// `relay_url`; the deploying identity lives at `launch.owner_pubkey` — both +/// were re-resolved from live workspace state by `build_deploy_payload`, so +/// this is the check tied to the use. When the caller carries an expectation +/// a missing payload field fails closed: an unverifiable payload must never +/// deploy on behalf of a scoped callback. +fn assert_payload_scope( + agent_json: &serde_json::Value, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, +) -> Result<(), String> { + let has_expectation = + |expected: Option<&str>| expected.map(str::trim).filter(|s| !s.is_empty()).is_some(); + match agent_json.get("relay_url").and_then(|v| v.as_str()) { + Some(embedded_relay) => crate::relay::assert_expected_relay_scope( + expected_relay_url, + &crate::relay::relay_http_base_url(embedded_relay), + )?, + None if has_expectation(expected_relay_url) => { + return Err("deploy payload carries no relay; not deployed".to_string()); + } + None => {} + } + match agent_json + .get("launch") + .and_then(|launch| launch.get("owner_pubkey")) + .and_then(|v| v.as_str()) + { + Some(owner) => crate::relay::assert_expected_signer(expected_signer_pubkey, owner)?, + None if has_expectation(expected_signer_pubkey) => { + return Err("deploy payload carries no owner identity; not deployed".to_string()); + } + None => {} + } + Ok(()) +} + fn policy_matches_payload( record: &crate::managed_agents::ManagedAgentRecord, deployed_agent_json: &serde_json::Value, @@ -162,6 +215,74 @@ mod tests { serde_json::json!({"respond_to": respond_to, "respond_to_allowlist": []}) } + fn scoped_payload(relay: &str, owner: &str) -> serde_json::Value { + serde_json::json!({ + "relay_url": relay, + "launch": { "owner_pubkey": owner }, + }) + } + + // ── assert_payload_scope: post-lock rebuilt-payload validation ────────── + + #[test] + fn matching_scope_and_signer_pass_on_the_rebuilt_payload() { + assert_payload_scope( + &scoped_payload("wss://tenant-a.example", "aa11"), + Some("wss://tenant-a.example"), + Some("aa11"), + ) + .unwrap(); + } + + #[test] + fn relay_switch_during_the_lock_wait_fails_closed() { + // Round-8 P1: a stale Projects-A start waited behind another deploy; + // the rebuild resolved tenant B. The payload actually invoked must be + // refused — the pre-lock snapshot its caller validated is irrelevant. + let error = assert_payload_scope( + &scoped_payload("wss://tenant-b.example", "aa11"), + Some("wss://tenant-a.example"), + Some("aa11"), + ) + .unwrap_err(); + assert!(error.contains("active community changed"), "{error}"); + } + + #[test] + fn same_relay_identity_switch_during_the_lock_wait_fails_closed() { + // Same relay, different owner: an identity switch alone must also be + // refused — the rebuilt launch.owner_pubkey belongs to a tenant the + // caller never validated. + let error = assert_payload_scope( + &scoped_payload("wss://tenant-a.example", "bb22"), + Some("wss://tenant-a.example"), + Some("aa11"), + ) + .unwrap_err(); + assert!(error.contains("active identity changed"), "{error}"); + } + + #[test] + fn scoped_caller_with_an_unverifiable_payload_fails_closed() { + let payload = serde_json::json!({}); + let relay_error = + assert_payload_scope(&payload, Some("wss://tenant-a.example"), None).unwrap_err(); + assert!(relay_error.contains("no relay"), "{relay_error}"); + let signer_error = assert_payload_scope(&payload, None, Some("aa11")).unwrap_err(); + assert!(signer_error.contains("no owner identity"), "{signer_error}"); + } + + #[test] + fn unscoped_callers_deploy_any_payload() { + assert_payload_scope( + &scoped_payload("wss://anywhere.example", "cc33"), + None, + None, + ) + .unwrap(); + assert_payload_scope(&serde_json::json!({}), None, None).unwrap(); + } + #[test] fn successful_deploy_acknowledges_pending_policy() { let mut record = record(); diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 483eb60134..da5bb3ba5c 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -83,7 +83,25 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); } if let Some(value) = effective_model { - policy_env.insert("BUZZ_ACP_MODEL".into(), value.to_string()); + // B2: remote env-authority model key. Claude's startup model authority + // is ANTHROPIC_MODEL (same as the local A1 path — the harness reads it + // first and skips the BUZZ_ACP_MODEL catalog-switch path that would + // introduce a second startup authority). All other runtimes use + // BUZZ_ACP_MODEL, which the harness reads into desired_model at spawn. + let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); + let model_key = if is_claude { + "ANTHROPIC_MODEL" + } else { + "BUZZ_ACP_MODEL" + }; + policy_env.insert(model_key.into(), value.to_string()); + } + // I-4: remote parity for persisted startup effort. Mirrors the local spawn + // path in runtime.rs. The harness reads BUZZ_ACP_EFFORT_LEVEL into + // PoolStartup.startup_effort and applies it at first session creation via + // resolve_startup_effort(). + if let Some(ref value) = record.effort_level { + policy_env.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.clone()); } if let Some(value) = record.idle_timeout_seconds { policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); @@ -101,10 +119,41 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } + // B5 remote parity: when a canonical effort_level is persisted, strip + // BUZZ_ACP_EFFORT_LEVEL from launch.env so it cannot shadow the canonical + // value in policy_env (tier 1). In the k8s three-tier model tier 2 + // (launch.env) overwrites tier 1 (policy_env) — later-wins — so the key + // must be absent from tier 2 whenever a canonical value is present. + // When effort_level is None there is no canonical to protect, so user + // env passthrough stands (env may legitimately seed startup effort). + // + // B2 remote parity: mirror the local A1 model authority. For a Claude + // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from + // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL + // alone (set above), and launch.env later-wins over policy_env. Left in + // launch.env, a user BUZZ_ACP_MODEL would introduce a second startup + // authority and a user ANTHROPIC_MODEL would silently override the + // canonical model. When no canonical model is present, neither key is in + // policy_env, so stripping them keeps the remote process free of both — + // matching local, where `apply_claude_model_env(None)` removes both. + let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); + let strip_key = |k: &str| { + (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) + || (is_claude + && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") + || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) + }; + let launch_env: BTreeMap = descriptor + .env + .iter() + .filter(|(k, _)| !strip_key(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + serde_json::json!({ "command": descriptor.command, "args": descriptor.args, - "env": descriptor.env, + "env": launch_env, "policy_env": policy_env, "owner_pubkey": owner_pubkey, }) @@ -284,13 +333,227 @@ mod tests { assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_DISPLAY_NAME"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); + // goose runtime: model goes via BUZZ_ACP_MODEL (non-claude path). assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert!( + launch["policy_env"]["ANTHROPIC_MODEL"].is_null(), + "goose must NOT receive ANTHROPIC_MODEL" + ); assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23"); assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); assert_eq!(launch["owner_pubkey"], "owner-hex"); } + #[test] + fn launch_block_claude_runtime_uses_anthropic_model_not_buzz_acp_model() { + // B2: remote claude deploys must send ANTHROPIC_MODEL, not BUZZ_ACP_MODEL, + // so the remote harness has a single startup model authority matching A1. + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::new(), + }; + let teams: Vec = vec![]; + let launch = build_launch_block( + &record, + &descriptor, + &teams, + None, + Some("claude-opus-4"), + "owner-hex", + ); + assert_eq!( + launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4", + "claude remote must receive ANTHROPIC_MODEL" + ); + assert!( + launch["policy_env"]["BUZZ_ACP_MODEL"].is_null(), + "claude remote must NOT receive BUZZ_ACP_MODEL" + ); + } + + /// F2: remote Claude launch must mirror local A1 — ALWAYS strip BOTH + /// BUZZ_ACP_MODEL and ANTHROPIC_MODEL from launch.env (tier 2), so the + /// canonical model in policy_env (tier 1) is the sole authority. Since + /// launch.env later-wins over policy_env, a user BUZZ_ACP_MODEL would add a + /// second startup authority and a user ANTHROPIC_MODEL would silently + /// override the canonical model. + #[test] + fn launch_block_claude_strips_both_model_keys_from_launch_env() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::from([ + ("BUZZ_ACP_MODEL".to_string(), "user-sonnet".to_string()), + ("ANTHROPIC_MODEL".to_string(), "user-opus".to_string()), + ("KEEP_ME".to_string(), "yes".to_string()), + ]), + }; + let launch = build_launch_block( + &record, + &descriptor, + &[], + None, + Some("claude-opus-4"), + "owner-hex", + ); + + // Canonical model rides policy_env alone. + assert_eq!(launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4"); + assert!(launch["policy_env"]["BUZZ_ACP_MODEL"].is_null()); + // Both model keys are stripped from launch.env — neither can later-win. + assert!( + launch["env"]["BUZZ_ACP_MODEL"].is_null(), + "user BUZZ_ACP_MODEL must be stripped from launch.env for claude" + ); + assert!( + launch["env"]["ANTHROPIC_MODEL"].is_null(), + "user ANTHROPIC_MODEL must be stripped from launch.env for claude" + ); + // Unrelated user env survives. + assert_eq!(launch["env"]["KEEP_ME"], "yes"); + } + + /// F2: when no canonical model resolves, a Claude launch still strips both + /// model keys from launch.env, so neither authority reaches the remote + /// process — matching local `apply_claude_model_env(None)`, which removes + /// both. + #[test] + fn launch_block_claude_strips_model_keys_even_without_canonical() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::from([ + ("BUZZ_ACP_MODEL".to_string(), "user-sonnet".to_string()), + ("ANTHROPIC_MODEL".to_string(), "user-opus".to_string()), + ]), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert!(launch["policy_env"]["ANTHROPIC_MODEL"].is_null()); + assert!(launch["policy_env"]["BUZZ_ACP_MODEL"].is_null()); + assert!( + launch["env"]["BUZZ_ACP_MODEL"].is_null(), + "user BUZZ_ACP_MODEL must be stripped even without a canonical model" + ); + assert!( + launch["env"]["ANTHROPIC_MODEL"].is_null(), + "user ANTHROPIC_MODEL must be stripped even without a canonical model" + ); + } + + /// F2: non-Claude runtimes must NOT strip model keys from launch.env — the + /// model authority stripping is Claude-specific (BUZZ_ACP_MODEL is the + /// spawn authority for other runtimes and rides policy_env there). + #[test] + fn launch_block_non_claude_preserves_user_model_env() { + let record = record(); // goose command + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_MODEL".to_string(), "user-model".to_string())]), + }; + let launch = + build_launch_block(&record, &descriptor, &[], None, Some("model"), "owner-hex"); + + // goose puts canonical in policy_env, and the user launch.env value is + // preserved (later-wins is the intended goose behavior). + assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert_eq!(launch["env"]["BUZZ_ACP_MODEL"], "user-model"); + } + + #[test] + fn launch_block_claude_runtime_injects_effort_level_when_set() { + // I-4: remote parity — record.effort_level → BUZZ_ACP_EFFORT_LEVEL in policy_env. + let mut record = record(); + record.effort_level = Some("high".to_string()); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::new(), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "claude remote must receive BUZZ_ACP_EFFORT_LEVEL when effort_level is set" + ); + } + + #[test] + fn launch_block_does_not_inject_effort_level_when_absent() { + // I-4: no BUZZ_ACP_EFFORT_LEVEL in policy_env when record.effort_level is None. + let record = record(); // effort_level is None by default + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::new(), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "policy_env must NOT contain BUZZ_ACP_EFFORT_LEVEL when effort_level is None" + ); + } + + /// B5 remote parity: when a canonical effort_level is persisted, a conflicting + /// user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env must NOT shadow it. + /// The canonical value in policy_env (tier 1) must win in the final build_env + /// output — the key must be absent from launch.env (tier 2) so tier 1 is + /// authoritative. + #[test] + fn launch_block_canonical_effort_strips_user_env_collision() { + let mut record = record(); + record.effort_level = Some("high".to_string()); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + // User-supplied conflicting value in descriptor.env. + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + // Canonical must be in policy_env (tier 1). + assert_eq!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "canonical effort must be in policy_env when record.effort_level is Some" + ); + // Conflicting user value must be absent from launch.env (tier 2) so it + // cannot shadow the canonical tier-1 value in build_env. + assert!( + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "user BUZZ_ACP_EFFORT_LEVEL must be stripped from launch.env when canonical is present" + ); + } + + /// B5 remote parity: when no canonical effort is persisted (effort_level is + /// None), a user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env survives + /// into launch.env — passthrough preserved for startup seeding. + #[test] + fn launch_block_user_effort_env_survives_when_no_canonical_value() { + let record = record(); // effort_level is None + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + // No canonical — key must NOT appear in policy_env. + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "policy_env must NOT contain BUZZ_ACP_EFFORT_LEVEL when effort_level is None" + ); + // User value must survive in launch.env so the harness can use it. + assert_eq!( + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "low", + "user-supplied effort must survive in launch.env when no canonical value" + ); + } + /// OpenClaw descriptor: `launch.policy_env["BUZZ_ACP_AGENTS"]` must be "5" /// even when the record's requested parallelism is 10. This is the direct /// `launch.policy_env` seam test — the executable contract for remote providers. diff --git a/desktop/src-tauri/src/commands/agents_pending.rs b/desktop/src-tauri/src/commands/agents_pending.rs new file mode 100644 index 0000000000..8b9564942c --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_pending.rs @@ -0,0 +1,177 @@ +//! Retention-queue helpers for managed-agent lifecycle events: pending +//! upserts, NIP-09 tombstones, and NIP-IA archive requests. Split from +//! `agents.rs` (which mounts this as `mod pending`) purely along the +//! retention seam; every function runs inside the +//! `managed_agents_store_lock`-held body and NEVER across an `.await`. + +use tauri::AppHandle; + +use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; + +/// Retain a freshly authored managed-agent event in the local store, flagged +/// for relay sync. MUST be called inside the `managed_agents_store_lock`-held +/// body after `save_managed_agents`, NEVER across an `.await`: it acquires +/// `state.keys` and a retention-db connection, both `std::sync` guards, and +/// drops them before returning. +/// +/// Owner-authored, mirroring `commands::personas::retain_persona_pending`: the +/// owner keys sign, the d_tag is the agent's pubkey, so the coordinate is +/// `30177::`. The event content is the opt-IN +/// [`agent_event_content`] projection — the retention upsert's content-equality +/// guard compares this projection, so an operational start/stop that mutates +/// only runtime fields produces an identical row and never re-enqueues a +/// publish. Best-effort: a failure here is logged and swallowed so a retention +/// hiccup never blocks the disk-authoritative write. +pub(crate) fn retain_managed_agent_pending( + app: &AppHandle, + state: &AppState, + record: &ManagedAgentRecord, +) { + use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; + + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + // Shared engine with the boot-time reconcile: projection content diff + // (no republish for runtime-only churn) + monotonic created_at bump + // past the retained head (NIP-AP step 3). + retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: agent-retain: {e}"); + } +} + +/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both +/// inside the `managed_agents_store_lock`-held delete body and NEVER across an +/// `.await`. +/// +/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at +/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can +/// never resurrect it after the tombstone publishes, then the kind:5 tombstone +/// is retained at its own `(5, owner, agent_pubkey)` coordinate with +/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a +/// failure is logged and swallowed so a retention hiccup never blocks the +/// disk-authoritative delete. +pub(crate) fn tombstone_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) { + use crate::managed_agents::{ + agent_events::build_agent_delete, + retention::{ + delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, + RetainedEvent, + }, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + const KIND_DELETE: u32 = 5; + + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; + delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_DELETE, + pubkey: owner_pubkey, + // Key by the target coordinate so cross-kind d-tag tombstones + // occupy distinct rows (F2c). + d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey), + 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: agent-tombstone: {e}"); + } +} + +/// Build an owner-authenticated NIP-IA `kind:9035` archive request for a deleted agent. +/// Definition-linked agents carry the persona id in `content`, where it survives the +/// kind:30177 tombstone as owner-signed historical alias data. The request uses the +/// same builder as the GUI Archive action and the NIP-IA `retired` reason. +pub(crate) fn build_agent_archive_request( + keys: &nostr::Keys, + agent_pubkey: &str, + persona_id: Option<&str>, +) -> Result { + let auth_tag = if keys + .public_key() + .to_hex() + .eq_ignore_ascii_case(agent_pubkey) + { + None + } else { + let agent = nostr::PublicKey::from_hex(agent_pubkey) + .map_err(|e| format!("invalid agent pubkey: {e}"))?; + let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "") + .map_err(|e| format!("failed to build owner auth tag: {e}"))?; + let parts: Vec = serde_json::from_str(&tag_json) + .map_err(|e| format!("failed to parse owner auth tag: {e}"))?; + Some( + <[String; 4]>::try_from(parts) + .map_err(|_| "owner auth tag must have four elements".to_string())?, + ) + }; + let content = persona_id + .filter(|id| !id.trim().is_empty()) + .map(|id| serde_json::json!({ "persona_id": id }).to_string()) + .unwrap_or_default(); + crate::events::build_archive_identity_request( + agent_pubkey, + &content, + Some("retired"), + None, + auth_tag.as_ref(), + )? + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign archive request: {e}")) +} + +/// Durably enqueue the archive request next to the kind:5 tombstone. The flush +/// loop re-signs it with a relay-fresh timestamp. Best-effort and lock-scoped, +/// matching `tombstone_managed_agent_pending`. +pub(crate) fn archive_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, + persona_id: Option<&str>, +) { + use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; + use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey, persona_id)?; + let conn = open_retention_db(&scope.db_path)?; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_IA_ARCHIVE_REQUEST, + pubkey: owner_pubkey, + d_tag: agent_pubkey.to_string(), + 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: agent-archive: {e}"); + } +} diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index d28b58b50a..16a1538c75 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -19,9 +19,11 @@ pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, pub(crate) name: String, pub(crate) relay_url: String, - /// Exact relay for migration work captured while a community is active. - /// Ordinary runtime reconciliation leaves this unset and resolves against - /// the current workspace at execution time. + /// Exact relay pinned by the caller for the deferred task — captured + /// while the authorizing workspace/spawn was active (UI start, boot + /// restore, migration queue). When set it wins unconditionally; a task + /// left unpinned (no tenant boundary) resolves the current workspace at + /// execution time. See `resolve_reconcile_relay`. pub(crate) target_relay_url: Option, /// Expected avatar URL for the published profile. `None` for legacy records /// that predate the `avatar_url` field — these will be backfilled from the @@ -59,6 +61,27 @@ pub(super) fn resolve_legacy_avatar( .unwrap_or_default() } +/// Resolve the relay a reconciliation task will query and publish on. The +/// pure core of the `reconcile_agent_profile` relay choice, extracted so the +/// pinning contract is unit-testable: a caller-pinned `target_relay_url` +/// (captured while the authorizing workspace was active) wins UNCONDITIONALLY +/// over the execution-time workspace read — otherwise a community switch +/// landing between spawn and execution would retarget the kind:0 +/// query/publish to a tenant the caller never authorized. Only an unpinned +/// task (no tenant boundary) resolves the live workspace. +pub(super) fn resolve_reconcile_relay( + target_relay_url: Option<&str>, + record_relay_url: &str, + workspace_relay_at_execution: &str, +) -> String { + match target_relay_url { + Some(pinned) => pinned.to_string(), + None => { + crate::relay::effective_agent_relay_url(record_relay_url, workspace_relay_at_execution) + } + } +} + pub(crate) fn profile_reconcile_data( record: &crate::managed_agents::ManagedAgentRecord, personas: &[crate::managed_agents::AgentDefinition], @@ -153,11 +176,13 @@ pub(crate) fn mark_profile_reconciled( /// profile — and persists the updated record. After backfill, normal /// reconciliation proceeds. /// -/// Query and publish target the relay returned by `effective_agent_relay_url` -/// for every agent regardless of backend: an explicit per-agent `relay_url` -/// wins, and a blank one falls back to the active workspace relay. This keeps -/// reconciliation following the session's relay for never-pinned agents while -/// honoring a deliberate pin wherever it points. +/// Query and publish target the caller-pinned `target_relay_url` when set +/// (UI start, boot restore, migration queue — captured while the authorizing +/// workspace was active); an unpinned task falls back to +/// `effective_agent_relay_url` against the workspace at execution time. This +/// keeps deferred reconciliation from following a community switch it was +/// never authorized for while honoring a deliberate per-agent pin wherever +/// it points. pub(crate) async fn reconcile_agent_profile( state: &AppState, app: &AppHandle, @@ -166,12 +191,13 @@ pub(crate) async fn reconcile_agent_profile( ) -> Result { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; - // An explicit per-agent relay wins; an empty one falls back to the active - // workspace relay. Resolved once and used for both the read and write-back. - let workspace_relay = relay_ws_url_with_override(state); - let relay_url = data.target_relay_url.clone().unwrap_or_else(|| { - crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay) - }); + // Resolved ONCE and used for both the read and the write-back. A pinned + // `target_relay_url` wins unconditionally — see `resolve_reconcile_relay`. + let relay_url = resolve_reconcile_relay( + data.target_relay_url.as_deref(), + &data.relay_url, + &relay_ws_url_with_override(state), + ); if !state .managed_agent_profile_reconcile_enabled diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 0176478bc5..1c222ae23a 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -59,6 +59,7 @@ fn bare_agent_record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], @@ -322,6 +323,31 @@ fn profile_needs_sync_when_missing() { assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); } +// ── resolve_reconcile_relay: deferred-task relay pinning ──────────────────── + +#[test] +fn pinned_reconcile_relay_wins_over_a_post_switch_workspace() { + // Round-8 P1: the fire-and-forget reconciliation spawned by a scoped + // start may execute after an A→B community switch. The pinned relay — + // captured while A was the validated workspace — must win over the + // workspace read at execution time, so the kind:0 query/publish can + // never land on B under A's authorization. + let relay = resolve_reconcile_relay( + Some("wss://tenant-a.example"), + "", // never-pinned record + "wss://tenant-b.example", // the switch landed before execution + ); + assert_eq!(relay, "wss://tenant-a.example"); +} + +#[test] +fn unpinned_reconcile_relay_resolves_the_execution_time_workspace() { + // No tenant boundary: legacy behavior — follow the live workspace via + // effective_agent_relay_url (which ignores the record pin by design). + let relay = resolve_reconcile_relay(None, "wss://stale-pin.example", "wss://tenant-b.example"); + assert_eq!(relay, "wss://tenant-b.example"); +} + #[test] fn profile_needs_sync_when_missing_even_without_expected_avatar() { assert!(profile_needs_sync(None, "Duncan", None)); diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 2688346ffd..abf40c028f 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -10,7 +10,12 @@ use crate::{ // ── Reads (pure-nostr via /query) ──────────────────────────────────────────── -const DIRECTORY_PAGE_SIZE: usize = 500; +// The relay-backed channel list computation (fetch_channels, DirectoryScope, +// the directory cursor, the not-modified hash, and member-count collection) +// lives in the `fetch` submodule to keep this file under the per-file line cap. +mod fetch; +use fetch::{compute_channels_hash, fetch_channels, DirectoryScope}; + const STARTER_CHANNEL_NAMESPACE: uuid::Uuid = uuid::uuid!("3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0"); struct StarterChannelSpec { @@ -32,365 +37,13 @@ const STARTER_CHANNELS: &[StarterChannelSpec] = &[ }, ]; -fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { - let last = page - .last() - .expect("a full relay page always has a last event"); - filter["until"] = serde_json::json!(last.created_at.as_secs()); - filter["before_id"] = serde_json::json!(last.id.to_hex()); -} - -/// Fetch every page for a historical relay filter using the relay's composite -/// `(until, before_id)` cursor. A timestamp-only cursor can skip rows when more -/// than one page of events shares the same second. -async fn query_relay_all( - state: &AppState, - mut filter: serde_json::Value, -) -> Result, String> { - filter["limit"] = serde_json::json!(DIRECTORY_PAGE_SIZE); - let mut all = Vec::new(); - - loop { - let page = query_relay(state, &[filter.clone()]).await?; - let done = page.len() < DIRECTORY_PAGE_SIZE; - - if !done { - advance_directory_cursor(&mut filter, &page); - } - - all.extend(page); - if done { - return Ok(all); - } - } -} - -/// Whether an open channel not yet in the real member set should still be -/// classified `is_member=true` via the pending-owner overlay. Pulled out of -/// `get_channels`'s open-channel branch so the exact `(d_tag, my_pubkey, -/// overlay) -> is_member` decision — including the identity binding that -/// keeps one identity's pending entry from covering another's — is directly -/// unit-testable without going through the async relay-backed command. -fn classify_pending_owner(state: &AppState, my_pubkey: &str, d_tag: Option<&str>) -> bool { - d_tag.is_some_and(|d| state.is_pending_owned_channel(my_pubkey, d)) -} - -// ── FNV-1a hash for the not-modified short-circuit ─────────────────────────── - -/// FNV-1a 64-bit hash over arbitrary bytes. Used in preference to -/// `std::collections::hash_map::DefaultHasher` because the standard library -/// does not guarantee cross-invocation stability. -fn fnv1a_64(data: &[u8]) -> u64 { - const OFFSET: u64 = 14695981039346656037; - const PRIME: u64 = 1099511628211; - let mut hash = OFFSET; - for &byte in data { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(PRIME); - } - hash -} - -/// Stable projection of `ChannelInfo` for hashing. Excludes `last_message_at` -/// so routine message traffic does not invalidate the not-modified short-circuit -/// for the channel list. -#[derive(serde::Serialize)] -struct ChannelInfoForHash<'a> { - id: &'a str, - name: &'a str, - channel_type: &'a str, - visibility: &'a str, - description: &'a str, - topic: &'a Option, - purpose: &'a Option, - member_count: i64, - member_pubkeys: &'a Vec, - archived_at: &'a Option, - participants: &'a Vec, - participant_pubkeys: &'a Vec, - is_member: bool, - ttl_seconds: &'a Option, - ttl_deadline: &'a Option, -} - -/// Compute a stable 64-bit FNV-1a hash over the channel list, canonicalized -/// by sorting on channel id and excluding `last_message_at`. Returns a -/// 16-character lowercase hex string. -fn compute_channels_hash(channels: &[ChannelInfo]) -> String { - let mut sorted: Vec<&ChannelInfo> = channels.iter().collect(); - sorted.sort_by(|a, b| a.id.cmp(&b.id)); - - let projections: Vec> = sorted - .iter() - .map(|c| ChannelInfoForHash { - id: &c.id, - name: &c.name, - channel_type: &c.channel_type, - visibility: &c.visibility, - description: &c.description, - topic: &c.topic, - purpose: &c.purpose, - member_count: c.member_count, - member_pubkeys: &c.member_pubkeys, - archived_at: &c.archived_at, - participants: &c.participants, - participant_pubkeys: &c.participant_pubkeys, - is_member: c.is_member, - ttl_seconds: &c.ttl_seconds, - ttl_deadline: &c.ttl_deadline, - }) - .collect(); - - let canonical = serde_json::to_string(&projections).unwrap_or_default(); - format!("{:016x}", fnv1a_64(canonical.as_bytes())) -} - -// ── Core fetch implementation ───────────────────────────────────────────────── - -/// Fetch the full channel list from the relay. Called by both `get_channels` -/// (the Tauri command, which wraps the result with hash-based short-circuit -/// logic) and `ensure_starter_channels` (which needs the raw list directly). -/// -/// Relay round-trips run in two concurrent phases: -/// - Phase 1 (parallel): member-chain (kind:39002→kind:39000), open directory -/// (kind:39000 all-open), and hidden-DM snapshot (kind:30622). -/// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message -/// timestamps (per-channel kind:9/40002). -async fn fetch_channels(state: &AppState) -> Result, String> { - #[cfg(debug_assertions)] - let _profile_start = std::time::Instant::now(); - - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; - - // Phase 1 — concurrent: member-chain (steps 1→2), open directory (step 3), - // and hidden-DM snapshot (step 6). These three have no mutual dependencies. - let (member_chain_result, open_meta_result, hidden_dms) = tokio::join!( - // Steps 1+2: find the channels this identity belongs to, then fetch - // their metadata events. - async { - // Step 1: kind:39002 events listing my pubkey as a member. - let member_events = query_relay_all( - state, - serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), - ) - .await?; - - let mut member_channel_ids: Vec = member_events - .iter() - .filter_map(|ev| { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "d" { - Some(s[1].clone()) - } else { - None - } - }) - }) - .collect(); - member_channel_ids.sort(); - member_channel_ids.dedup(); - - // Real kind:39002 membership has landed — clear the pending-owner - // overlay so a subsequent leave correctly flips `is_member` back - // to false. See `AppState::pending_owned_channels`. - for id in &member_channel_ids { - state.clear_pending_owned_channel(&my_pubkey, id); - } - - // Step 2: fetch channel metadata events (kind:39000) for member channels. - // kind:39000 is addressable: exactly one event per `d` tag, so a limit - // equal to the number of ids is both necessary and sufficient. - let meta_events = if !member_channel_ids.is_empty() { - query_relay( - state, - &[serde_json::json!({ - "kinds": [39000], - "#d": &member_channel_ids, - "limit": member_channel_ids.len(), - })], - ) - .await? - } else { - Vec::new() - }; - - Ok::<_, String>(meta_events) - }, - // Step 3: fetch ALL open channel metadata so the channel browser can show - // discoverable channels the user hasn't joined yet. - query_relay_all(state, serde_json::json!({"kinds": [39000]})), - // Step 6: NIP-DV hidden-DM snapshot. Tolerant — a failure means no DMs - // are hidden rather than aborting the whole fetch. - async { - let events = query_relay( - state, - &[serde_json::json!({ - "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], - "#p": [&my_pubkey], - "limit": 1, - })], - ) - .await - .unwrap_or_default(); - events - .iter() - .max_by_key(|e| e.created_at.as_secs()) - .map(|e| { - e.tags - .iter() - .filter_map(|t| { - let s = t.as_slice(); - (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) - }) - .collect::>() - }) - .unwrap_or_default() - }, - ); - - #[cfg(debug_assertions)] - let t_phase1 = _profile_start.elapsed(); - - let meta_events = member_chain_result?; - let open_meta_events = open_meta_result?; - // hidden_dms is already a resolved HashSet (tolerant path above) - - // Merge: member channels (marked as member) + open channels (not yet joined). - let member_d_tags: std::collections::HashSet = meta_events - .iter() - .filter_map(|ev| { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "d" { - Some(s[1].clone()) - } else { - None - } - }) - }) - .collect(); - - let mut channels = Vec::with_capacity(meta_events.len() + open_meta_events.len()); - for ev in &meta_events { - if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(true)) { - channels.push(info); - } - } - for ev in &open_meta_events { - // Skip channels already included from the member set. - let d_tag = ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "d" { - Some(s[1].clone()) - } else { - None - } - }); - if let Some(ref d) = d_tag { - if member_d_tags.contains(d) { - continue; - } - } - // The overlay (`AppState::pending_owned_channels`) marks channels this - // identity just created via `create_channel` whose kind:39002 owner - // membership hasn't propagated yet (#1761). - let is_pending_owner = classify_pending_owner(state, &my_pubkey, d_tag.as_deref()); - if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(is_pending_owner)) { - channels.push(info); - } - } - - // Phase 2 — concurrent: member counts (step 4) and last-message timestamps - // (step 5). Both tolerate failures — empty defaults leave counts at 0 and - // timestamps at None rather than aborting. - let all_channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); - if !all_channel_ids.is_empty() { - let last_msg_filters: Vec = all_channel_ids - .iter() - .map(|id| { - serde_json::json!({ - "kinds": [9, 40002], - "#h": [id], - "limit": 1 - }) - }) - .collect(); - - // Bind both filter arrays before the join so their lifetimes cover - // both branches of the concurrent pair. - let member_count_filters = [serde_json::json!({ - "kinds": [39002], - "#d": &all_channel_ids, - "limit": all_channel_ids.len(), - })]; - let (members_result, message_result) = tokio::join!( - // Step 4: batch-fetch kind:39002 for member counts. - query_relay(state, &member_count_filters), - // Step 5: per-channel last-message filter. Uses per-channel `#h` - // so the relay can push each query to its indexed channel_id column. - query_relay(state, &last_msg_filters), - ); - - let membership = collect_members_by_channel(&members_result.unwrap_or_default()); - for channel in &mut channels { - if let Some(info) = membership.get(&channel.id) { - channel.member_count = info.count; - channel.member_pubkeys = info.pubkeys.clone(); - } - } - - let mut last_message_by_channel: std::collections::HashMap = - std::collections::HashMap::new(); - for ev in &message_result.unwrap_or_default() { - if let Some(ch_id) = ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) - }) { - let ts = ev.created_at.as_secs(); - last_message_by_channel - .entry(ch_id) - .and_modify(|existing| { - if ts > *existing { - *existing = ts; - } - }) - .or_insert(ts); - } - } - for channel in &mut channels { - if let Some(&ts) = last_message_by_channel.get(&channel.id) { - channel.last_message_at = Some(nostr_convert::timestamp_to_iso(ts)); - } - } - } - - #[cfg(debug_assertions)] - { - let total = _profile_start.elapsed(); - eprintln!( - "buzz-desktop: get_channels profile channels={} phase1(member_chain+open_meta+hidden_dm)={:?} phase2(member_counts+last_msg)={:?} total={:?}", - channels.len(), - t_phase1, - total - t_phase1, - total, - ); - } - - // NIP-DV: drop DMs the viewer has hidden. - if !hidden_dms.is_empty() { - channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); - } - - Ok(channels) -} - // ── Tauri commands ──────────────────────────────────────────────────────────── -/// Return the full channel list for the active identity. +/// Return the channels the active identity belongs to (plus its own +/// not-yet-propagated creations). This is the 60s poll path: it performs no +/// all-open directory scan, so its phase-2 fan-out is bounded by membership. +/// Joinable open channels are served separately by +/// [`get_open_channel_directory`]. /// /// `known_hash` is a previously returned `hash` value. When it matches the /// computed stable hash (which excludes `last_message_at`), the response @@ -402,7 +55,7 @@ pub async fn get_channels( known_hash: Option, state: State<'_, AppState>, ) -> Result { - let channels = fetch_channels(&state).await?; + let channels = fetch_channels(&state, DirectoryScope::MemberOnly).await?; let last_messages: std::collections::HashMap = channels .iter() @@ -433,40 +86,17 @@ pub async fn get_channels( }) } -struct ChannelMembership { - count: i64, - pubkeys: Vec, -} - -/// Build a `channel_id → membership` map from a batch of kind:39002 events. -/// Events without a `d` tag are skipped; member dedupe is delegated to -/// [`nostr_convert::channel_members_from_event`] so the parsing rules match the -/// per-channel `get_channel_members` path. -fn collect_members_by_channel( - events: &[nostr::Event], -) -> std::collections::HashMap { - let mut map: std::collections::HashMap = - std::collections::HashMap::with_capacity(events.len()); - for ev in events { - let Some(d) = ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - (s.len() >= 2 && s[0] == "d").then(|| s[1].clone()) - }) else { - continue; - }; - let Ok(resp) = nostr_convert::channel_members_from_event(ev) else { - continue; - }; - let pubkeys: Vec = resp.members.iter().map(|m| m.pubkey.clone()).collect(); - map.insert( - d, - ChannelMembership { - count: pubkeys.len() as i64, - pubkeys, - }, - ); - } - map +/// Return the open-channel directory: every joinable open channel plus the +/// identity's own channels, marked with `is_member`. This is the discovery +/// superset that `get_channels` intentionally omits from the 60s poll — the +/// channel browser and global search fetch it on demand (browse open / search +/// active) with a generous staleTime, so the expensive all-open scan runs only +/// when a user is actually looking for channels to join. +#[tauri::command] +pub async fn get_open_channel_directory( + state: State<'_, AppState>, +) -> Result, String> { + fetch_channels(&state, DirectoryScope::IncludeOpenDirectory).await } #[tauri::command] @@ -711,7 +341,8 @@ pub async fn create_channel( pub async fn ensure_starter_channels( state: State<'_, AppState>, ) -> Result, String> { - let mut existing_channels = fetch_channels(&state).await?; + let mut existing_channels = + fetch_channels(&state, DirectoryScope::IncludeOpenDirectory).await?; let relay_scope = relay_api_base_url_with_override(&state); let creator_keys = state.signing_keys()?; let creator_pubkey = creator_keys.public_key().to_hex(); @@ -770,7 +401,7 @@ pub async fn ensure_starter_channels( } if !has_all_starter_channels(&existing_channels) { - existing_channels = fetch_channels(&state).await?; + existing_channels = fetch_channels(&state, DirectoryScope::IncludeOpenDirectory).await?; } if !has_all_starter_channels(&existing_channels) { diff --git a/desktop/src-tauri/src/commands/channels/fetch.rs b/desktop/src-tauri/src/commands/channels/fetch.rs new file mode 100644 index 0000000000..c41a707341 --- /dev/null +++ b/desktop/src-tauri/src/commands/channels/fetch.rs @@ -0,0 +1,456 @@ +//! Relay-backed channel list computation for the channels commands. +//! +//! Split out of `channels.rs` to keep that file under the per-file line cap. +//! Owns the two-phase relay fetch (`fetch_channels`), its `DirectoryScope` +//! (member-only poll vs. the discovery superset), the paged directory cursor, +//! the not-modified hash, and the member-count collection. The Tauri commands +//! and channel writes stay in `channels.rs`. + +use crate::{app_state::AppState, models::ChannelInfo, nostr_convert, relay::query_relay}; + +pub(super) const DIRECTORY_PAGE_SIZE: usize = 500; + +pub(super) fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { + let last = page + .last() + .expect("a full relay page always has a last event"); + filter["until"] = serde_json::json!(last.created_at.as_secs()); + filter["before_id"] = serde_json::json!(last.id.to_hex()); +} + +/// Fetch every page for a historical relay filter using the relay's composite +/// `(until, before_id)` cursor. A timestamp-only cursor can skip rows when more +/// than one page of events shares the same second. +async fn query_relay_all( + state: &AppState, + mut filter: serde_json::Value, +) -> Result, String> { + filter["limit"] = serde_json::json!(DIRECTORY_PAGE_SIZE); + let mut all = Vec::new(); + + loop { + let page = query_relay(state, &[filter.clone()]).await?; + let done = page.len() < DIRECTORY_PAGE_SIZE; + + if !done { + advance_directory_cursor(&mut filter, &page); + } + + all.extend(page); + if done { + return Ok(all); + } + } +} + +/// Whether an open channel not yet in the real member set should still be +/// classified `is_member=true` via the pending-owner overlay. Pulled out of +/// `get_channels`'s open-channel branch so the exact `(d_tag, my_pubkey, +/// overlay) -> is_member` decision — including the identity binding that +/// keeps one identity's pending entry from covering another's — is directly +/// unit-testable without going through the async relay-backed command. +pub(super) fn classify_pending_owner( + state: &AppState, + my_pubkey: &str, + d_tag: Option<&str>, +) -> bool { + d_tag.is_some_and(|d| state.is_pending_owned_channel(my_pubkey, d)) +} + +// ── FNV-1a hash for the not-modified short-circuit ─────────────────────────── + +/// FNV-1a 64-bit hash over arbitrary bytes. Used in preference to +/// `std::collections::hash_map::DefaultHasher` because the standard library +/// does not guarantee cross-invocation stability. +fn fnv1a_64(data: &[u8]) -> u64 { + const OFFSET: u64 = 14695981039346656037; + const PRIME: u64 = 1099511628211; + let mut hash = OFFSET; + for &byte in data { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(PRIME); + } + hash +} + +/// Stable projection of `ChannelInfo` for hashing. Excludes `last_message_at` +/// so routine message traffic does not invalidate the not-modified short-circuit +/// for the channel list. +#[derive(serde::Serialize)] +struct ChannelInfoForHash<'a> { + id: &'a str, + name: &'a str, + channel_type: &'a str, + visibility: &'a str, + description: &'a str, + topic: &'a Option, + purpose: &'a Option, + member_count: i64, + member_pubkeys: &'a Vec, + archived_at: &'a Option, + participants: &'a Vec, + participant_pubkeys: &'a Vec, + is_member: bool, + ttl_seconds: &'a Option, + ttl_deadline: &'a Option, +} + +/// Compute a stable 64-bit FNV-1a hash over the channel list, canonicalized +/// by sorting on channel id and excluding `last_message_at`. Returns a +/// 16-character lowercase hex string. +pub(super) fn compute_channels_hash(channels: &[ChannelInfo]) -> String { + let mut sorted: Vec<&ChannelInfo> = channels.iter().collect(); + sorted.sort_by(|a, b| a.id.cmp(&b.id)); + + let projections: Vec> = sorted + .iter() + .map(|c| ChannelInfoForHash { + id: &c.id, + name: &c.name, + channel_type: &c.channel_type, + visibility: &c.visibility, + description: &c.description, + topic: &c.topic, + purpose: &c.purpose, + member_count: c.member_count, + member_pubkeys: &c.member_pubkeys, + archived_at: &c.archived_at, + participants: &c.participants, + participant_pubkeys: &c.participant_pubkeys, + is_member: c.is_member, + ttl_seconds: &c.ttl_seconds, + ttl_deadline: &c.ttl_deadline, + }) + .collect(); + + let canonical = serde_json::to_string(&projections).unwrap_or_default(); + format!("{:016x}", fnv1a_64(canonical.as_bytes())) +} + +// ── Core fetch implementation ───────────────────────────────────────────────── + +/// Whether `fetch_channels` includes the unbounded all-open directory scan. +/// +/// The 60s channel poll uses [`DirectoryScope::MemberOnly`]: it resolves only +/// the channels the identity belongs to (plus its own not-yet-propagated +/// creations), so phase 2's fan-out is bounded by membership instead of the +/// entire relay. [`DirectoryScope::IncludeOpenDirectory`] additionally scans +/// every open channel — the discovery surfaces (channel browser, global +/// search) and onboarding need that superset, but the poll must not pay for it +/// on every tick. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum DirectoryScope { + MemberOnly, + IncludeOpenDirectory, +} + +/// Fetch the channel list from the relay at the requested [`DirectoryScope`]. +/// Called by `get_channels` (member-only poll, wrapped with hash-based +/// short-circuit logic), `get_open_channel_directory` (discovery superset), and +/// `ensure_starter_channels` (which needs the raw open-inclusive list). +/// +/// Relay round-trips run in two concurrent phases: +/// - Phase 1 (parallel): member-chain (kind:39002→kind:39000), the non-member +/// metadata source (pending-owned ids when member-only, else the all-open +/// kind:39000 scan), and the hidden-DM snapshot (kind:30622). +/// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message +/// timestamps (per-channel kind:9/40002), fanned out over the merged set. +pub(super) async fn fetch_channels( + state: &AppState, + scope: DirectoryScope, +) -> Result, String> { + #[cfg(debug_assertions)] + let _profile_start = std::time::Instant::now(); + + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + + // Channels this identity created whose kind:39002 membership hasn't yet + // propagated. Under member-only scope they are the only non-member + // metadata we resolve, so a just-created channel stays visible without the + // all-open scan (#1761). Read before the member chain runs; any that have + // since become real members are harmlessly skipped during the merge. + let pending_owned_ids = state.pending_owned_channel_ids(&my_pubkey); + + // Phase 1 — concurrent: member-chain (steps 1→2), the non-member metadata + // source (step 3), and hidden-DM snapshot (step 6). No mutual dependencies. + let (member_chain_result, open_meta_result, hidden_dms) = tokio::join!( + // Steps 1+2: find the channels this identity belongs to, then fetch + // their metadata events. + async { + // Step 1: kind:39002 events listing my pubkey as a member. + let member_events = query_relay_all( + state, + serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), + ) + .await?; + + let mut member_channel_ids: Vec = member_events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .collect(); + member_channel_ids.sort(); + member_channel_ids.dedup(); + + // Real kind:39002 membership has landed — clear the pending-owner + // overlay so a subsequent leave correctly flips `is_member` back + // to false. See `AppState::pending_owned_channels`. + for id in &member_channel_ids { + state.clear_pending_owned_channel(&my_pubkey, id); + } + + // Step 2: fetch channel metadata events (kind:39000) for member channels. + // kind:39000 is addressable: exactly one event per `d` tag, so a limit + // equal to the number of ids is both necessary and sufficient. + let meta_events = if !member_channel_ids.is_empty() { + query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": &member_channel_ids, + "limit": member_channel_ids.len(), + })], + ) + .await? + } else { + Vec::new() + }; + + Ok::<_, String>(meta_events) + }, + // Step 3: non-member channel metadata (kind:39000). + // - IncludeOpenDirectory: scan ALL open channels so the discovery + // surfaces can show joinable channels the user hasn't joined yet. + // - MemberOnly: resolve only the pending-owned ids, keeping a + // just-created channel visible without the unbounded all-open scan. + async { + match scope { + DirectoryScope::IncludeOpenDirectory => { + query_relay_all(state, serde_json::json!({"kinds": [39000]})).await + } + DirectoryScope::MemberOnly if !pending_owned_ids.is_empty() => { + query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": &pending_owned_ids, + "limit": pending_owned_ids.len(), + })], + ) + .await + } + DirectoryScope::MemberOnly => Ok(Vec::new()), + } + }, + // Step 6: NIP-DV hidden-DM snapshot. Tolerant — a failure means no DMs + // are hidden rather than aborting the whole fetch. + async { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], + "#p": [&my_pubkey], + "limit": 1, + })], + ) + .await + .unwrap_or_default(); + events + .iter() + .max_by_key(|e| e.created_at.as_secs()) + .map(|e| { + e.tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) + }) + .collect::>() + }) + .unwrap_or_default() + }, + ); + + #[cfg(debug_assertions)] + let t_phase1 = _profile_start.elapsed(); + + let meta_events = member_chain_result?; + let open_meta_events = open_meta_result?; + // hidden_dms is already a resolved HashSet (tolerant path above) + + // Merge: member channels (marked as member) + non-member channels (open + // directory when included, else pending-owned) not already in the member set. + let member_d_tags: std::collections::HashSet = meta_events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .collect(); + + let mut channels = Vec::with_capacity(meta_events.len() + open_meta_events.len()); + for ev in &meta_events { + if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(true)) { + channels.push(info); + } + } + for ev in &open_meta_events { + // Skip channels already included from the member set. + let d_tag = ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }); + if let Some(ref d) = d_tag { + if member_d_tags.contains(d) { + continue; + } + } + // The overlay (`AppState::pending_owned_channels`) marks channels this + // identity just created via `create_channel` whose kind:39002 owner + // membership hasn't propagated yet (#1761). + let is_pending_owner = classify_pending_owner(state, &my_pubkey, d_tag.as_deref()); + if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(is_pending_owner)) { + channels.push(info); + } + } + + // Phase 2 — concurrent: member counts (step 4) and last-message timestamps + // (step 5). Both tolerate failures — empty defaults leave counts at 0 and + // timestamps at None rather than aborting. + let all_channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); + if !all_channel_ids.is_empty() { + let last_msg_filters: Vec = all_channel_ids + .iter() + .map(|id| { + serde_json::json!({ + "kinds": [9, 40002], + "#h": [id], + "limit": 1 + }) + }) + .collect(); + + // Bind both filter arrays before the join so their lifetimes cover + // both branches of the concurrent pair. + let member_count_filters = [serde_json::json!({ + "kinds": [39002], + "#d": &all_channel_ids, + "limit": all_channel_ids.len(), + })]; + let (members_result, message_result) = tokio::join!( + // Step 4: batch-fetch kind:39002 for member counts. + query_relay(state, &member_count_filters), + // Step 5: per-channel last-message filter. Uses per-channel `#h` + // so the relay can push each query to its indexed channel_id column. + query_relay(state, &last_msg_filters), + ); + + let membership = collect_members_by_channel(&members_result.unwrap_or_default()); + for channel in &mut channels { + if let Some(info) = membership.get(&channel.id) { + channel.member_count = info.count; + channel.member_pubkeys = info.pubkeys.clone(); + } + } + + let mut last_message_by_channel: std::collections::HashMap = + std::collections::HashMap::new(); + for ev in &message_result.unwrap_or_default() { + if let Some(ch_id) = ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) + }) { + let ts = ev.created_at.as_secs(); + last_message_by_channel + .entry(ch_id) + .and_modify(|existing| { + if ts > *existing { + *existing = ts; + } + }) + .or_insert(ts); + } + } + for channel in &mut channels { + if let Some(&ts) = last_message_by_channel.get(&channel.id) { + channel.last_message_at = Some(nostr_convert::timestamp_to_iso(ts)); + } + } + } + + #[cfg(debug_assertions)] + { + let total = _profile_start.elapsed(); + eprintln!( + "buzz-desktop: get_channels profile channels={} phase1(member_chain+open_meta+hidden_dm)={:?} phase2(member_counts+last_msg)={:?} total={:?}", + channels.len(), + t_phase1, + total - t_phase1, + total, + ); + } + + // NIP-DV: drop DMs the viewer has hidden. + if !hidden_dms.is_empty() { + channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); + } + + Ok(channels) +} + +pub(super) struct ChannelMembership { + pub(super) count: i64, + pub(super) pubkeys: Vec, +} + +/// Build a `channel_id → membership` map from a batch of kind:39002 events. +/// Events without a `d` tag are skipped; member dedupe is delegated to +/// [`nostr_convert::channel_members_from_event`] so the parsing rules match the +/// per-channel `get_channel_members` path. +pub(super) fn collect_members_by_channel( + events: &[nostr::Event], +) -> std::collections::HashMap { + let mut map: std::collections::HashMap = + std::collections::HashMap::with_capacity(events.len()); + for ev in events { + let Some(d) = ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "d").then(|| s[1].clone()) + }) else { + continue; + }; + let Ok(resp) = nostr_convert::channel_members_from_event(ev) else { + continue; + }; + let pubkeys: Vec = resp.members.iter().map(|m| m.pubkey.clone()).collect(); + map.insert( + d, + ChannelMembership { + count: pubkeys.len() as i64, + pubkeys, + }, + ); + } + map +} diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 43da15703c..e78d4498c8 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -2,6 +2,9 @@ // channels.rs under the per-file line cap. use super::*; +// The relay-backed fetch helpers moved to the `fetch` submodule; its +// `pub(super)` items are visible here as a descendant of the channels module. +use super::fetch::*; use crate::models::ChannelInfo; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; @@ -195,6 +198,37 @@ fn pending_overlay_does_not_leak_across_identity_swap() { assert!(!state.is_pending_owned_channel(PK_B, "chan-1")); } +#[test] +fn pending_owned_channel_ids_scopes_to_the_asking_identity() { + // The member-only poll resolves non-member metadata solely from this + // helper (no all-open scan), so it must return exactly the caller's own + // not-yet-propagated channels — never another identity's — and nothing + // once membership is observed. + let state = crate::app_state::build_app_state(); + state.mark_pending_owned_channel(PK_A, "chan-1"); + state.mark_pending_owned_channel(PK_A, "chan-2"); + state.mark_pending_owned_channel(PK_B, "chan-3"); + + let mut a_ids = state.pending_owned_channel_ids(PK_A); + a_ids.sort(); + assert_eq!(a_ids, vec!["chan-1".to_string(), "chan-2".to_string()]); + assert_eq!( + state.pending_owned_channel_ids(PK_B), + vec!["chan-3".to_string()] + ); + + // Once chan-1's real membership lands, it drops out of the overlay set. + state.clear_pending_owned_channel(PK_A, "chan-1"); + assert_eq!( + state.pending_owned_channel_ids(PK_A), + vec!["chan-2".to_string()] + ); + + // An identity with no pending creations resolves no non-member metadata, + // so the member-only fetch issues no `#d` directory query at all. + assert!(state.pending_owned_channel_ids(PK_C).is_empty()); +} + #[test] fn classify_pending_owner_matches_only_the_owning_identity() { // Exercises the exact branch-level decision `get_channels`'s open-channel diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index dcac491b16..5f6ca27980 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -6,7 +6,10 @@ use crate::{ events, models::ChannelInfo, nostr_convert, - relay::{parse_command_response, query_relay, submit_event}, + relay::{ + assert_expected_relay_scope, assert_expected_signer, parse_command_response, + query_relay_at_with_keys, submit_event, submit_event_at_with_keys, + }, }; #[derive(Deserialize)] @@ -17,23 +20,47 @@ struct OpenDmAck { #[tauri::command] pub async fn open_dm( pubkeys: Vec, + expected_relay_url: Option, + expected_signer_pubkey: Option, state: State<'_, AppState>, ) -> Result { + // Resolve the relay AND the signing identity once for the open + metadata + // read pair. Callers with a captured tenant scope (Projects agent sends) + // pass `expected_relay_url` and `expected_signer_pubkey`; a mismatch on + // either means the active community changed while their callback was + // suspended. The relay check alone is not enough: relay and keys mutate + // under separate locks during a workspace switch, so a switch landing + // between the URL check and the key read would otherwise create the + // tenant-A DM signed as tenant B's identity — fail closed instead, and + // use this exact key snapshot for both the event signature and the + // NIP-98 auth of every request in this command. + let api_base_url = crate::relay::relay_api_base_url_with_override(&state); + assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?; + let keys = state.signing_keys()?; + assert_expected_signer( + expected_signer_pubkey.as_deref(), + &keys.public_key().to_hex(), + )?; + // Submit a kind:41010 dm-open event; the relay replies with the channel id // in its OK message payload. let builder = events::build_dm_open(&pubkeys)?; - let result = submit_event(builder, &state).await?; + let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?; let ack: OpenDmAck = parse_command_response(&result.message)?; // Re-fetch the channel metadata so the frontend gets the same `ChannelInfo` - // shape as `get_channel_details`. - let metadata = query_relay( + // shape as `get_channel_details` — through the same scope-checked base and + // the same pinned identity. + let metadata = query_relay_at_with_keys( &state, + &api_base_url, &[serde_json::json!({ "kinds": [39000], "#d": [ack.channel_id], "limit": 1 })], + &keys, + None, ) .await?; diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index d15ee82abc..0cc5679bf7 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -12,17 +12,50 @@ //! see §Owner-of-Agent Requests and §Relay Processing Algorithm. use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; use crate::{ app_state::AppState, events, + managed_agents::try_regenerate_nest, relay::{ - classify_request_error, query_relay, relay_http_base_url, relay_ws_url_with_override, - submit_event, SubmitEventResponse, + classify_request_error, query_relay, query_relay_at, relay_api_base_url, + relay_http_base_url, relay_ws_url, relay_ws_url_with_override, submit_event, + workspace_relay_override, SubmitEventResponse, }, }; +/// A relay target resolved from a single workspace-override read, so a caller +/// that performs several relay requests cannot mix two relays if the workspace +/// override changes mid-flight. +/// +/// `relay_ws_url_with_override` and `relay_api_base_url_with_override` each read +/// the override independently; a workspace switch between two such reads can +/// pair one relay's NIP-11 signer with another relay's snapshot query. +/// Capturing both fields from one read — matching those two functions' exact +/// precedence, including the standalone `BUZZ_RELAY_HTTP` path when no override +/// is set — guarantees the pair is internally consistent. +pub(crate) struct RelayTarget { + /// Relay WebSocket URL (drives the NIP-11 fetch and the rendered footer). + pub ws_url: String, + /// Relay HTTP API base URL (drives `/query`). + pub api_base_url: String, +} + +/// Capture the effective relay target once, before any network work. +pub(crate) fn capture_relay_target(state: &AppState) -> RelayTarget { + match workspace_relay_override(state) { + Some(url) => RelayTarget { + api_base_url: relay_http_base_url(&url), + ws_url: url, + }, + None => RelayTarget { + ws_url: relay_ws_url(), + api_base_url: relay_api_base_url(), + }, + } +} + // ── Helpers ───────────────────────────────────────────────────────────────── /// Read `target`'s live `kind:0` event and extract the first valid NIP-OA @@ -139,44 +172,116 @@ pub struct UnarchiveRequest { pub reason: Option, } -/// Submit a `kind:9035` archive request to the relay. Consent path is selected -/// by the relay — we just attach the owner-of-agent `auth` tag when the live -/// `kind:0` proves we own the target, so the relay can choose the `owner` -/// path. Self and admin paths require no auth tag. -#[tauri::command] -pub async fn archive_identity( - req: ArchiveRequest, - state: State<'_, AppState>, -) -> Result { - let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; - let auth_ref = auth_tag.as_ref(); +/// Roster refresh a successful archive/unarchive triggers. Binding the action +/// to a *type* rather than a closure selected at each call site is what closes +/// the regression Thufir found: the command wrapper passes a value (`&app`) +/// with no callback to construct, so the "regenerate on success" selection +/// lives entirely inside the cores below — where the tests traverse it. The +/// production binding is the single, irreducible `AppHandle` adapter. +pub(crate) trait NestRegenTrigger { + fn trigger(&self); +} +impl NestRegenTrigger for AppHandle { + fn trigger(&self) { + try_regenerate_nest(self); + } +} + +/// Submit `builder` to the active workspace relay, then trigger `on_success` +/// exactly once iff the relay accepted the event. +/// +/// This pins the shared half of the archive/unarchive → AGENTS.md-regeneration +/// contract: regeneration is best-effort roster maintenance, so it must fire on +/// a successful submission and must NOT fire when the submit is rejected (a +/// rejected request changed nothing to re-render). +async fn submit_then_regenerate( + builder: nostr::EventBuilder, + state: &AppState, + on_success: impl FnOnce(), +) -> Result { + let response = submit_event(builder, state).await?; + on_success(); + Ok(response) +} + +/// `AppHandle`-free core of [`archive_identity`]: resolve the owner-of-agent +/// `auth` tag, build the real `kind:9035` request, submit it, and trigger +/// `regen` so a successful archive refreshes the roster. +/// +/// The command wrapper is untestable (it needs a live Tauri runtime for its +/// `AppHandle`), so this core owns the whole orchestration — including *binding* +/// the regeneration trigger onto the successful-submit path. The wrapper only +/// hands it the `AppHandle` as the trigger; a test drives the exact archive +/// wiring with a counting trigger over a loopback relay. RED-on-revert: change +/// `|| regen.trigger()` to `|| {}` here and +/// `archive_core_fires_regen_only_on_accepted_submit` fails while the unarchive +/// core test stays green. +async fn archive_identity_core( + req: &ArchiveRequest, + state: &AppState, + regen: &impl NestRegenTrigger, +) -> Result { + let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?; let builder = events::build_archive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), req.replaced_by.as_deref(), - auth_ref, + auth_tag.as_ref(), )?; - submit_event(builder, &state).await + submit_then_regenerate(builder, state, || regen.trigger()).await } -/// Submit a `kind:9036` unarchive request to the relay. -#[tauri::command] -pub async fn unarchive_identity( - req: UnarchiveRequest, - state: State<'_, AppState>, +/// `AppHandle`-free core of [`unarchive_identity`]: builds the real `kind:9036` +/// request and triggers `regen` on acceptance. See [`archive_identity_core`] +/// for why this seam is extracted. RED-on-revert: change `|| regen.trigger()` +/// to `|| {}` here and `unarchive_core_fires_regen_only_on_accepted_submit` +/// fails while the archive core test stays green. +async fn unarchive_identity_core( + req: &UnarchiveRequest, + state: &AppState, + regen: &impl NestRegenTrigger, ) -> Result { - let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; - let auth_ref = auth_tag.as_ref(); - + let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?; let builder = events::build_unarchive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), - auth_ref, + auth_tag.as_ref(), )?; - submit_event(builder, &state).await + submit_then_regenerate(builder, state, || regen.trigger()).await +} + +/// Submit a `kind:9035` archive request to the relay. Consent path is selected +/// by the relay — we just attach the owner-of-agent `auth` tag when the live +/// `kind:0` proves we own the target, so the relay can choose the `owner` +/// path. Self and admin paths require no auth tag. +/// +/// On acceptance, refresh AGENTS.md so a just-archived agent drops from the +/// roster without waiting for the next unrelated edit or app restart. The +/// regen is fire-and-forget and fail-open like every other mutation site; it +/// races the relay's kind:13535 snapshot update, so a stale render self-heals +/// on the next regen. +#[tauri::command] +pub async fn archive_identity( + req: ArchiveRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + archive_identity_core(&req, &state, &app).await +} + +/// Submit a `kind:9036` unarchive request to the relay. See +/// [`archive_identity`]: refresh the roster so an unarchived agent reappears +/// promptly, fail-open against the same snapshot race. +#[tauri::command] +pub async fn unarchive_identity( + req: UnarchiveRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + unarchive_identity_core(&req, &state, &app).await } /// If the current user is the verified NIP-OA owner of `target`, return the @@ -228,8 +333,18 @@ struct RelayInformationDocument { } pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> { - let relay_url = relay_ws_url_with_override(state); - let http_url = relay_http_base_url(&relay_url); + fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await +} + +/// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL +/// instead of re-resolving the workspace override. Used by +/// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot +/// query belong to the same captured relay target. +pub(crate) async fn fetch_relay_self_at( + state: &AppState, + relay_url: &str, +) -> Result, String> { + let http_url = relay_http_base_url(relay_url); let response = state .http_client .get(&http_url) @@ -275,46 +390,71 @@ fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec { .collect() } -/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend -/// caches this and tests membership client-side to drive the "Archived" flair. +/// Read the relay's latest valid `kind:13535` archive snapshot as lowercase +/// hex pubkeys. Shared by the `list_archived_identities` command (frontend +/// flair) and the backend nest regen (excluding archived agents from +/// `AGENTS.md`). /// /// Per NIP-IA §Client Behavior and §Snapshot and Delta Consistency, only a /// snapshot signed by the relay identity advertised in NIP-11 `self` can affect -/// archive state. If the relay has no stable `self`, fail open with an empty -/// snapshot rather than trusting unauthenticated relay-authoritative state. -#[tauri::command] -pub async fn list_archived_identities( - state: State<'_, AppState>, -) -> Result { - let Some(relay_self) = fetch_relay_self(&state).await? else { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); +/// archive state. Every failure path — no stable `self`, no snapshot, a bad +/// signature or wrong author, or a query error — **fails open** with an empty +/// set rather than trusting unauthenticated relay-authoritative state. +pub(crate) async fn fetch_archived_pubkeys(state: &AppState) -> Vec { + fetch_archived_pubkeys_at(state, &capture_relay_target(state)).await +} + +/// Like [`fetch_archived_pubkeys`] but resolves both the NIP-11 signer and the +/// snapshot query against one captured [`RelayTarget`] instead of re-reading +/// the workspace override for each. This keeps a regeneration's advertised +/// signer and its snapshot query on the same relay even if the workspace +/// override changes between the two awaits. +pub(crate) async fn fetch_archived_pubkeys_at( + state: &AppState, + target: &RelayTarget, +) -> Vec { + let Ok(Some(relay_self)) = fetch_relay_self_at(state, &target.ws_url).await else { + return vec![]; }; - let events = query_relay( - &state, + let query = query_relay_at( + state, + &target.api_base_url, &[serde_json::json!({ "authors": [relay_self.clone()], "kinds": [13535], "limit": 1, })], ) - .await?; + .await; + let Ok(events) = query else { + return vec![]; + }; let Some(snapshot) = events.into_iter().next() else { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; }; // Defense-in-depth: the filter should already restrict author, but the // client must still reject malformed or wrongly signed relay state. if !snapshot.verify_id() || !snapshot.verify_signature() { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; } if !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; } + archived_pubkeys_from_snapshot(&snapshot) +} + +/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend +/// caches this and tests membership client-side to drive the "Archived" flair. +#[tauri::command] +pub async fn list_archived_identities( + state: State<'_, AppState>, +) -> Result { Ok(ArchivedIdentitiesSnapshot { - archived: archived_pubkeys_from_snapshot(&snapshot), + archived: fetch_archived_pubkeys(&state).await, }) } @@ -336,6 +476,29 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[cfg(not(target_os = "windows"))] + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Counting [`NestRegenTrigger`] double: records how many times the core + /// fires regeneration on the successful-submit path, standing in for the + /// production `AppHandle` binding without a live Tauri runtime. + #[cfg(not(target_os = "windows"))] + #[derive(Default)] + struct CountingRegen(AtomicUsize); + + #[cfg(not(target_os = "windows"))] + impl CountingRegen { + fn count(&self) -> usize { + self.0.load(Ordering::SeqCst) + } + } + + #[cfg(not(target_os = "windows"))] + impl NestRegenTrigger for CountingRegen { + fn trigger(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } /// Build a fake `kind:0` with a valid NIP-OA auth tag for a fresh owner. fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event { @@ -478,4 +641,223 @@ mod tests { assert_eq!(minimal.content, ""); assert!(minimal.reason.is_none()); } + + /// Regression for the cross-relay capture defect: `fetch_archived_pubkeys_at` + /// must resolve BOTH the NIP-11 signer and the `/query` snapshot against the + /// single captured [`RelayTarget`], never re-reading the live workspace + /// override. Two loopback relays advertise distinct signers and archive + /// distinct pubkeys; we capture relay A, then mutate the override to relay B + /// before the fetch. Because capture happens once up front, the override's + /// value at any later instant — including between the two archive awaits — + /// is irrelevant by construction, so setting it to B is the strongest form + /// of that perturbation. A must supply both the signer and the snapshot. + /// + /// RED-on-revert: restore `fetch_archived_pubkeys` to read the override for + /// each leg (`fetch_relay_self` + `query_relay`) and this returns B's pubkey. + #[tokio::test] + async fn archived_fetch_never_crosses_relays_mid_flight() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + use axum::{routing::get, routing::post, Json, Router}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + // Build a loopback relay that advertises `relay_keys` as its NIP-11 + // `self` and serves a relay-signed 13535 snapshot archiving `archived`. + async fn spawn_relay(relay_keys: Keys, archived: String) -> String { + let self_hex = relay_keys.public_key().to_hex(); + let snapshot = EventBuilder::new(Kind::Custom(13535), "") + .tags([ + Tag::parse(["-"]).unwrap(), + Tag::parse(["p", &archived]).unwrap(), + ]) + .sign_with_keys(&relay_keys) + .unwrap(); + let snapshot_json = serde_json::to_value(&snapshot).unwrap(); + + let router = Router::new() + .route( + "/", + get(move || { + let self_hex = self_hex.clone(); + async move { Json(serde_json::json!({ "self": self_hex })) } + }), + ) + .route( + "/query", + post(move || { + let snapshot_json = snapshot_json.clone(); + async move { Json(serde_json::json!([snapshot_json])) } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + format!("ws://{addr}") + } + + let relay_a_keys = Keys::generate(); + let relay_b_keys = Keys::generate(); + // Distinct archived pubkeys, unrelated to either relay's signing key — + // nostr 0.37's EventBuilder silently drops a `p` tag that references the + // event's own signer, so the archived key must not equal the relay key. + let archived_on_a = Keys::generate().public_key().to_hex(); + let archived_on_b = Keys::generate().public_key().to_hex(); + let relay_a = spawn_relay(relay_a_keys, archived_on_a.clone()).await; + let relay_b = spawn_relay(relay_b_keys, archived_on_b.clone()).await; + + let state = build_app_state(); + + // Capture relay A, then swap the override to relay B before the fetch. + *state.relay_url_override.lock().unwrap() = Some(relay_a.clone()); + let target = capture_relay_target(&state); + *state.relay_url_override.lock().unwrap() = Some(relay_b.clone()); + + let archived = fetch_archived_pubkeys_at(&state, &target).await; + + assert_eq!( + archived, + vec![archived_on_a], + "signer and snapshot must both come from the captured relay A, \ + never the mutated override (relay B)" + ); + reset_rate_limit_gate(); + } + + /// Spawn a loopback `/events` relay that answers every submit with the + /// given `accepted` verdict, so the archive/unarchive cores see a real + /// success or rejection over the wire. Returns the `ws://` base. + /// + /// The literal `/events` route below is why this file carries an + /// `EVENTS_INVENTORY` row (one occurrence, zero guard calls): a test + /// loopback, never a production egress site. + #[cfg(not(target_os = "windows"))] + async fn spawn_submit_relay(accepted: bool) -> String { + use axum::{routing::post, Json, Router}; + + let router = Router::new() + .route( + "/events", + post(move || async move { + Json(serde_json::json!({ + "event_id": "e".repeat(64), + "accepted": accepted, + "message": if accepted { "" } else { "rejected by relay" }, + })) + }), + ) + // The cores resolve the owner-of-agent auth tag first, which reads + // the target's live kind:0; answer with an empty result set so that + // read resolves to "no owner tag" without a live upstream relay. + .route("/query", post(|| async { Json(serde_json::json!([])) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + format!("ws://{addr}") + } + + /// Regression for the outsider-reported item 1, archive site: a successful + /// `kind:9035` archive MUST trigger nest regeneration, and a rejected + /// submit MUST NOT. This drives the production [`archive_identity_core`] + /// (the exact seam the command wrapper delegates to), forwarding a counting + /// hook against a loopback relay. RED-on-revert: replace the core's + /// forwarded `on_success` with `|| {}` and the "fires once" assertion fails; + /// this pins the archive command's callback independently of unarchive. + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn archive_core_fires_regen_only_on_accepted_submit() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + let state = build_app_state(); + let req = ArchiveRequest { + target_pubkey: Keys::generate().public_key().to_hex(), + content: String::new(), + reason: None, + replaced_by: None, + }; + + // Accepted archive → hook fires exactly once. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await); + let regen = CountingRegen::default(); + let response = archive_identity_core(&req, &state, ®en) + .await + .expect("accepted archive returns Ok"); + assert!(response.accepted); + assert_eq!( + regen.count(), + 1, + "an accepted archive must trigger regeneration exactly once" + ); + + // Rejected submit → error propagates, hook never fires. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await); + let regen = CountingRegen::default(); + let result = archive_identity_core(&req, &state, ®en).await; + assert!(result.is_err(), "a rejected archive must return an error"); + assert_eq!( + regen.count(), + 0, + "a rejected archive changed nothing, so regeneration must not fire" + ); + + reset_rate_limit_gate(); + } + + /// Regression for item 1, unarchive site: mirrors + /// [`archive_core_fires_regen_only_on_accepted_submit`] against the + /// `kind:9036` [`unarchive_identity_core`]. RED-on-revert: replace that + /// core's forwarded `on_success` with `|| {}` and this fails while the + /// archive test stays green — proving each command's callback is pinned + /// independently, not just the shared `submit_then_regenerate`. + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn unarchive_core_fires_regen_only_on_accepted_submit() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + let state = build_app_state(); + let req = UnarchiveRequest { + target_pubkey: Keys::generate().public_key().to_hex(), + content: String::new(), + reason: None, + }; + + // Accepted unarchive → hook fires exactly once. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await); + let regen = CountingRegen::default(); + let response = unarchive_identity_core(&req, &state, ®en) + .await + .expect("accepted unarchive returns Ok"); + assert!(response.accepted); + assert_eq!( + regen.count(), + 1, + "an accepted unarchive must trigger regeneration exactly once" + ); + + // Rejected submit → error propagates, hook never fires. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await); + let regen = CountingRegen::default(); + let result = unarchive_identity_core(&req, &state, ®en).await; + assert!(result.is_err(), "a rejected unarchive must return an error"); + assert_eq!( + regen.count(), + 0, + "a rejected unarchive changed nothing, so regeneration must not fire" + ); + + reset_rate_limit_gate(); + } } diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 168be1ecf6..31559777d2 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -17,7 +17,10 @@ use crate::{ SendChannelMessageResponse, ThreadRepliesResponse, }, nostr_convert, - relay::{query_relay, submit_event, submit_event_with_keys}, + relay::{ + assert_expected_relay_scope, assert_expected_signer, query_relay, submit_event, + submit_event_at_created_at, submit_event_with_keys_created_at, + }, }; // ── Reads (pure-nostr) ────────────────────────────────────────────────────── @@ -431,54 +434,8 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result Result { - let parent_eid = - EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?; - - let evs = query_relay( - state, - &[serde_json::json!({ - "ids": [parent_event_id], - "kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED], - "limit": 1 - })], - ) - .await?; - - let parent = evs - .first() - .ok_or_else(|| "parent event not found".to_string())?; - - // Walk tags looking for NIP-10 root/reply markers. - let (mut root, mut reply) = (None, None); - for tag in parent.tags.iter() { - let s = tag.as_slice(); - if s.len() >= 4 && s[0] == "e" { - match s[3].as_str() { - "root" => root = Some(s[1].clone()), - "reply" => reply = Some(s[1].clone()), - _ => {} - } - } - } - let root_hex = root.or(reply); - - let root_eid = match root_hex { - Some(hex) if hex != parent_event_id => { - EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))? - } - _ => parent_eid, - }; - - Ok(events::ThreadRef { - root_event_id: root_eid, - parent_event_id: parent_eid, - }) -} +mod thread_ref; +use thread_ref::resolve_thread_ref; #[tauri::command] #[allow(clippy::too_many_arguments)] @@ -493,6 +450,8 @@ pub async fn send_channel_message( sent_from_thread_tag: Option>, mention_pubkeys: Option>, kind: Option, + expected_relay_url: Option, + expected_signer_pubkey: Option, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -503,7 +462,23 @@ pub async fn send_channel_message( let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); let link_previews = link_preview_tags.unwrap_or_default(); + // Resolve the relay AND the signing identity once and use them for every + // read and the submission. Callers that captured a tenant scope before an + // await (Projects agent sends) pass `expected_relay_url` and + // `expected_signer_pubkey`; a mismatch on either means the active + // community changed mid-flight and the send must fail closed rather than + // publish the captured tenant's content to the new tenant's relay — or + // sign it under the new tenant's identity. The relay check alone cannot + // catch the latter: relay and keys mutate under separate locks during a + // workspace switch, so the keys are snapshotted here, asserted, and that + // exact snapshot signs the event and its NIP-98 auth below. let relay_base = crate::relay::relay_api_base_url_with_override(&state); + assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?; + let signing_keys = state.signing_keys()?; + assert_expected_signer( + expected_signer_pubkey.as_deref(), + &signing_keys.public_key().to_hex(), + )?; let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE { return Err("sent-from-thread provenance requires a stream message".into()); @@ -523,7 +498,8 @@ pub async fn send_channel_message( let parent_id = parent_event_id .as_deref() .ok_or("forum comment requires parent_event_id")?; - let thread_ref = resolve_thread_ref(parent_id, &state).await?; + let thread_ref = + resolve_thread_ref(parent_id, &state, &relay_base, Some(&signing_keys)).await?; resolved_root = Some(thread_ref.root_event_id.to_hex()); events::build_forum_comment( channel_uuid, @@ -537,7 +513,8 @@ pub async fn send_channel_message( _ => { let thread_ref = match parent_event_id.as_deref() { Some(pid) => { - let tr = resolve_thread_ref(pid, &state).await?; + let tr = + resolve_thread_ref(pid, &state, &relay_base, Some(&signing_keys)).await?; resolved_root = Some(tr.root_event_id.to_hex()); Some(tr) } @@ -558,7 +535,13 @@ pub async fn send_channel_message( } }; - let result = submit_event(builder, &state).await?; + // `created_at` is the signed event's own second, not a post-publication + // clock read — persisted as an event cursor by the Projects opener. + // Submit through the base resolved (and scope-checked) above and the + // identity snapshotted (and signer-checked) above — a re-resolve or key + // re-read here would reopen the mid-command switch window. + let (result, created_at) = + submit_event_at_created_at(builder, &state, &relay_base, &signing_keys).await?; let depth = match (&parent_event_id, &resolved_root) { (None, _) => 0, @@ -572,7 +555,7 @@ pub async fn send_channel_message( root_event_id: resolved_root, parent_event_id, depth, - created_at: chrono::Utc::now().timestamp(), + created_at, }) } @@ -775,7 +758,18 @@ pub async fn send_managed_agent_channel_message( let submission_auth_tag = managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?; let thread_ref = match parent_event_id.as_deref() { - Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?), + Some(parent_id) => Some( + // Same active-relay resolution as before — this path has no + // caller-captured tenant scope (yet), so resolve the override + // here and read through it with the active identity. + resolve_thread_ref( + parent_id, + &state, + &crate::relay::relay_api_base_url_with_override(&state), + None, + ) + .await?, + ), None => None, }; @@ -820,15 +814,18 @@ pub async fn send_managed_agent_channel_message( &mentions, &client_tags, )?; - let result = - submit_event_with_keys(builder, &state, &keys, submission_auth_tag.as_deref()).await?; + // Same contract as `send_channel_message`: `created_at` is the signed + // event's, not a post-publication clock read. + let (result, created_at) = + submit_event_with_keys_created_at(builder, &state, &keys, submission_auth_tag.as_deref()) + .await?; Ok(SendChannelMessageResponse { event_id: result.event_id, parent_event_id: parent_event_id.clone(), root_event_id: thread_ref.map(|reference| reference.root_event_id.to_hex()), depth: if parent_event_id.is_some() { 1 } else { 0 }, - created_at: chrono::Utc::now().timestamp(), + created_at, }) } diff --git a/desktop/src-tauri/src/commands/messages/thread_ref.rs b/desktop/src-tauri/src/commands/messages/thread_ref.rs new file mode 100644 index 0000000000..97a03fdad5 --- /dev/null +++ b/desktop/src-tauri/src/commands/messages/thread_ref.rs @@ -0,0 +1,65 @@ +use nostr::EventId; + +use crate::{ + app_state::AppState, + events, + relay::{query_relay_at, query_relay_at_with_keys}, +}; + +/// Fetch a parent event and extract the thread root from its NIP-10 e-tags. +/// +/// Reads through the explicit `api_base_url` the calling command resolved — +/// never re-resolving the workspace override — so a mid-command community +/// switch cannot split one logical send across two relays. Callers that +/// pinned a signer snapshot pass it as `keys` so this read's NIP-98 auth is +/// minted by the same identity that signs the eventual event; `None` +/// preserves the active-identity read for unpinned callers. +pub(super) async fn resolve_thread_ref( + parent_event_id: &str, + state: &AppState, + api_base_url: &str, + keys: Option<&nostr::Keys>, +) -> Result { + let parent_eid = + EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?; + + let filters = [serde_json::json!({ + "ids": [parent_event_id], + "kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED], + "limit": 1 + })]; + let evs = match keys { + Some(keys) => query_relay_at_with_keys(state, api_base_url, &filters, keys, None).await?, + None => query_relay_at(state, api_base_url, &filters).await?, + }; + + let parent = evs + .first() + .ok_or_else(|| "parent event not found".to_string())?; + + // Walk tags looking for NIP-10 root/reply markers. + let (mut root, mut reply) = (None, None); + for tag in parent.tags.iter() { + let s = tag.as_slice(); + if s.len() >= 4 && s[0] == "e" { + match s[3].as_str() { + "root" => root = Some(s[1].clone()), + "reply" => reply = Some(s[1].clone()), + _ => {} + } + } + } + let root_hex = root.or(reply); + + let root_eid = match root_hex { + Some(hex) if hex != parent_event_id => { + EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))? + } + _ => parent_eid, + }; + + Ok(events::ThreadRef { + root_event_id: root_eid, + parent_event_id: parent_eid, + }) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 761bee9cd3..85e9df0b10 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -50,9 +50,11 @@ mod project_git; mod project_git_branches; mod project_git_diff; mod project_git_exec; +mod project_git_file_content; mod project_git_merge_error; mod project_git_push; mod project_git_recipient_notes; +mod project_git_types; mod project_git_workflow; mod project_repo_paths; mod project_terminal; @@ -107,6 +109,7 @@ pub use profile::*; pub use project_git::*; pub use project_git_branches::*; pub use project_git_diff::*; +pub use project_git_file_content::*; pub use project_git_recipient_notes::*; pub use project_git_workflow::*; pub use project_terminal::*; 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 f4590f5d6e..a4bbdeb677 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -67,6 +67,7 @@ fn make_agent( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index c322e6cb6e..5214dd5a27 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -124,6 +124,8 @@ pub async fn reconcile_inbound_persona_event( &config, agent_json, cached_binary_path.as_deref(), + None, + None, ) .await .map_err(|error| { 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 c052622215..fbfede3588 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -216,6 +216,7 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } 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 6d7a2e6264..341426fe94 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -65,6 +65,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 7d3fd95ff3..75a1edea65 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -653,6 +653,7 @@ pub async fn confirm_agent_snapshot_import( definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + effort_level: None, runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 5e8cea52e6..fedb0e6058 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -74,6 +74,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } 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 72bdca7de9..556127373b 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 @@ -59,6 +59,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 201f3a0507..5b8b2adf8e 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -2,92 +2,25 @@ use super::project_git_exec::{ build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url, GitAuthConfig, }; +use super::project_git_file_content::{checkout_project_repo, read_preview_content}; use super::project_git_push::push_project_local_repository_blocking; +pub use super::project_git_types::{ + GitIdentityInfo, ProjectLocalRepoInfo, ProjectLocalRepoSnapshotInfo, ProjectRepoCommitInfo, + ProjectRepoContributorInfo, ProjectRepoFileInfo, ProjectRepoPullResult, ProjectRepoPushResult, + ProjectRepoSnapshotInfo, ProjectRepoSyncStatusInfo, +}; use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir}; use crate::app_state::AppState; -use serde::Serialize; use std::time::UNIX_EPOCH; -use tauri::State; -#[derive(Clone, Serialize)] -pub struct ProjectRepoCommitInfo { - pub hash: String, - pub short_hash: String, - pub author_name: String, - pub author_email: String, - pub timestamp: i64, - pub subject: String, -} -#[derive(Serialize)] -pub struct ProjectRepoFileInfo { - pub path: String, - pub kind: String, - pub size: Option, - pub preview_content: Option, - pub last_changed_at: Option, - pub latest_commit: Option, -} -#[derive(Serialize)] -pub struct ProjectRepoContributorInfo { - pub name: String, - pub email: String, - pub commit_count: usize, - pub last_commit_at: i64, -} -#[derive(Serialize)] -pub struct ProjectRepoSnapshotInfo { - pub latest_commit: Option, - pub commits: Vec, - pub files: Vec, - pub contributors: Vec, -} -#[derive(Serialize)] -pub struct ProjectLocalRepoSnapshotInfo { - pub path: String, - pub snapshot: ProjectRepoSnapshotInfo, -} -#[derive(Serialize)] -pub struct ProjectLocalRepoInfo { - pub name: String, - pub path: String, -} -#[derive(Serialize)] -pub struct ProjectRepoSyncStatusInfo { - pub local_path: Option, - pub local_branch: Option, - pub local_branches: Vec, - pub local_head: Option, - pub local_short_head: Option, - pub remote_branch: Option, - pub remote_head: Option, - pub remote_short_head: Option, - pub merge_base: Option, - pub ahead_count: usize, - pub behind_count: usize, - pub has_uncommitted_changes: bool, - pub has_untracked_files: bool, - pub can_push: bool, - pub push_block_reason: Option, - pub can_pull: bool, - pub pull_block_reason: Option, -} -#[derive(Serialize)] -pub struct ProjectRepoPushResult { - pub pushed: bool, - pub message: String, - pub branch: String, - pub commit: String, - pub merge_base: Option, -} -#[derive(Serialize)] -pub struct ProjectRepoPullResult { - pub pulled: bool, - pub message: String, -} -#[derive(Serialize)] -pub struct GitIdentityInfo { - pub name: Option, - pub email: Option, -} +use tauri::{AppHandle, State}; +use tauri_plugin_opener::OpenerExt; + +// Bound eager content without truncating the repository tree. +const MAX_EAGER_FILE_PREVIEWS: usize = 250; + +#[cfg(test)] +#[path = "project_git_tests.rs"] +mod tests; fn parse_latest_commit(output: &str) -> Option { let line = output.lines().next()?; let mut parts = line.split('\0'); @@ -134,30 +67,6 @@ fn has_untracked_files(output: &str) -> bool { output.lines().any(|line| line.starts_with("??")) } -fn read_preview_content( - repo_dir: &std::path::Path, - path: &str, - size: Option, -) -> Option { - const MAX_PREVIEW_BYTES: u64 = 64 * 1024; - if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { - return None; - } - - let full_path = repo_dir.join(path); - let normalized = full_path.canonicalize().ok()?; - let repo_root = repo_dir.canonicalize().ok()?; - if !normalized.starts_with(repo_root) { - return None; - } - - let bytes = std::fs::read(normalized).ok()?; - if bytes.contains(&0) { - return None; - } - String::from_utf8(bytes).ok() -} - fn parse_commits(output: &str) -> Vec { output .lines() @@ -254,24 +163,26 @@ fn parse_worktree_files( .filter_map(|path| { let full_path = repo_dir.join(path); let metadata = std::fs::metadata(&full_path).ok()?; - if !metadata.is_file() { - return None; - } + metadata.is_file().then_some((path, full_path, metadata)) + }) + .enumerate() + .map(|(index, (path, full_path, metadata))| { let size = Some(metadata.len()); let latest_commit = latest_commit_by_path.get(path).cloned(); - Some(ProjectRepoFileInfo { + ProjectRepoFileInfo { path: path.to_string(), kind: "blob".to_string(), size, - preview_content: read_preview_content(repo_dir, path, size), + preview_content: (index < MAX_EAGER_FILE_PREVIEWS) + .then(|| read_preview_content(repo_dir, path, size)) + .flatten(), last_changed_at: latest_commit .as_ref() .map(|commit| commit.timestamp) .or_else(|| path_modified_at(&full_path)), latest_commit, - }) + } }) - .take(250) .collect() } @@ -314,6 +225,7 @@ fn parse_ls_tree( output: &str, latest_commit_by_path: &std::collections::HashMap, ) -> Vec { + let mut blob_index = 0; output .lines() .filter_map(|line| { @@ -323,11 +235,12 @@ fn parse_ls_tree( let kind = parts.next()?.to_string(); let _object = parts.next()?; let size = parts.next().and_then(|value| value.parse::().ok()); - let preview_content = if kind == "blob" { - read_preview_content(repo_dir, path, size) - } else { - None - }; + if kind == "blob" { + blob_index += 1; + } + let preview_content = (kind == "blob" && blob_index <= MAX_EAGER_FILE_PREVIEWS) + .then(|| read_preview_content(repo_dir, path, size)) + .flatten(); Some(ProjectRepoFileInfo { path: path.to_string(), kind, @@ -339,7 +252,6 @@ fn parse_ls_tree( latest_commit: latest_commit_by_path.get(path).cloned(), }) }) - .take(250) .collect() } @@ -727,61 +639,14 @@ pub async fn get_project_repo_snapshot( tauri::async_runtime::spawn_blocking(move || { let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; let repo_dir = temp_dir.path().join("repo"); - let repo_path = repo_dir - .to_str() - .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?; - - let explicit_target = target_ref.as_deref().or(target_commit.as_deref()); - if let Some(fetch_ref) = explicit_target { - run_git( - &[ - "clone", - "--filter=blob:none", - "--no-checkout", - clone_url.as_str(), - repo_path, - ], - None, - &auth, - )?; - run_git( - &["fetch", "--depth=100", "origin", fetch_ref], - Some(&repo_dir), - &auth, - )?; - if let Some(expected_commit) = target_commit.as_deref() { - let fetched_commit = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth) - .ok() - .and_then(|output| first_output_line(&output)) - .map(|commit| commit.to_ascii_lowercase()) - .ok_or_else(|| "Could not resolve the requested repository ref.".to_string())?; - if fetched_commit != expected_commit { - return Err( - "The requested repository ref changed. Refresh and try again.".to_string(), - ); - } - } - run_git( - &["checkout", "--detach", "FETCH_HEAD"], - Some(&repo_dir), - &auth, - )?; - } else { - let mut clone_args = vec!["clone", "--filter=blob:none"]; - if let Some(ref branch) = branch { - clone_args.push("--branch"); - clone_args.push(branch.as_str()); - } - clone_args.push(clone_url.as_str()); - clone_args.push(repo_path); - if run_git(&clone_args, None, &auth).is_err() && branch.is_some() { - run_git( - &["clone", "--filter=blob:none", clone_url.as_str(), repo_path], - None, - &auth, - )?; - } - } + checkout_project_repo( + &repo_dir, + &clone_url, + branch.as_deref(), + target_ref.as_deref(), + target_commit.as_deref(), + &auth, + )?; let snapshot = snapshot_from_repo(&repo_dir, &auth, branch.as_deref(), base_branch.as_deref()); @@ -861,6 +726,26 @@ pub async fn list_project_local_repositories( .map_err(|error| format!("local repo list task failed: {error}"))? } +#[tauri::command] +pub async fn open_project_repository_folder( + repos_dir: Option, + project_dtag: String, + clone_url: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_workspace_clone_url(&clone_url, &state)?; + let repo_dir = tauri::async_runtime::spawn_blocking(move || { + find_local_repo_dir(repos_dir.as_deref(), &project_dtag, Some(&clone_url))? + .ok_or_else(|| "No local checkout found.".to_string()) + }) + .await + .map_err(|error| format!("local repo lookup task failed: {error}"))??; + app.opener() + .open_path(repo_dir.to_string_lossy(), None::<&str>) + .map_err(|error| format!("open local repository folder: {error}")) +} + #[tauri::command] pub async fn get_project_repo_sync_status( repos_dir: Option, diff --git a/desktop/src-tauri/src/commands/project_git_file_content.rs b/desktop/src-tauri/src/commands/project_git_file_content.rs new file mode 100644 index 0000000000..1ada9f664f --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_file_content.rs @@ -0,0 +1,178 @@ +use super::project_git::first_output_line; +use super::project_git_exec::{ + build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url, + GitAuthConfig, +}; +use super::project_repo_paths::find_local_repo_dir; +use crate::app_state::AppState; +use tauri::State; + +const MAX_PREVIEW_BYTES: u64 = 64 * 1024; + +pub(crate) fn read_preview_content( + repo_dir: &std::path::Path, + path: &str, + size: Option, +) -> Option { + if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { + return None; + } + + let full_path = repo_dir.join(path); + if std::fs::symlink_metadata(&full_path) + .ok()? + .file_type() + .is_symlink() + { + return None; + } + let normalized = full_path.canonicalize().ok()?; + let repo_root = repo_dir.canonicalize().ok()?; + if !normalized.starts_with(repo_root) { + return None; + } + + let metadata = std::fs::metadata(&normalized).ok()?; + if !metadata.is_file() || metadata.len() > MAX_PREVIEW_BYTES { + return None; + } + let bytes = std::fs::read(normalized).ok()?; + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok() +} + +pub(crate) fn validate_repo_file_path(path: &str) -> Result<(), String> { + if path.is_empty() + || std::path::Path::new(path) + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err("Repository file path must be a relative file path.".to_string()); + } + Ok(()) +} + +pub(crate) fn checkout_project_repo( + repo_dir: &std::path::Path, + clone_url: &str, + branch: Option<&str>, + target_ref: Option<&str>, + target_commit: Option<&str>, + auth: &GitAuthConfig, +) -> Result<(), String> { + let repo_path = repo_dir + .to_str() + .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?; + let explicit_target = target_ref.or(target_commit); + + if let Some(fetch_ref) = explicit_target { + run_git( + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + clone_url, + repo_path, + ], + None, + auth, + )?; + run_git( + &["fetch", "--depth=100", "origin", fetch_ref], + Some(repo_dir), + auth, + )?; + if let Some(expected_commit) = target_commit { + let fetched_commit = run_git(&["rev-parse", "FETCH_HEAD"], Some(repo_dir), auth) + .ok() + .and_then(|output| first_output_line(&output)) + .map(|commit| commit.to_ascii_lowercase()) + .ok_or_else(|| "Could not resolve the requested repository ref.".to_string())?; + if fetched_commit != expected_commit { + return Err( + "The requested repository ref changed. Refresh and try again.".to_string(), + ); + } + } + run_git( + &["checkout", "--detach", "FETCH_HEAD"], + Some(repo_dir), + auth, + )?; + return Ok(()); + } + + let mut clone_args = vec!["clone", "--filter=blob:none"]; + if let Some(branch) = branch { + clone_args.push("--branch"); + clone_args.push(branch); + } + clone_args.push(clone_url); + clone_args.push(repo_path); + if run_git(&clone_args, None, auth).is_err() && branch.is_some() { + run_git( + &["clone", "--filter=blob:none", clone_url, repo_path], + None, + auth, + )?; + } + Ok(()) +} + +#[tauri::command] +pub async fn get_project_repo_file_content( + clone_url: String, + default_branch: Option, + target_ref: Option, + target_commit: Option, + path: String, + state: State<'_, AppState>, +) -> Result, String> { + validate_workspace_clone_url(&clone_url, &state)?; + validate_repo_file_path(&path)?; + let auth = build_git_auth_config(&state)?; + let branch = clean_branch(default_branch); + let target_ref = clean_target_ref(target_ref); + let target_commit = target_commit + .map(|value| value.to_ascii_lowercase()) + .filter(|value| matches!(value.len(), 40 | 64)) + .filter(|value| value.chars().all(|c| c.is_ascii_hexdigit())); + + tauri::async_runtime::spawn_blocking(move || { + let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; + let repo_dir = temp_dir.path().join("repo"); + checkout_project_repo( + &repo_dir, + &clone_url, + branch.as_deref(), + target_ref.as_deref(), + target_commit.as_deref(), + &auth, + )?; + Ok(read_preview_content(&repo_dir, &path, None)) + }) + .await + .map_err(|error| format!("repo file content task failed: {error}"))? +} + +#[tauri::command] +pub async fn get_project_local_repo_file_content( + repos_dir: Option, + project_dtag: String, + clone_url: Option, + path: String, +) -> Result, String> { + validate_repo_file_path(&path)?; + tauri::async_runtime::spawn_blocking(move || { + let Some(repo_dir) = + find_local_repo_dir(repos_dir.as_deref(), &project_dtag, clone_url.as_deref())? + else { + return Ok(None); + }; + Ok(read_preview_content(&repo_dir, &path, None)) + }) + .await + .map_err(|error| format!("local repo file content task failed: {error}"))? +} diff --git a/desktop/src-tauri/src/commands/project_git_tests.rs b/desktop/src-tauri/src/commands/project_git_tests.rs new file mode 100644 index 0000000000..99e31d7774 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_tests.rs @@ -0,0 +1,111 @@ +use super::super::project_git_file_content::validate_repo_file_path; +use super::*; + +#[test] +fn parse_ls_tree_keeps_paths_after_eager_preview_limit() { + let repo_dir = tempfile::tempdir().expect("create temporary repository"); + std::fs::create_dir(repo_dir.path().join("src")).expect("create source directory"); + std::fs::write(repo_dir.path().join("README.md"), "# Deferred README") + .expect("write deferred README"); + std::fs::write( + repo_dir.path().join("src/application.rs"), + "fn deferred() {}", + ) + .expect("write deferred source file"); + let hidden_entries = (0..MAX_EAGER_FILE_PREVIEWS) + .map(|index| { + format!( + "100644 blob {} 1\t.agents/generated-{index:03}.txt", + "a".repeat(40) + ) + }) + .collect::>() + .join("\n"); + let output = format!( + "{hidden_entries}\n100644 blob {} 17\tREADME.md\n100644 blob {} 16\tsrc/application.rs", + "b".repeat(40), + "c".repeat(40) + ); + + let files = parse_ls_tree(repo_dir.path(), &output, &std::collections::HashMap::new()); + + assert_eq!(files.len(), MAX_EAGER_FILE_PREVIEWS + 2); + let readme = files + .iter() + .find(|file| file.path == "README.md") + .expect("README metadata remains visible"); + assert_eq!(readme.preview_content, None); + assert_eq!( + read_preview_content(repo_dir.path(), &readme.path, readme.size).as_deref(), + Some("# Deferred README") + ); + assert_eq!( + files.last().map(|file| file.path.as_str()), + Some("src/application.rs") + ); + let source = files.last().expect("source metadata remains visible"); + assert_eq!(source.preview_content, None); + assert_eq!( + read_preview_content(repo_dir.path(), &source.path, source.size).as_deref(), + Some("fn deferred() {}") + ); +} + +#[test] +fn repo_file_paths_reject_traversal_and_absolute_paths() { + assert!(validate_repo_file_path("src/application.rs").is_ok()); + assert!(validate_repo_file_path("../outside.txt").is_err()); + assert!(validate_repo_file_path("src/../outside.txt").is_err()); + assert!(validate_repo_file_path("/absolute.txt").is_err()); +} + +#[test] +fn parse_ls_tree_counts_only_blobs_toward_eager_preview_limit() { + let repo_dir = tempfile::tempdir().expect("create temporary repository"); + std::fs::write(repo_dir.path().join("application.rs"), "fn main() {}") + .expect("write preview file"); + let non_blob_entries = (0..MAX_EAGER_FILE_PREVIEWS) + .map(|index| { + format!( + "160000 commit {} -\tvendor/dependency-{index:03}", + "a".repeat(40) + ) + }) + .collect::>() + .join("\n"); + let output = format!( + "{non_blob_entries}\n100644 blob {} 12\tapplication.rs", + "b".repeat(40) + ); + + let files = parse_ls_tree(repo_dir.path(), &output, &std::collections::HashMap::new()); + + assert_eq!( + files + .last() + .and_then(|file| file.preview_content.as_deref()), + Some("fn main() {}") + ); +} + +#[test] +fn parse_worktree_files_counts_only_files_toward_eager_preview_limit() { + let repo_dir = tempfile::tempdir().expect("create temporary repository"); + std::fs::create_dir(repo_dir.path().join("directory")).expect("create directory"); + let paths = (0..MAX_EAGER_FILE_PREVIEWS) + .map(|index| { + let path = format!("file-{index:03}.txt"); + std::fs::write(repo_dir.path().join(&path), "preview").expect("write preview file"); + path + }) + .collect::>(); + let output = std::iter::once("directory") + .chain(paths.iter().map(String::as_str)) + .collect::>() + .join("\0"); + + let files = parse_worktree_files(repo_dir.path(), &output, &std::collections::HashMap::new()); + + assert_eq!(files.len(), MAX_EAGER_FILE_PREVIEWS); + assert!(files.iter().all(|file| file.preview_content.is_some())); +} diff --git a/desktop/src-tauri/src/commands/project_git_types.rs b/desktop/src-tauri/src/commands/project_git_types.rs new file mode 100644 index 0000000000..ce04c73f00 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_types.rs @@ -0,0 +1,91 @@ +use serde::Serialize; + +#[derive(Clone, Serialize)] +pub struct ProjectRepoCommitInfo { + pub hash: String, + pub short_hash: String, + pub author_name: String, + pub author_email: String, + pub timestamp: i64, + pub subject: String, +} + +#[derive(Serialize)] +pub struct ProjectRepoFileInfo { + pub path: String, + pub kind: String, + pub size: Option, + pub preview_content: Option, + pub last_changed_at: Option, + pub latest_commit: Option, +} + +#[derive(Serialize)] +pub struct ProjectRepoContributorInfo { + pub name: String, + pub email: String, + pub commit_count: usize, + pub last_commit_at: i64, +} + +#[derive(Serialize)] +pub struct ProjectRepoSnapshotInfo { + pub latest_commit: Option, + pub commits: Vec, + pub files: Vec, + pub contributors: Vec, +} + +#[derive(Serialize)] +pub struct ProjectLocalRepoSnapshotInfo { + pub path: String, + pub snapshot: ProjectRepoSnapshotInfo, +} + +#[derive(Serialize)] +pub struct ProjectLocalRepoInfo { + pub name: String, + pub path: String, +} + +#[derive(Serialize)] +pub struct ProjectRepoSyncStatusInfo { + pub local_path: Option, + pub local_branch: Option, + pub local_branches: Vec, + pub local_head: Option, + pub local_short_head: Option, + pub remote_branch: Option, + pub remote_head: Option, + pub remote_short_head: Option, + pub merge_base: Option, + pub ahead_count: usize, + pub behind_count: usize, + pub has_uncommitted_changes: bool, + pub has_untracked_files: bool, + pub can_push: bool, + pub push_block_reason: Option, + pub can_pull: bool, + pub pull_block_reason: Option, +} + +#[derive(Serialize)] +pub struct ProjectRepoPushResult { + pub pushed: bool, + pub message: String, + pub branch: String, + pub commit: String, + pub merge_base: Option, +} + +#[derive(Serialize)] +pub struct ProjectRepoPullResult { + pub pulled: bool, + pub message: String, +} + +#[derive(Serialize)] +pub struct GitIdentityInfo { + pub name: Option, + pub email: Option, +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 8315f39a36..e4c08a14be 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -610,6 +610,7 @@ pub async fn confirm_team_snapshot_import( definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + effort_level: None, runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index a466228160..bec7f43bf8 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -230,6 +230,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, runtime: None, name_pool: vec![], }; diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 89e6e29cec..77d519b94b 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -10,6 +10,32 @@ use crate::managed_agents::{ }; use crate::relay; +const WORKSPACE_APPLY_SUPERSEDED: &str = "workspace apply superseded by a newer request"; + +fn next_apply_generation(generation: &std::sync::atomic::AtomicU64) -> u64 { + generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1) +} + +fn assert_current_apply_generation( + generation: &std::sync::atomic::AtomicU64, + ticket: u64, +) -> Result<(), String> { + if generation.load(Ordering::Acquire) == ticket { + Ok(()) + } else { + Err(WORKSPACE_APPLY_SUPERSEDED.to_string()) + } +} + +async fn begin_workspace_apply( + lock: std::sync::Arc>, + generation: &std::sync::atomic::AtomicU64, +) -> (tokio::sync::OwnedMutexGuard<()>, u64) { + let guard = lock.lock_owned().await; + let ticket = next_apply_generation(generation); + (guard, ticket) +} + /// Adopt the pre-scoping global retention database's pending rows into `scope`. /// /// Best-effort: a failure is logged and the boot proceeds. The migration's own @@ -131,11 +157,24 @@ pub async fn apply_workspace( agent_managed_profiles: Option, app: AppHandle, ) -> Result<(), String> { + let state = app.state::(); + // Take the generation only after entering the serialized transaction. An + // apply that is already running remains authoritative until it releases + // the lock; the next apply then advances the generation. This keeps every + // awaited reconciliation/event-sync phase inside one ordered transaction. + let (apply_guard, apply_generation) = begin_workspace_apply( + state.workspace_apply_lock.clone(), + &state.workspace_apply_generation, + ) + .await; + let restore_app = app.clone(); + let apply_app = app.clone(); // Capture the caller's relay before the blocking apply. Reading shared // state afterward could pick up a newer concurrent community switch. let profile_reconcile_relay = relay_url.clone(); tokio::task::spawn_blocking(move || { + let app = apply_app; let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── @@ -166,6 +205,11 @@ pub async fn apply_workspace( None => None, }; + // Defense in depth: this transaction still owns the serialized apply + // generation before making its first mutation. Normal queued applies + // cannot advance it until this transaction releases the guard. + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + // ── Apply all state changes (nothing below can fail) ────────────────── { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; @@ -214,6 +258,8 @@ pub async fn apply_workspace( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + let state = restore_app.state::(); super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; // The Bumble→Pollen migration may have renamed stopped agents. Reconcile @@ -273,17 +319,15 @@ pub async fn apply_workspace( .managed_agent_restore_pending .swap(false, Ordering::AcqRel); - // The coordinator starts before React applies the selected workspace, so - // its startup publication may have used the fallback relay and placeholder - // identity. Correct it off the command path so an unavailable relay cannot - // hold the frontend on its loading gate. On initial launch, restore MeshLLM - // first so a slow stopped-status request cannot overwrite a newly restored - // serving status, then restore managed agents after the admission identity - // has been published (or the bounded publication attempt has timed out). + // Transfer the apply guard to launch restoration. The command can return + // promptly, but a queued workspace cannot mutate relay/identity until the + // restore has completed every mutable workspace read and side effect. #[cfg(feature = "mesh-llm")] { + let restore_lock = apply_guard; let app = restore_app.clone(); tauri::async_runtime::spawn(async move { + let _restore_lock = restore_lock; let state = app.state::(); if restore_pending { if let Err(error) = @@ -301,12 +345,15 @@ pub async fn apply_workspace( } } }); + return Ok(()); } #[cfg(not(feature = "mesh-llm"))] if restore_pending { + let restore_lock = apply_guard; let app = restore_app.clone(); tauri::async_runtime::spawn(async move { + let _restore_lock = restore_lock; let state = app.state::(); if let Err(error) = restore_managed_agents_on_launch(&app, &state.shutdown_started).await @@ -314,7 +361,59 @@ pub async fn apply_workspace( eprintln!("buzz-desktop: failed to restore managed agents: {error}"); } }); + return Ok(()); } + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }; + + use super::{assert_current_apply_generation, begin_workspace_apply, next_apply_generation}; + + #[test] + fn explicit_newer_generation_supersedes_older_ticket() { + let generation = AtomicU64::new(0); + let older = next_apply_generation(&generation); + let newer = next_apply_generation(&generation); + + let error = assert_current_apply_generation(&generation, older).unwrap_err(); + assert!(error.contains("superseded"), "{error}"); + assert_current_apply_generation(&generation, newer).unwrap(); + } + + #[tokio::test] + async fn queued_apply_cannot_supersede_running_transaction_or_restore_phase() { + let lock = Arc::new(tokio::sync::Mutex::new(())); + let generation = Arc::new(AtomicU64::new(0)); + let (running_guard, running_ticket) = + begin_workspace_apply(Arc::clone(&lock), &generation).await; + + let queued_lock = Arc::clone(&lock); + let queued_generation = Arc::clone(&generation); + let queued = tokio::spawn(async move { + let (_guard, ticket) = begin_workspace_apply(queued_lock, &queued_generation).await; + ticket + }); + tokio::task::yield_now().await; + + // A queued workspace has not advanced the generation, so every awaited + // phase of the running transaction, including one-shot launch restore, + // remains authoritative while it holds the lock. + assert_eq!(generation.load(Ordering::Acquire), running_ticket); + assert_current_apply_generation(&generation, running_ticket).unwrap(); + assert!(!queued.is_finished()); + + drop(running_guard); + let queued_ticket = queued.await.unwrap(); + assert!(queued_ticket > running_ticket); + assert_current_apply_generation(&generation, queued_ticket).unwrap(); + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 9b4a01b1c7..83ac7e59ff 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -372,6 +372,10 @@ fn is_hex64(value: &str) -> bool { value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) } +fn is_git_object_id(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit()) +} + /// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a /// narrower d-tag charset than Nostr allows. fn is_linkable_dtag(value: &str) -> bool { @@ -416,13 +420,14 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> { let needs_event_id = host == "pr" || host == "issue"; let allows_tab = host == "repo" || host == "project"; - let (mut owner, mut dtag, mut id, mut tab) = (None, None, None, None); + let (mut owner, mut dtag, mut id, mut tab, mut commit) = (None, None, None, None, None); for (key, value) in url.query_pairs() { let slot = match key.as_ref() { "owner" => &mut owner, "d" => &mut dtag, "id" if needs_event_id => &mut id, "tab" if allows_tab => &mut tab, + "commit" if host == "repo" => &mut commit, _ => return None, }; if slot.is_some() { @@ -440,8 +445,13 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> { if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) { return None; } - if let Some(tab) = tab { - if !ENTITY_LINK_TABS.contains(&tab.as_str()) { + if let Some(tab) = tab.as_deref() { + if !ENTITY_LINK_TABS.contains(&tab) { + return None; + } + } + if let Some(commit) = commit { + if tab.as_deref() != Some("commits") || !is_git_object_id(&commit) { return None; } } diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs index eaddbb7a4c..84a08c4c64 100644 --- a/desktop/src-tauri/src/deep_link_tests.rs +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -34,6 +34,11 @@ fn parse_entity_deep_link_accepts_every_share_link_shape() { "{raw}" ); } + let commit_link = format!( + "buzz://repo?owner={owner}&d={dtag}&tab=commits&commit={}", + golden["eventId"].as_str().unwrap() + ); + assert!(parse_entity_deep_link(&Url::parse(&commit_link).unwrap()).is_some()); let expected_tabs = golden["tabs"] .as_array() .unwrap() @@ -64,6 +69,8 @@ fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() { // Unknown tab value, duplicate tab, and tab on an event link. format!("buzz://repo?owner={owner}&d=buzz-world&tab=overview"), format!("buzz://repo?owner={owner}&d=buzz-world&tab=prs&tab=prs"), + format!("buzz://repo?owner={owner}&d=buzz-world&tab=files&commit={event_id}"), + format!("buzz://repo?owner={owner}&d=buzz-world&tab=commits&commit=short"), format!("buzz://pr?id={event_id}&owner={owner}&d=buzz-world&tab=prs"), format!("buzz://repo/extra?owner={owner}&d=buzz-world"), format!("buzz://repo?owner={owner}&d=buzz-world#top"), diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 1513742bea..0c2a9573af 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -276,6 +276,10 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Mock-relay route in its in-file tests; production publish goes through // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). ("src/commands/personas/sharing.rs", 1, 0), + // Loopback submit relay in `identity_archive.rs`'s in-file regen tests; + // production archive/unarchive publish through the guarded boundary-1 + // funnel via `submit_event`. + ("src/commands/identity_archive.rs", 1, 0), ]; // Needles are assembled at runtime so this scan file itself contains no diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 19a28b150b..8185944834 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -19,6 +19,7 @@ //! sherpa-onnx is CPU-bound and not Send-safe across await points. use std::{ + collections::VecDeque, path::PathBuf, sync::{ atomic::{AtomicBool, Ordering}, @@ -158,20 +159,42 @@ impl Drop for SttPipeline { // ── Worker thread ───────────────────────────────────────────────────────────── /// How many 16 kHz samples of silence before we flush to STT. -/// 300 ms × 16 000 Hz / 256 samples-per-frame ≈ 19 frames. -/// Previous value (28 frames / 450 ms) felt sluggish in conversation. +/// 500 ms × 16 000 Hz / 256 samples-per-frame ≈ 31 frames. +/// This favors natural conversational pauses over the lower latency of the +/// previous 19-frame / 304 ms window. /// /// This window is a turn-taking quality knob, not a latency lever: an earlier /// env override (`BUZZ_STT_FLUSH_MS`) let it be lowered to 150 ms, which split /// natural mid-sentence pauses into separate messages and confused the /// listening agents. Reverted — the window is fixed at the production value. -const SILENCE_FLUSH_FRAMES: usize = 19; +const SILENCE_FLUSH_FRAMES: usize = 31; /// earshot requires exactly 256 samples per frame at 16 kHz. const VAD_FRAME_SAMPLES: usize = 256; -/// VAD probability threshold — above this is considered speech. -const VAD_THRESHOLD: f32 = 0.5; +/// Earshot 1.1.0 onset operating point. Any Earshot model/version change +/// invalidates this and `VAD_OFFSET_THRESHOLD`; re-run the matched-corpus +/// threshold harness before updating either constant. +const VAD_ONSET_THRESHOLD: f32 = 0.55; + +/// Earshot 1.1.0 offset operating point. The lower threshold keeps borderline +/// speech inside the active utterance without changing the onset sensitivity. +const VAD_OFFSET_THRESHOLD: f32 = 0.35; + +/// Consecutive onset frames required before an utterance begins. +const VAD_ONSET_FRAMES: usize = 3; + +/// Audio retained before confirmed onset so initial phonemes are not clipped. +/// A rolling pre-roll that survived a hard boundary would leak segment N into +/// segment N+1 when the next confirmed onset occurs within +/// `VAD_PRE_ROLL_FRAMES - VAD_ONSET_FRAMES` frames (13 frames, or 208 ms, at +/// the shipped values) of the previous flush. Hangover and the silence flush +/// window do not enter this bound; `reset_segment` keeps them independent by +/// clearing pre-roll. +const VAD_PRE_ROLL_FRAMES: usize = 16; + +/// Trailing silence retained in the transcript buffer (about 100 ms). +const VAD_HANGOVER_FRAMES: usize = 6; /// Minimum voiced audio needed before an utterance may be decoded. /// One earshot false-positive frame is only 16 ms; requiring 192 ms prevents @@ -179,6 +202,112 @@ const VAD_THRESHOLD: f32 = 0.5; /// transcript text while still preserving short replies such as "yes". const MIN_VOICED_FRAMES: usize = 12; +#[derive(Debug, PartialEq, Eq)] +enum VadFrameAction { + None, + Speech, + FirstSilence, + Flush, +} + +struct VadEndpoint { + pre_roll: VecDeque>, + speech_buf: Vec, + onset_frames: usize, + silence_frames: usize, + voiced_frames: usize, + in_speech: bool, +} + +impl VadEndpoint { + fn new() -> Self { + Self { + pre_roll: VecDeque::with_capacity(VAD_PRE_ROLL_FRAMES), + speech_buf: Vec::new(), + onset_frames: 0, + silence_frames: 0, + voiced_frames: 0, + in_speech: false, + } + } + + fn process_frame( + &mut self, + frame: Vec, + probability: f32, + accepts_audio: bool, + flush_allowed: bool, + flush_frames: usize, + ) -> VadFrameAction { + if !accepts_audio { + self.pre_roll.clear(); + self.onset_frames = 0; + return VadFrameAction::None; + } + + if !self.in_speech { + self.pre_roll.push_back(frame); + if self.pre_roll.len() > VAD_PRE_ROLL_FRAMES { + self.pre_roll.pop_front(); + } + + if probability > VAD_ONSET_THRESHOLD { + self.onset_frames += 1; + } else { + self.onset_frames = 0; + } + + if self.onset_frames < VAD_ONSET_FRAMES { + return VadFrameAction::None; + } + + self.in_speech = true; + self.silence_frames = 0; + self.voiced_frames = self.onset_frames; + self.onset_frames = 0; + for buffered in self.pre_roll.drain(..) { + self.speech_buf.extend_from_slice(&buffered); + } + return VadFrameAction::Speech; + } + + if probability > VAD_OFFSET_THRESHOLD { + self.silence_frames = 0; + self.voiced_frames += 1; + self.speech_buf.extend_from_slice(&frame); + return VadFrameAction::Speech; + } + + self.silence_frames += 1; + self.speech_buf.extend_from_slice(&frame); + if flush_allowed && self.silence_frames >= flush_frames { + let excess_silence = self.silence_frames.saturating_sub(VAD_HANGOVER_FRAMES); + let retained_samples = self + .speech_buf + .len() + .saturating_sub(excess_silence * VAD_FRAME_SAMPLES); + self.speech_buf.truncate(retained_samples); + VadFrameAction::Flush + } else if self.silence_frames == 1 { + VadFrameAction::FirstSilence + } else { + VadFrameAction::None + } + } + + fn reset_segment(&mut self) { + self.speech_buf.clear(); + // A hard message boundary also clears pre-roll: fast follow-up turns + // may receive less than the full window, but no frame can be decoded + // into both adjacent transcript messages. + self.pre_roll.clear(); + self.onset_frames = 0; + self.silence_frames = 0; + self.voiced_frames = 0; + self.in_speech = false; + } +} + /// How long the worker waits on the audio channel before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(50); @@ -279,14 +408,8 @@ fn stt_worker( let mut input_buf_48k: Vec = Vec::with_capacity(chunk_in * 2); // Leftover 16 kHz samples that didn't fill a full VAD frame. let mut leftover_16k: Vec = Vec::new(); - // Accumulated speech frames (16 kHz). - let mut speech_buf: Vec = Vec::new(); - // Consecutive silence frame count. - let mut silence_frames: usize = 0; - // Whether we're currently in a speech segment. - let mut in_speech = false; - // Number of frames earshot classified as voiced in the current segment. - let mut voiced_frames = 0; + // Model-independent endpointing state around Earshot's frame probabilities. + let mut endpoint = VadEndpoint::new(); // Silence flush window (frames) — fixed at the production value. let flush_frames = SILENCE_FLUSH_FRAMES; // EXPERIMENTAL: speculative decode result + the voiced-frame count it was @@ -315,12 +438,18 @@ fn stt_worker( || manual_mic_unmuted .as_ref() .is_some_and(|manual| manual.load(Ordering::Acquire)); - if transmit_was_active && !transmit_now && in_speech && !speech_buf.is_empty() { - flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx); - speech_buf.clear(); - silence_frames = 0; - in_speech = false; - voiced_frames = 0; + if transmit_was_active + && !transmit_now + && endpoint.in_speech + && !endpoint.speech_buf.is_empty() + { + flush_to_stt( + &endpoint.speech_buf, + endpoint.voiced_frames, + &recognizer, + &text_tx, + ); + endpoint.reset_segment(); } transmit_was_active = transmit_now; } @@ -351,10 +480,7 @@ fn stt_worker( &resampled, &mut leftover_16k, &mut vad, - &mut speech_buf, - &mut silence_frames, - &mut in_speech, - &mut voiced_frames, + &mut endpoint, flush_frames, (speculative_enabled, &mut speculative), &recognizer, @@ -413,10 +539,7 @@ fn process_16k_samples( samples: &[f32], leftover: &mut Vec, vad: &mut earshot::Detector, - speech_buf: &mut Vec, - silence_frames: &mut usize, - in_speech: &mut bool, - voiced_frames: &mut usize, + endpoint: &mut VadEndpoint, flush_frames: usize, speculative: (bool, &mut Option<(String, usize)>), recognizer: &sherpa_onnx::OfflineRecognizer, @@ -431,73 +554,61 @@ fn process_16k_samples( let frame: Vec = leftover.drain(..VAD_FRAME_SAMPLES).collect(); let clamped: Vec = frame.iter().map(|&s| s.clamp(-1.0, 1.0)).collect(); let prob = vad.predict_f32(&clamped); - let is_speech = prob > VAD_THRESHOLD; - let manually_open = manual_mic_unmuted.is_some_and(|manual| manual.load(Ordering::Acquire)); let ptt_held = ptt_active.is_some_and(|ptt| ptt.load(Ordering::Acquire)); - // Shortcut-enabled mode accepts input from either the held shortcut or - // a manually open microphone. - let is_speech = if ptt_active.is_some() { - is_speech && (ptt_held || manually_open) - } else { - is_speech - }; + let accepts_audio = ptt_active.is_none() || ptt_held || manually_open; // A held shortcut means "I am not done talking": silence never ends // the utterance while it is held. VAD pause flushing applies in pure // VAD mode, or with a manually open mic once the shortcut is up. - let vad_flush_allowed = vad_flush_allowed(ptt_active.is_some(), manually_open, ptt_held); + let flush_allowed = vad_flush_allowed(ptt_active.is_some(), manually_open, ptt_held); - if is_speech { - *silence_frames = 0; - *in_speech = true; - *voiced_frames += 1; - speech_buf.extend_from_slice(&frame); - // New voiced audio invalidates any speculative decode. - speculative.take(); - - // OOM guard: flush and reset if the buffer exceeds 30 s of audio. - if speech_buf.len() >= MAX_SPEECH_SAMPLES { - flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); - speech_buf.clear(); - *silence_frames = 0; - *in_speech = false; - *voiced_frames = 0; + match endpoint.process_frame(frame, prob, accepts_audio, flush_allowed, flush_frames) { + VadFrameAction::Speech => { + // New voiced audio invalidates any speculative decode. + speculative.take(); } - } else if *in_speech { - // Still accumulate during brief silence gaps. - speech_buf.extend_from_slice(&frame); - *silence_frames += 1; - - // EXPERIMENTAL: kick the Parakeet decode at the first silent - // frame so it overlaps the flush window. speech_buf keeps - // accumulating silence afterwards, but trailing silence does not - // change the transcript; any resumed speech invalidates the - // speculative result above. - if speculative_enabled - && speculative.is_none() - && vad_flush_allowed - && has_enough_voiced_audio(*voiced_frames) - { - speculative.replace((decode_speech(recognizer, speech_buf), *voiced_frames)); + VadFrameAction::FirstSilence => { + // Start speculative decode at the first silent frame. Any + // resumed speech invalidates this result in the arm above. + if speculative_enabled + && speculative.is_none() + && flush_allowed + && has_enough_voiced_audio(endpoint.voiced_frames) + { + speculative.replace(( + decode_speech(recognizer, &endpoint.speech_buf), + endpoint.voiced_frames, + )); + } } - - // A manually open microphone behaves like normal VAD. A held - // shortcut keeps the utterance grouped until key release. - if vad_flush_allowed && *silence_frames >= flush_frames { - // End of utterance — transcribe (or emit the speculative decode). + VadFrameAction::Flush => { match speculative.take() { - Some((text, decoded_at)) if decoded_at == *voiced_frames => { + Some((text, decoded_at)) if decoded_at == endpoint.voiced_frames => { send_transcript(text, text_tx); } - _ => flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx), + _ => flush_to_stt( + &endpoint.speech_buf, + endpoint.voiced_frames, + recognizer, + text_tx, + ), } - speech_buf.clear(); - *silence_frames = 0; - *in_speech = false; - *voiced_frames = 0; + endpoint.reset_segment(); } + VadFrameAction::None => {} + } + + // Preserve the 30 s guard even while PTT suppresses silence flushing. + if endpoint.speech_buf.len() >= MAX_SPEECH_SAMPLES { + flush_to_stt( + &endpoint.speech_buf, + endpoint.voiced_frames, + recognizer, + text_tx, + ); + endpoint.reset_segment(); + speculative.take(); } - // If not in speech and not accumulating, just discard the frame. } } @@ -511,7 +622,13 @@ fn flush_to_stt( recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, ) { - if speech_buf.is_empty() || !has_enough_voiced_audio(voiced_frames) { + if speech_buf.is_empty() { + return; + } + if !has_enough_voiced_audio(voiced_frames) { + eprintln!( + "buzz-desktop: STT dropped short VAD segment ({voiced_frames}/{MIN_VOICED_FRAMES} voiced frames)" + ); return; } send_transcript(decode_speech(recognizer, speech_buf), text_tx); @@ -570,7 +687,14 @@ use super::drain_until_shutdown; #[cfg(test)] mod tests { - use super::{has_enough_voiced_audio, vad_flush_allowed, MIN_VOICED_FRAMES}; + use super::{ + has_enough_voiced_audio, vad_flush_allowed, VadEndpoint, VadFrameAction, MIN_VOICED_FRAMES, + SILENCE_FLUSH_FRAMES, VAD_FRAME_SAMPLES, VAD_ONSET_FRAMES, VAD_PRE_ROLL_FRAMES, + }; + + fn frame(value: f32) -> Vec { + vec![value; VAD_FRAME_SAMPLES] + } #[test] fn short_vad_blips_do_not_reach_the_recognizer() { @@ -579,6 +703,171 @@ mod tests { assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); } + #[test] + fn confirmed_onset_prepends_pre_roll_once() { + let mut endpoint = VadEndpoint::new(); + for value in 0..VAD_PRE_ROLL_FRAMES - VAD_ONSET_FRAMES { + assert_eq!( + endpoint.process_frame(frame(value as f32), 0.0, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::None + ); + } + for value in 0..VAD_ONSET_FRAMES { + let action = endpoint.process_frame( + frame(100.0 + value as f32), + 0.9, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + if value + 1 == VAD_ONSET_FRAMES { + assert_eq!(action, VadFrameAction::Speech); + } else { + assert_eq!(action, VadFrameAction::None); + } + } + + assert_eq!( + endpoint.speech_buf.len(), + VAD_PRE_ROLL_FRAMES * VAD_FRAME_SAMPLES + ); + assert_eq!(endpoint.speech_buf[0], 0.0); + assert_eq!(endpoint.speech_buf[VAD_FRAME_SAMPLES], 1.0); + assert_eq!(endpoint.pre_roll.len(), 0); + endpoint.process_frame(frame(200.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + assert_eq!( + endpoint.speech_buf.len(), + (VAD_PRE_ROLL_FRAMES + 1) * VAD_FRAME_SAMPLES + ); + } + + #[test] + fn onset_requires_consecutive_high_frames() { + let mut endpoint = VadEndpoint::new(); + for probability in [0.9, 0.9, 0.2, 0.9, 0.9] { + assert_eq!( + endpoint.process_frame(frame(1.0), probability, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::None + ); + } + assert_eq!( + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::Speech + ); + } + + #[test] + fn offset_hysteresis_preserves_borderline_speech() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!( + endpoint.process_frame(frame(2.0), 0.4, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::Speech + ); + assert_eq!(endpoint.silence_frames, 0); + } + + #[test] + fn below_offset_threshold_starts_silence() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!( + endpoint.process_frame(frame(0.0), 0.3, true, true, SILENCE_FLUSH_FRAMES), + VadFrameAction::FirstSilence + ); + assert_eq!(endpoint.silence_frames, 1); + } + + #[test] + fn short_segment_reaches_the_visible_drop_path() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let mut action = VadFrameAction::None; + for _ in 0..SILENCE_FLUSH_FRAMES { + action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); + } + assert_eq!(action, VadFrameAction::Flush); + assert!(!has_enough_voiced_audio(endpoint.voiced_frames)); + assert!(!endpoint.speech_buf.is_empty()); + } + + #[test] + fn silence_flush_retains_only_hangover_audio() { + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(1.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let speech_len = endpoint.speech_buf.len(); + for index in 1..=SILENCE_FLUSH_FRAMES { + let action = endpoint.process_frame(frame(0.0), 0.0, true, true, SILENCE_FLUSH_FRAMES); + if index == SILENCE_FLUSH_FRAMES { + assert_eq!(action, VadFrameAction::Flush); + } + } + assert_eq!( + endpoint.speech_buf.len(), + speech_len + 6 * VAD_FRAME_SAMPLES + ); + } + + #[test] + fn flush_boundary_never_double_includes_audio() { + const SEGMENT_N_MARKER: f32 = 777.0; + let mut endpoint = VadEndpoint::new(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame( + frame(SEGMENT_N_MARKER), + 0.9, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + } + for _ in 0..SILENCE_FLUSH_FRAMES { + endpoint.process_frame( + frame(SEGMENT_N_MARKER), + 0.0, + true, + true, + SILENCE_FLUSH_FRAMES, + ); + } + endpoint.reset_segment(); + + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let leaked = endpoint + .speech_buf + .iter() + .filter(|sample| **sample == SEGMENT_N_MARKER) + .count(); + assert_eq!(leaked, 0, "segment N audio leaked into segment N+1"); + } + + #[test] + fn reset_prevents_pre_roll_from_leaking_between_segments() { + const SEGMENT_N_MARKER: f32 = 777.0; + let mut endpoint = VadEndpoint::new(); + endpoint.pre_roll.push_back(frame(SEGMENT_N_MARKER)); + endpoint.reset_segment(); + for _ in 0..VAD_ONSET_FRAMES { + endpoint.process_frame(frame(2.0), 0.9, true, true, SILENCE_FLUSH_FRAMES); + } + let leaked = endpoint + .speech_buf + .iter() + .filter(|sample| **sample == SEGMENT_N_MARKER) + .count(); + assert_eq!(leaked, 0, "segment N pre-roll leaked into segment N+1"); + } + #[test] fn held_push_to_talk_never_silence_flushes() { // Pure VAD mode: silence always ends the utterance. diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index aca2339a3c..5c6bbc4c77 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -18,10 +18,9 @@ //! → cancel flag: a 10 ms barge-in monitor thread silences the player and //! releases tts_active on the flag's rising edge (~15 ms flag-to-silence, //! even mid-sentence while the worker is blocked in synth_chunk); the -//! worker then consumes the flag — drain queue + clear + play (un-pause). -//! Monitor clears and worker player mutations are serialized through the -//! `player_ops` mutex, with the flag re-checked under the lock — see the -//! monitor block in `tts_worker` for the race this closes. +//! worker then consumes the flag and drains stale text. Every Player +//! operation is serialized by `PlaybackCoordinator`; cancellation swaps in +//! a fresh queue and drops the old Player after releasing the coordinator. //! ``` //! //! Lookahead pipelining spans *items*, not just sentences within one item: @@ -55,6 +54,9 @@ use super::preprocessing::preprocess_for_tts; #[path = "tts_voice_transition.rs"] mod voice_transition; use voice_transition::*; +#[path = "tts_playback.rs"] +mod playback; +use playback::*; #[path = "tts_startup.rs"] mod startup; use startup::await_worker_startup; @@ -102,10 +104,26 @@ const SYNTH_STEPS: usize = 1; /// the leading waveform is important. const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; -/// Length of the zero-sample cushion prepended when playback is idle, so the -/// OS audio device / rodio mixer has a fully-quiet ramp-up window before the -/// real onset hits. Continuously queued chunks receive no synthetic padding. -const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; +/// rodio 0.22.2 bootstraps `UniformSourceIterator` when a source is added to +/// the mixer (`conversions/uniform.rs:49-66`). Its empty queue's 512-sample +/// span (`queue.rs::SourcesQueueInput::new`) can therefore retain placeholder +/// format metadata until the next span. The lead-in covers that whole span, +/// rounded up to the next millisecond, while preserving the product's existing +/// 20 ms quiet ramp-up. Continuously queued chunks receive no synthetic padding. +const SAMPLES_PER_MS: usize = SAMPLE_RATE as usize / 1_000; +const PRODUCT_RAMP_UP_MS: usize = 20; +const PRODUCT_RAMP_UP_SAMPLES: usize = PRODUCT_RAMP_UP_MS * SAMPLES_PER_MS; +const RODIO_ADD_BOOTSTRAP_SPAN_SAMPLES: usize = 512; +const RODIO_ADD_BOOTSTRAP_CUSHION_MS: usize = + RODIO_ADD_BOOTSTRAP_SPAN_SAMPLES.div_ceil(SAMPLES_PER_MS); +const SENTENCE_LEAD_IN_SAMPLES: usize = { + let bootstrap_cushion = RODIO_ADD_BOOTSTRAP_CUSHION_MS * SAMPLES_PER_MS; + if PRODUCT_RAMP_UP_SAMPLES > bootstrap_cushion { + PRODUCT_RAMP_UP_SAMPLES + } else { + bootstrap_cushion + } +}; type WorkerControlState = ( Arc, @@ -332,7 +350,6 @@ fn tts_worker( // ── 3. Initialise rodio output device ───────────────────────────────────── use rodio::buffer::SamplesBuffer; - use rodio::Player; let sink_handle = match super::audio_output::open_output_sink_by_name(output_device.as_deref()) { @@ -360,28 +377,24 @@ fn tts_worker( } }; - // Single persistent Player for the lifetime of the worker — all sentence - // buffers from all text items append here, and rodio plays them gaplessly. - // Persistence is what enables cross-item pipelining: the worker never - // waits for one item to drain before synthesizing the next. - // - // Shared (Arc) with the barge-in monitor thread below, which needs to - // silence it while this thread is blocked inside `synth_chunk`. - let player = Arc::new(Player::connect_new(sink_handle.mixer())); - playback_probe.install(Arc::clone(&player)); + // One coordinator owns the current Player and every operation on it. + // Cancellation replaces its queue while the output stream and mixer stay + // alive, preserving cross-item pipelining without exposing Player handles. + let playback = Arc::new(PlaybackCoordinator::new(sink_handle.mixer())); + playback_probe.install(Arc::clone(&playback)); // Prime the audio output stream with a short silent buffer. // On macOS, CoreAudio initializes the output device lazily on first use. // Without this, the first real append races against device startup and - // player.empty() returns true before audio has started draining — causing + // playback.empty() returns true before audio has started draining — causing // the first TTS message to be truncated after a few words. { let silence = vec![0.0f32; SAMPLE_RATE as usize / 10]; // 100ms of silence - player.append(SamplesBuffer::new(channels, rate, silence)); + playback.append_untracked(SamplesBuffer::new(channels, rate, silence)); // Wait for the silent buffer to drain — this ensures the output stream // is fully initialized before the first real utterance. let deadline = std::time::Instant::now() + AUDIO_PRIME_TIMEOUT; - while !player.empty() { + while !playback.empty() { if std::time::Instant::now() >= deadline { eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_prime"); let _ = startup_tx.send(Err( @@ -397,16 +410,14 @@ fn tts_worker( } eprintln!("buzz-desktop: tts stage=startup status=ready"); - let player_ops = Arc::clone(&playback_probe.player_ops); let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); let monitor = spawn_tts_monitor(TtsMonitorState { - player: Arc::clone(&player), + playback: Arc::clone(&playback), cancel: Arc::clone(&cancel), voice_cancel: Arc::clone(&voice_cancel), tts_active: Arc::clone(&tts_active), stop: Arc::clone(&monitor_stop), - player_ops: Arc::clone(&player_ops), activity_frames: Arc::clone(&activity_frames), active_speaker: Arc::clone(&active_speaker), speaker_cancel: Arc::clone(&speaker_cancel), @@ -429,75 +440,77 @@ fn tts_worker( // EXPERIMENTAL (latency bench): `Some(emit_frames)` = stream PCM deltas // out of Pocket as they are generated (see tts_streaming.rs). let tts_streaming = streaming_emit_frames(); - // `first_append` = "no audio queued since the player last went idle". - // Flipped by `build_sentence_append_buffer` on the first real append; the - // idle branch below uses it to decide when to drop `tts_active` and to - // arm a fresh lead-in cushion for the next utterance. - let mut first_append = true; let mut last_route_id = 0; let mut deferred_text = VecDeque::new(); let append_audio = |prepared: PreparedModelAudio, route_id: u64, speaker_pubkey: Option<&str>, speaker_generation: u64| { - let _ops = lock_player_ops(&player_ops); - if cancel.load(Ordering::Acquire) - || voice_cancel.load(Ordering::Acquire) - || shutdown.load(Ordering::Acquire) - { - let reason = if shutdown.load(Ordering::Acquire) { - "shutdown" - } else if cancel.load(Ordering::Acquire) { - "barge_in" - } else { - "voice_switch" - }; - eprintln!( - "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" - ); - return false; - } - let speaker_is_current = speaker_pubkey.is_none_or(|pubkey| { - current_speaker_generation(&speaker_generations, pubkey) == speaker_generation + let sample_count = prepared.sample_count; + let chunk_index = prepared.chunk_index; + let activity = speaker_pubkey.map(|pubkey| { + build_tts_speaker_activity_frames(&prepared.buffer, pubkey, SAMPLE_RATE as usize) }); - if !speaker_is_current { - eprintln!( - "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" - ); + let accepted = playback.append_if( + SamplesBuffer::new(channels, rate, prepared.buffer), + |player_empty| { + if cancel.load(Ordering::Acquire) + || voice_cancel.load(Ordering::Acquire) + || shutdown.load(Ordering::Acquire) + { + let reason = if shutdown.load(Ordering::Acquire) { + "shutdown" + } else if cancel.load(Ordering::Acquire) { + "barge_in" + } else { + "voice_switch" + }; + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}" + ); + return false; + } + if speaker_pubkey.is_some_and(|pubkey| { + current_speaker_generation(&speaker_generations, pubkey) != speaker_generation + }) { + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" + ); + return false; + } + if let Some(pubkey) = speaker_pubkey { + let mut active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if player_empty { + active.take(); + } + if active + .as_deref() + .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) + { + return false; + } + active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .extend(activity.unwrap_or_default()); + } + true + }, + // Publish activity under the coordinator: an append and the mic + // gate it implies are one transition, so a cancellation that + // replaces this player cannot have its release overwritten by a + // `true` landing after the fact. + || tts_active.store(true, Ordering::Release), + ); + if !accepted { return false; } - if let Some(pubkey) = speaker_pubkey { - let mut active = active_speaker - .lock() - .unwrap_or_else(|error| error.into_inner()); - if player.empty() { - active.take(); - } - if active - .as_deref() - .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) - { - return false; - } - active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); - } - if let Some(pubkey) = speaker_pubkey { - activity_frames - .lock() - .unwrap_or_else(|error| error.into_inner()) - .extend(build_tts_speaker_activity_frames( - &prepared.buffer, - pubkey, - SAMPLE_RATE as usize, - )); - } - player.append(SamplesBuffer::new(channels, rate, prepared.buffer)); eprintln!( - "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={} sample_count={}", - prepared.chunk_index, prepared.sample_count + "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={chunk_index} sample_count={sample_count}" ); - // Set this only after append so STT remains open during synthesis. - tts_active.store(true, Ordering::Release); true }; @@ -509,9 +522,8 @@ fn tts_worker( &speaker_generations, &tts_active, (&text_rx, &mut deferred_text, &mut no_current_text), - Some((&player, &player_ops)), + Some(&playback), ) { - first_append = true; continue; } if handle_cancel_or_shutdown( @@ -521,14 +533,13 @@ fn tts_worker( (&text_rx, &mut deferred_text, &mut no_current_text), &voice_change_ack, None, - Some((&player, &player_ops)), + Some(&playback), ) { if shutdown.load(Ordering::Acquire) { break; } // Cancel consumed: queued audio cleared, queue drained. The next // append starts a new utterance and needs its own lead-in cushion. - first_append = true; continue; } @@ -555,7 +566,7 @@ fn tts_worker( // Nothing queued. If playback has also finished, the agent // has gone quiet — release the mic gate and reset the // lead-in so the next utterance gets a fresh cushion. - if player.empty() && !first_append { + playback.release_if_drained(|| { tts_active.store(false, Ordering::Release); active_speaker .lock() @@ -564,8 +575,7 @@ fn tts_worker( eprintln!( "buzz-desktop: tts stage=player status=drained route_id={last_route_id}" ); - first_append = true; - } + }); continue; } Err(mpsc::RecvTimeoutError::Disconnected) => break, @@ -582,12 +592,11 @@ fn tts_worker( (&text_rx, &mut deferred_text, &mut queued_text), &voice_change_ack, pending_route_id, - Some((&player, &player_ops)), + Some(&playback), ) { if shutdown.load(Ordering::Acquire) { break; } - first_append = true; continue; } let Some(queued_text) = queued_text else { @@ -607,7 +616,7 @@ fn tts_worker( ); continue; } - if !player.empty() + if !playback.empty() && queued_text .speaker_pubkey .as_deref() @@ -639,18 +648,14 @@ fn tts_worker( // release stale ownership before doing any potentially slow voice or // synthesis work. Serialize the drain decision with Stop and append so // those paths observe one coherent utterance boundary. - { - let _ops = lock_player_ops(&player_ops); - if player.empty() && !first_append { - tts_active.store(false, Ordering::Release); - active_speaker - .lock() - .unwrap_or_else(|error| error.into_inner()) - .take(); - eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); - first_append = true; - } - } + playback.release_if_drained(|| { + tts_active.store(false, Ordering::Release); + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); + }); // From this point until the item finishes, an empty player can mean a // voice-preparation or synthesis gap rather than a drained utterance. @@ -716,9 +721,8 @@ fn tts_worker( (&text_rx, &mut deferred_text, &mut no_current_text), &voice_change_ack, Some(route_id), - Some((&player, &player_ops)), + Some(&playback), ) { - first_append = true; synthesis_outcome = "cancelled"; break; } @@ -738,8 +742,7 @@ fn tts_worker( emit_frames, (&cancel, &voice_cancel, &shutdown), StreamingPlayback { - player: &player, - first_append: &mut first_append, + playback: &playback, route_id, }, &mut |prepared| { @@ -791,9 +794,8 @@ fn tts_worker( (&text_rx, &mut deferred_text, &mut no_current_text), &voice_change_ack, Some(route_id), - Some((&player, &player_ops)), + Some(&playback), ) { - first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; } @@ -817,25 +819,20 @@ fn tts_worker( // synthesis that completed after cancellation so stale audio // never reaches the player, while keeping buzz-voice's // extracted April engine API unchanged. - first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; } match synthesis { Ok(samples) if !samples.is_empty() => { - if let Some(prepared) = playback_audio.push( - samples, - chunk_index, - &mut first_append, - player.empty(), - ) { + if let Some(prepared) = playback + .prepare_audio(|empty| playback_audio.push(samples, chunk_index, empty)) + { if !append_audio( prepared, route_id, speaker_pubkey.as_deref(), speaker_generation, ) { - first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; } @@ -857,14 +854,13 @@ fn tts_worker( } } } - if let Some(prepared) = playback_audio.finish(&mut first_append, player.empty()) { + if let Some(prepared) = playback.prepare_audio(|empty| playback_audio.finish(empty)) { if !append_audio( prepared, route_id, speaker_pubkey.as_deref(), speaker_generation, ) { - first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; } @@ -884,8 +880,8 @@ fn tts_worker( } } - // Stop the barge-in monitor before exiting — it holds a Player clone, - // and an orphaned monitor would keep ticking against a dead pipeline. + // Stop the barge-in monitor before exiting so an orphaned monitor cannot + // keep ticking against a dead pipeline. monitor_stop.store(true, Ordering::Release); if let Ok(handle) = monitor { let _ = handle.join(); diff --git a/desktop/src-tauri/src/huddle/tts_audio.rs b/desktop/src-tauri/src/huddle/tts_audio.rs index 80bf0c4661..993ce11298 100644 --- a/desktop/src-tauri/src/huddle/tts_audio.rs +++ b/desktop/src-tauri/src/huddle/tts_audio.rs @@ -21,35 +21,24 @@ impl PlaybackChunkAudio { &mut self, samples: Vec, chunk_index: usize, - first_append: &mut bool, playback_idle: bool, ) -> Option { if samples.is_empty() { return None; } let previous = self.pending.replace((samples, chunk_index))?; - let prepared = prepare_model_audio(previous, first_append, playback_idle, false); + let prepared = prepare_model_audio(previous, playback_idle, false); Some(prepared) } - pub(super) fn finish( - &mut self, - first_append: &mut bool, - playback_idle: bool, - ) -> Option { + pub(super) fn finish(&mut self, playback_idle: bool) -> Option { let pending = self.pending.take()?; - Some(prepare_model_audio( - pending, - first_append, - playback_idle, - true, - )) + Some(prepare_model_audio(pending, playback_idle, true)) } } fn prepare_model_audio( (samples, chunk_index): (Vec, usize), - first_append: &mut bool, starts_playback_chunk: bool, ends_playback_chunk: bool, ) -> PreparedModelAudio { @@ -59,7 +48,7 @@ fn prepare_model_audio( apply_fade_out(&mut audio); } PreparedModelAudio { - buffer: build_sentence_append_buffer(first_append, audio, starts_playback_chunk), + buffer: build_sentence_append_buffer(audio, starts_playback_chunk), sample_count, chunk_index, } @@ -80,14 +69,9 @@ pub(super) fn apply_fade_out(samples: &mut [f32]) { } pub(super) fn build_sentence_append_buffer( - first_append: &mut bool, audio: Vec, starts_playback_chunk: bool, ) -> Vec { - if *first_append { - *first_append = false; - } - let lead_in_len = if starts_playback_chunk { SENTENCE_LEAD_IN_SAMPLES } else { @@ -106,19 +90,14 @@ mod tests { #[test] fn model_units_are_queued_contiguously_without_injected_silence() { let mut chunk = PlaybackChunkAudio::new(); - let mut first_append = true; - assert!(chunk - .push(vec![0.4; 16], 0, &mut first_append, false) - .is_none()); + assert!(chunk.push(vec![0.4; 16], 0, false).is_none()); let first = chunk - .push(vec![0.5; 16], 1, &mut first_append, false) + .push(vec![0.5; 16], 1, false) .expect("first ready model unit"); assert_eq!(first.buffer, vec![0.4; 16]); - let last = chunk - .finish(&mut first_append, false) - .expect("last ready model unit"); + let last = chunk.finish(false).expect("last ready model unit"); assert_eq!(last.buffer.len(), 16); assert_eq!(last.sample_count, 16); } @@ -126,39 +105,27 @@ mod tests { #[test] fn empty_edge_units_do_not_steal_audio_boundaries() { let mut chunk = PlaybackChunkAudio::new(); - let mut first_append = true; - assert!(chunk - .push(Vec::new(), 0, &mut first_append, false) - .is_none()); - assert!(chunk - .push(vec![0.5; 16], 1, &mut first_append, false) - .is_none()); - assert!(chunk - .push(Vec::new(), 2, &mut first_append, false) - .is_none()); + assert!(chunk.push(Vec::new(), 0, false).is_none()); + assert!(chunk.push(vec![0.5; 16], 1, false).is_none()); + assert!(chunk.push(Vec::new(), 2, false).is_none()); - let only = chunk - .finish(&mut first_append, false) - .expect("only audible model unit"); + let only = chunk.finish(false).expect("only audible model unit"); assert_eq!(only.buffer.len(), 16); } #[test] fn playback_underrun_rearms_the_onset_cushion() { let mut chunk = PlaybackChunkAudio::new(); - let mut first_append = true; - assert!(chunk - .push(vec![0.4; 16], 0, &mut first_append, false) - .is_none()); + assert!(chunk.push(vec![0.4; 16], 0, false).is_none()); let first = chunk - .push(vec![0.5; 16], 1, &mut first_append, false) + .push(vec![0.5; 16], 1, false) .expect("first ready model unit"); assert_eq!(first.buffer.len(), 16); let after_underrun = chunk - .push(vec![0.6; 16], 2, &mut first_append, true) + .push(vec![0.6; 16], 2, true) .expect("second ready model unit"); assert_eq!(after_underrun.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); assert!(after_underrun.buffer[..SENTENCE_LEAD_IN_SAMPLES] diff --git a/desktop/src-tauri/src/huddle/tts_playback.rs b/desktop/src-tauri/src/huddle/tts_playback.rs new file mode 100644 index 0000000000..93a5b16b8c --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_playback.rs @@ -0,0 +1,485 @@ +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use rodio::{mixer::Mixer, Player, Source}; + +/// Serializes every operation on the TTS player and owns the utterance-boundary +/// bookkeeping that must change atomically when playback is replaced. +/// +/// Poison recovery is sound because `PlaybackState` has no partially-valid +/// representation: `Player` replacement is a single assignment, booleans are +/// independently valid at either value, and no mutable reference to the state +/// leaves the locked operation that created it. +pub(super) struct PlaybackCoordinator { + mixer: Mixer, + state: Mutex, +} + +struct PlaybackState { + player: Player, + /// `true` while no append has been committed since the last utterance + /// boundary. Only `append_if` clears it, so it records appends that were + /// actually queued — never one the authorization refused. + first_append: bool, + synthesis_in_flight: bool, + synthesis_generation: u64, +} + +pub(super) struct SynthesisFlightGuard { + playback: Arc, + generation: u64, +} + +impl Drop for SynthesisFlightGuard { + fn drop(&mut self) { + let mut state = self.playback.lock(); + if state.synthesis_generation == self.generation { + state.synthesis_in_flight = false; + } + } +} + +impl PlaybackCoordinator { + pub(super) fn new(mixer: &Mixer) -> Self { + Self { + mixer: mixer.clone(), + state: Mutex::new(PlaybackState { + player: Player::connect_new(mixer), + first_append: true, + synthesis_in_flight: false, + synthesis_generation: 0, + }), + } + } + + fn lock(&self) -> MutexGuard<'_, PlaybackState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Queue `source` when `authorize` accepts, then publish the append with + /// `commit` before releasing the coordinator. `commit` runs under the lock + /// so an append and the activity state it implies are one transition: a + /// concurrent cancellation either replaces the queue before this append is + /// authorized, or observes the committed state after it — never lands its + /// own release between the two and gets overwritten. + pub(super) fn append_if( + &self, + source: S, + authorize: impl FnOnce(bool) -> bool, + commit: impl FnOnce(), + ) -> bool + where + S: Source + Send + 'static, + { + let mut state = self.lock(); + if !authorize(state.player.empty()) { + return false; + } + state.player.append(source); + state.first_append = false; + commit(); + true + } + + pub(super) fn append_untracked(&self, source: S) + where + S: Source + Send + 'static, + { + self.lock().player.append(source); + } + + pub(super) fn empty(&self) -> bool { + self.lock().player.empty() + } + + /// Observe playback emptiness under the coordinator so the onset decision + /// for the audio being built is serialized with append and cancellation. + pub(super) fn prepare_audio(&self, prepare: impl FnOnce(bool) -> R) -> R { + let state = self.lock(); + let empty = state.player.empty(); + prepare(empty) + } + + pub(super) fn release_if_drained(&self, release: impl FnOnce()) -> bool { + let mut state = self.lock(); + if !state.player.empty() || state.first_append { + return false; + } + release(); + state.first_append = true; + true + } + + pub(super) fn begin_synthesis(self: &Arc) -> SynthesisFlightGuard { + let generation = { + let mut state = self.lock(); + state.synthesis_generation = state.synthesis_generation.wrapping_add(1); + state.synthesis_in_flight = true; + state.synthesis_generation + }; + SynthesisFlightGuard { + playback: Arc::clone(self), + generation, + } + } + + pub(super) fn with_playback_live(&self, observe: impl FnOnce(bool) -> R) -> R { + let state = self.lock(); + observe(!state.player.empty() || state.synthesis_in_flight) + } + + /// Replace live playback with a fresh queue, publishing the replacement + /// with `commit` before releasing the coordinator. The old player is + /// dropped after releasing, so rodio's teardown cannot extend the critical + /// section. Concurrent cancel observers elect exactly one replacement + /// because replacement resets both liveness signals. + /// + /// `commit` is the mirror of `append_if`'s: a replacement and the activity + /// state it implies are one transition, so an append that wins the lock + /// handoff after this cancellation cannot have its own publication + /// overwritten by a `false` landing late. + pub(super) fn cancel_if_live( + &self, + authorize: impl FnOnce() -> bool, + commit: impl FnOnce(), + ) -> bool { + let old_player = { + let mut state = self.lock(); + if (state.player.empty() && !state.synthesis_in_flight) || !authorize() { + return false; + } + state.first_append = true; + state.synthesis_in_flight = false; + state.synthesis_generation = state.synthesis_generation.wrapping_add(1); + let old_player = std::mem::replace(&mut state.player, Player::connect_new(&self.mixer)); + commit(); + old_player + }; + drop(old_player); + true + } +} + +#[cfg(test)] +mod tests { + use std::{ + num::NonZero, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Barrier, + }, + thread, + time::{Duration, Instant}, + }; + + use rodio::buffer::SamplesBuffer; + + use super::*; + + fn coordinator() -> (Arc, rodio::mixer::MixerSource) { + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(24_000).expect("nonzero rate"); + let (mixer, source) = rodio::mixer::mixer(channels, rate); + (Arc::new(PlaybackCoordinator::new(&mixer)), source) + } + + fn append_second(playback: &PlaybackCoordinator) { + playback.append_if( + SamplesBuffer::new( + NonZero::new(1).expect("nonzero channels"), + NonZero::new(24_000).expect("nonzero rate"), + vec![0.25; 24_000], + ), + |_| true, + || {}, + ); + } + + #[test] + fn cancel_replaces_playback_without_waiting_for_the_mixer() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + + let started = Instant::now(); + assert!(playback.cancel_if_live(|| true, || {})); + + assert!(started.elapsed() < Duration::from_millis(50)); + assert!(playback.empty()); + } + + #[test] + fn concurrent_cancel_observers_elect_exactly_one_replacement() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let barrier = Arc::new(Barrier::new(3)); + let replacements = Arc::new(AtomicUsize::new(0)); + let mut threads = Vec::new(); + for _ in 0..2 { + let playback = Arc::clone(&playback); + let barrier = Arc::clone(&barrier); + let replacements = Arc::clone(&replacements); + threads.push(thread::spawn(move || { + barrier.wait(); + if playback.cancel_if_live(|| true, || {}) { + replacements.fetch_add(1, Ordering::Relaxed); + } + })); + } + barrier.wait(); + for thread in threads { + thread.join().expect("cancel observer"); + } + + assert_eq!(replacements.load(Ordering::Relaxed), 1); + } + + #[test] + fn append_and_cancel_are_one_serialized_public_operation() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let append_authorized = Arc::new(Barrier::new(2)); + let release_append = Arc::new(Barrier::new(2)); + let append_thread = { + let playback = Arc::clone(&playback); + let append_authorized = Arc::clone(&append_authorized); + let release_append = Arc::clone(&release_append); + thread::spawn(move || { + playback.append_if( + SamplesBuffer::new( + NonZero::new(1).expect("nonzero channels"), + NonZero::new(24_000).expect("nonzero rate"), + vec![0.5; 24_000], + ), + |_| { + append_authorized.wait(); + release_append.wait(); + true + }, + || {}, + ) + }) + }; + append_authorized.wait(); + let cancel_thread = { + let playback = Arc::clone(&playback); + thread::spawn(move || playback.cancel_if_live(|| true, || {})) + }; + release_append.wait(); + + assert!(append_thread.join().expect("append")); + assert!(cancel_thread.join().expect("cancel")); + assert!( + playback.empty(), + "cancel must replace the queue after append" + ); + } + + #[test] + fn an_accepted_append_publishes_its_activity_inside_the_append_transition() { + let (playback, _unpulled_source) = coordinator(); + let committed = Arc::new(AtomicBool::new(false)); + + let appended = playback.append_if( + SamplesBuffer::new( + NonZero::new(1).expect("nonzero channels"), + NonZero::new(24_000).expect("nonzero rate"), + vec![0.25; 24_000], + ), + |_| true, + || { + // The coordinator is still held, so no cancellation can land a + // release between queueing this audio and publishing it. + assert!( + playback.state.try_lock().is_err(), + "commit must run inside the append's critical section" + ); + committed.store(true, Ordering::Release); + }, + ); + + assert!(appended); + assert!( + committed.load(Ordering::Acquire), + "an accepted append must publish" + ); + assert!( + playback.state.try_lock().is_ok(), + "the coordinator is released once the append returns" + ); + } + + #[test] + fn a_refused_append_publishes_nothing_and_leaves_the_onset_armed() { + let (playback, _unpulled_source) = coordinator(); + + // The worker builds its buffer under the coordinator, then the append + // is refused — cancelled, or owned by another speaker. + playback.prepare_audio(|starts_playback_chunk| { + assert!(starts_playback_chunk, "a fresh coordinator is idle"); + }); + let appended = playback.append_if( + SamplesBuffer::new( + NonZero::new(1).expect("nonzero channels"), + NonZero::new(24_000).expect("nonzero rate"), + vec![0.25; 24_000], + ), + |_| false, + || panic!("a refused append must publish nothing"), + ); + + assert!(!appended); + // Nothing was queued, so this is not a drained utterance: releasing + // here would drop the mic gate and log a drain for audio that never + // played, and cost the next append its onset cushion. + assert!( + !playback.release_if_drained(|| panic!("a refused append is not a drain")), + "a refused append must not present as a drained utterance" + ); + } + + #[test] + fn an_appended_utterance_still_releases_exactly_once_when_it_drains() { + let (playback, mut source) = coordinator(); + append_second(&playback); + + assert!( + !playback.release_if_drained(|| panic!("queued audio is not drained")), + "queued audio must not release" + ); + while !playback.empty() { + assert!( + source.next().is_some(), + "the mixer source outlives the queue" + ); + } + + let releases = Arc::new(AtomicUsize::new(0)); + for _ in 0..2 { + let releases = Arc::clone(&releases); + playback.release_if_drained(move || { + releases.fetch_add(1, Ordering::Relaxed); + }); + } + + assert_eq!( + releases.load(Ordering::Relaxed), + 1, + "a drained utterance releases once and rearms the onset" + ); + } + + /// The reverse direction of the same barrier: a cancellation must publish + /// its release *inside* the replacement. The cancelling thread is + /// otherwise past its replacement and about to release the mic gate, while + /// an append that wins the coordinator handoff has already published its + /// own `true` — a `false` landing outside the replacement would ungate the + /// mic for audio that is actually playing, and VAD would hear our own TTS + /// and barge in on it. + #[test] + fn a_replacement_publishes_its_release_inside_the_cancel_transition() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + let committed = Arc::new(AtomicBool::new(false)); + + let replaced = playback.cancel_if_live( + || true, + || { + // Still held: no append can commit its own activity between + // this replacement and the release it implies. + assert!( + playback.state.try_lock().is_err(), + "commit must run inside the cancellation's critical section" + ); + committed.store(true, Ordering::Release); + }, + ); + + assert!(replaced, "live playback must be replaced"); + assert!( + committed.load(Ordering::Acquire), + "a replacement must publish" + ); + assert!( + playback.state.try_lock().is_ok(), + "the coordinator is released once the cancellation returns" + ); + } + + /// A cancellation that replaces nothing publishes nothing: the caller + /// still owns releasing the gate, and a replacement that never happened + /// must not present as one. + #[test] + fn a_cancellation_with_nothing_live_publishes_nothing() { + let (playback, _unpulled_source) = coordinator(); + + assert!(!playback.cancel_if_live(|| true, || panic!("nothing was replaced"))); + assert!(!playback.cancel_if_live(|| false, || panic!("cancellation was refused"))); + } + + /// Both publication directions under real contention: whichever + /// transition takes the coordinator last decides, and the activity flag + /// must describe the player that survived. + #[test] + fn a_cancellation_and_an_append_never_disagree_about_the_mic_gate() { + for _ in 0..256 { + let (playback, _unpulled_source) = coordinator(); + let tts_active = Arc::new(AtomicBool::new(true)); + append_second(&playback); + let barrier = Arc::new(Barrier::new(2)); + + let canceller = { + let playback = Arc::clone(&playback); + let tts_active = Arc::clone(&tts_active); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + playback.cancel_if_live(|| true, || tts_active.store(false, Ordering::Release)) + }) + }; + let appender = { + let playback = Arc::clone(&playback); + let tts_active = Arc::clone(&tts_active); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + playback.append_if( + SamplesBuffer::new( + NonZero::new(1).expect("nonzero channels"), + NonZero::new(24_000).expect("nonzero rate"), + vec![0.5; 24_000], + ), + |_| true, + || tts_active.store(true, Ordering::Release), + ) + }) + }; + + let replaced = canceller.join().expect("canceller"); + let appended = appender.join().expect("appender"); + assert!(replaced, "live playback must be replaced"); + assert!(appended, "the append is authorized either way"); + + assert_eq!( + tts_active.load(Ordering::Acquire), + !playback.empty(), + "the mic gate must agree with the player that survived" + ); + } + } + + #[test] + fn cancellation_rearms_first_append_and_releases_activity_once() { + let (playback, _unpulled_source) = coordinator(); + append_second(&playback); + assert!(playback.cancel_if_live(|| true, || {})); + assert!(!playback.release_if_drained(|| panic!("fresh replacement is not a drain"))); + playback.prepare_audio(|starts_playback_chunk| { + assert!( + starts_playback_chunk, + "the first append after replacement must carry the onset cushion" + ); + }); + + append_second(&playback); + assert!(!playback.release_if_drained(|| panic!("queued audio is not drained"))); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs index 4b9c2824f7..185ca56d08 100644 --- a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs +++ b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs @@ -1,12 +1,11 @@ use super::*; pub(super) struct TtsMonitorState { - pub(super) player: Arc, + pub(super) playback: Arc, pub(super) cancel: Arc, pub(super) voice_cancel: Arc, pub(super) tts_active: Arc, pub(super) stop: Arc, - pub(super) player_ops: Arc>, pub(super) activity_frames: Arc>>, pub(super) active_speaker: ActiveSpeaker, pub(super) speaker_cancel: SpeakerCancellation, @@ -23,25 +22,28 @@ pub(super) fn spawn_tts_monitor(state: TtsMonitorState) -> std::io::Result std::io::Result, + playback: &PlaybackCoordinator, tts_active: &AtomicBool, ) { let Some(cancelled) = cancellation @@ -105,12 +106,10 @@ pub(super) fn silence_cancelled_speaker( else { return; }; - let _ops = lock_player_ops(player_ops); - if take_cancelled_active_speaker(&cancelled, active_speaker) { - player.clear(); - player.play(); - tts_active.store(false, Ordering::Release); - } + playback.cancel_if_live( + || take_cancelled_active_speaker(&cancelled, active_speaker), + || tts_active.store(false, Ordering::Release), + ); } fn take_cancelled_active_speaker(cancelled: &str, active_speaker: &ActiveSpeaker) -> bool { @@ -133,7 +132,7 @@ pub(super) fn consume_speaker_cancel( generations: &SpeakerGenerations, tts_active: &AtomicBool, text_state: CancelTextState<'_>, - player: Option<(&rodio::Player, &Mutex<()>)>, + playback: Option<&PlaybackCoordinator>, ) -> bool { let Some(cancelled) = cancellation .lock() @@ -145,12 +144,11 @@ pub(super) fn consume_speaker_cancel( let (text_rx, deferred_text, current_text) = text_state; retain_current_speaker_text(generations, deferred_text, current_text, text_rx); let mut cleared_player = false; - if let Some((player, player_ops)) = player { - let _ops = lock_player_ops(player_ops); - if take_cancelled_active_speaker(&cancelled, active_speaker) { - player.clear(); - player.play(); - tts_active.store(false, Ordering::Release); + if let Some(playback) = playback { + if playback.cancel_if_live( + || take_cancelled_active_speaker(&cancelled, active_speaker), + || tts_active.store(false, Ordering::Release), + ) { cleared_player = true; } } @@ -164,6 +162,31 @@ pub(super) fn consume_speaker_cancel( mod tests { use super::*; + use std::sync::Barrier; + + use rodio::buffer::SamplesBuffer; + + /// Headless coordinator: a real mixer with its source held unpulled, so + /// the queue never drains and no output device is opened. + fn coordinator() -> (Arc, rodio::mixer::MixerSource) { + let channels = std::num::NonZero::new(1).expect("nonzero channels"); + let rate = std::num::NonZero::new(24_000).expect("nonzero rate"); + let (mixer, source) = rodio::mixer::mixer(channels, rate); + (Arc::new(PlaybackCoordinator::new(&mixer)), source) + } + + /// Append as `speaker` the way the worker does: activity is published + /// inside the append transition. + fn speak(playback: &PlaybackCoordinator, tts_active: &AtomicBool) { + let channels = std::num::NonZero::new(1).expect("nonzero channels"); + let rate = std::num::NonZero::new(24_000).expect("nonzero rate"); + assert!(playback.append_if( + SamplesBuffer::new(channels, rate, vec![0.25; 24_000]), + |_| true, + || tts_active.store(true, Ordering::Release), + )); + } + #[test] fn stale_targeted_cancel_does_not_release_the_next_speaker() { let active_speaker = Arc::new(Mutex::new(Some("bob".to_string()))); @@ -174,4 +197,129 @@ mod tests { Some("bob") ); } + + /// The wedge Mari found: the monitor silences the cancelled speaker while + /// the worker is mid-append. The monitor takes `active_speaker`, so the + /// worker's later `consume_speaker_cancel` fails authorization and never + /// clears `tts_active` — if the worker's `true` could land after the + /// monitor's `false`, mic gating stays active with nothing playing. + #[test] + fn a_targeted_cancel_racing_an_append_leaves_the_mic_gate_released() { + for _ in 0..64 { + let (playback, _unpulled_source) = coordinator(); + let tts_active = Arc::new(AtomicBool::new(false)); + let active_speaker: ActiveSpeaker = Arc::new(Mutex::new(None)); + let speaker_cancel: SpeakerCancellation = Arc::new(Mutex::new(None)); + + speak(&playback, &tts_active); + active_speaker + .lock() + .expect("active speaker") + .replace("alice".to_string()); + speaker_cancel + .lock() + .expect("speaker cancel") + .replace("alice".to_string()); + + let barrier = Arc::new(Barrier::new(2)); + let monitor = { + let playback = Arc::clone(&playback); + let tts_active = Arc::clone(&tts_active); + let active_speaker = Arc::clone(&active_speaker); + let speaker_cancel = Arc::clone(&speaker_cancel); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + silence_cancelled_speaker( + &speaker_cancel, + &active_speaker, + &playback, + &tts_active, + ); + }) + }; + let worker = { + let playback = Arc::clone(&playback); + let tts_active = Arc::clone(&tts_active); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + // The worker appends the next chunk of the utterance the + // monitor is cancelling. + playback.append_if( + SamplesBuffer::new( + std::num::NonZero::new(1).expect("nonzero channels"), + std::num::NonZero::new(24_000).expect("nonzero rate"), + vec![0.25; 24_000], + ), + |_| true, + || tts_active.store(true, Ordering::Release), + ) + }) + }; + + let appended = worker.join().expect("worker"); + monitor.join().expect("monitor"); + + // Whichever order the two took the coordinator, the surviving + // activity flag must describe the surviving player. + assert_eq!( + tts_active.load(Ordering::Acquire), + !playback.empty(), + "the mic gate must agree with the player that survived \ + (appended={appended})" + ); + } + } + + /// The worker arm of the same race: the monitor already took the speaker, + /// so `consume_speaker_cancel` is not authorized to clear anything. It + /// must not report a clear it did not perform, and it must not disturb the + /// activity flag the monitor already published. + #[test] + fn consuming_a_cancel_the_monitor_already_handled_preserves_the_released_gate() { + let (playback, _unpulled_source) = coordinator(); + let tts_active = Arc::new(AtomicBool::new(false)); + let active_speaker: ActiveSpeaker = Arc::new(Mutex::new(None)); + let speaker_cancel: SpeakerCancellation = Arc::new(Mutex::new(None)); + let generations: SpeakerGenerations = Arc::new(Mutex::new(HashMap::new())); + let (_text_tx, text_rx) = mpsc::channel::(); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + speak(&playback, &tts_active); + active_speaker + .lock() + .expect("active speaker") + .replace("alice".to_string()); + speaker_cancel + .lock() + .expect("speaker cancel") + .replace("alice".to_string()); + + silence_cancelled_speaker(&speaker_cancel, &active_speaker, &playback, &tts_active); + assert!( + !tts_active.load(Ordering::Acquire), + "the monitor releases the mic gate it cancelled" + ); + + let cleared = consume_speaker_cancel( + &speaker_cancel, + &active_speaker, + &generations, + &tts_active, + (&text_rx, &mut deferred_text, &mut current_text), + Some(&playback), + ); + + assert!( + !cleared, + "the worker must not claim a clear the monitor already performed" + ); + assert!( + !tts_active.load(Ordering::Acquire), + "the released mic gate must survive the worker's pass" + ); + assert!(playback.empty(), "the cancelled utterance stays silenced"); + } } diff --git a/desktop/src-tauri/src/huddle/tts_streaming.rs b/desktop/src-tauri/src/huddle/tts_streaming.rs index 2bb401c43f..cf618f8be6 100644 --- a/desktop/src-tauri/src/huddle/tts_streaming.rs +++ b/desktop/src-tauri/src/huddle/tts_streaming.rs @@ -27,8 +27,7 @@ pub(super) fn streaming_emit_frames() -> Option { /// Playback context threaded through one streamed chunk. pub(super) struct StreamingPlayback<'a> { - pub(super) player: &'a rodio::Player, - pub(super) first_append: &'a mut bool, + pub(super) playback: &'a PlaybackCoordinator, pub(super) route_id: u64, } @@ -52,11 +51,7 @@ pub(super) fn synthesize_streaming( append_audio: &mut dyn FnMut(PreparedModelAudio) -> bool, ) -> Option<&'static str> { let (cancel, voice_cancel, shutdown) = signals; - let StreamingPlayback { - player, - first_append, - route_id, - } = playback; + let StreamingPlayback { playback, route_id } = playback; let mut playback_audio = PlaybackChunkAudio::new(); let mut delta_index = 0usize; let stream_result = engine.synth_chunk_streaming(text, style, emit_frames, &mut |samples| { @@ -69,7 +64,7 @@ pub(super) fn synthesize_streaming( let chunk_index = delta_index; delta_index += 1; if let Some(prepared) = - playback_audio.push(samples, chunk_index, first_append, player.empty()) + playback.prepare_audio(|empty| playback_audio.push(samples, chunk_index, empty)) { if !append_audio(prepared) { return false; @@ -79,9 +74,8 @@ pub(super) fn synthesize_streaming( }); match stream_result { Ok(true) => { - if let Some(prepared) = playback_audio.finish(first_append, player.empty()) { + if let Some(prepared) = playback.prepare_audio(|empty| playback_audio.finish(empty)) { if !append_audio(prepared) { - *first_append = true; return Some("cancelled"); } } @@ -91,7 +85,6 @@ pub(super) fn synthesize_streaming( eprintln!( "buzz-desktop: tts stage=synthesis status=cancelled reason=stream_callback route_id={route_id}" ); - *first_append = true; Some("cancelled") } Err(_) => { diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 50e4d17ced..3358d52644 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -785,22 +785,12 @@ fn apply_fade_out_single_sample() { // ── build_sentence_append_buffer tests ─────────────────────────────────── -/// `first_append` still flips on the first append for `tts_active` gating. -#[test] -fn build_sentence_append_buffer_flips_first_append() { - let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); - assert_eq!(buf, vec![0.5; 100]); - assert!(!first, "first call must flip the flag"); -} - /// Playback chunks are contiguous: Pocket's generated pause is not extended /// with a fixed inter-sentence silence budget. #[test] fn sentence_append_buffer_does_not_inject_silence() { - let mut first = true; - let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); - let second_buf = build_sentence_append_buffer(&mut first, vec![0.25; 100], false); + let first_buf = build_sentence_append_buffer(vec![0.5; 100], false); + let second_buf = build_sentence_append_buffer(vec![0.25; 100], false); assert_eq!(first_buf, vec![0.5; 100]); assert_eq!(second_buf, vec![0.25; 100]); @@ -810,8 +800,7 @@ fn sentence_append_buffer_does_not_inject_silence() { /// the first phoneme while the output path wakes back up. #[test] fn idle_playback_gets_an_onset_cushion() { - let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], true); + let buf = build_sentence_append_buffer(vec![0.5; 100], true); assert_eq!(buf.len(), SENTENCE_LEAD_IN_SAMPLES + 100); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); diff --git a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs index 404f8a8153..bd9d85215e 100644 --- a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs +++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs @@ -1,18 +1,19 @@ use super::*; -/// The onset cushion covers 20 ms at the production sample rate. +/// The onset cushion rounds rodio's 512-sample bootstrap span up to 22 ms at +/// the 24 kHz production sample rate (528 samples). #[test] fn chunk_lead_in_is_sane() { - assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); + assert_eq!(RODIO_ADD_BOOTSTRAP_SPAN_SAMPLES, 512); + assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 528, "22 ms × 24 kHz"); } /// Model-token splits remain contiguous: only the playback chunk as a whole /// receives its onset cushion and trailing sentence gap. #[test] fn token_split_units_do_not_add_sentence_boundary_padding() { - let mut first = true; - let first_unit = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); - let last_unit = build_sentence_append_buffer(&mut first, vec![0.25; 100], false); + let first_unit = build_sentence_append_buffer(vec![0.5; 100], false); + let last_unit = build_sentence_append_buffer(vec![0.25; 100], false); assert_eq!(first_unit.len(), 100); assert_eq!(first_unit.last(), Some(&0.5)); diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 99b165bfe8..78c9c3cc4d 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -5,10 +5,12 @@ use std::{ sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, SyncSender}, - Arc, Mutex, MutexGuard, PoisonError, + Arc, Mutex, }, }; +use super::{PlaybackCoordinator, SynthesisFlightGuard}; + use crate::huddle::pocket::{load_voice_style, VoiceStyle, DEFAULT_VOICE, VOICE_FILE_EXT}; #[derive(Debug)] @@ -32,51 +34,29 @@ pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); #[derive(Clone)] pub(super) struct PlaybackProbe { - player: Arc>>>, - pub(super) player_ops: Arc>, - synthesis_in_flight: Arc, -} - -pub(super) struct SynthesisFlightGuard { - playback_probe: PlaybackProbe, -} - -impl Drop for SynthesisFlightGuard { - fn drop(&mut self) { - self.playback_probe.set_synthesis_in_flight(false); - } + playback: Arc>>>, } impl PlaybackProbe { pub(super) fn new() -> Self { Self { - player: Arc::new(Mutex::new(None)), - player_ops: Arc::new(Mutex::new(())), - synthesis_in_flight: Arc::new(AtomicBool::new(false)), + playback: Arc::new(Mutex::new(None)), } } - pub(super) fn install(&self, player: Arc) { - self.player + pub(super) fn install(&self, playback: Arc) { + self.playback .lock() .unwrap_or_else(|error| error.into_inner()) - .replace(player); + .replace(playback); } - pub(super) fn set_synthesis_in_flight(&self, in_flight: bool) { - let _ops = lock_player_ops(&self.player_ops); - self.synthesis_in_flight.store(in_flight, Ordering::Release); + pub(super) fn begin_synthesis(&self) -> Option { + self.playback().map(|playback| playback.begin_synthesis()) } - pub(super) fn begin_synthesis(&self) -> SynthesisFlightGuard { - self.set_synthesis_in_flight(true); - SynthesisFlightGuard { - playback_probe: self.clone(), - } - } - - fn player(&self) -> Option> { - self.player + pub(super) fn playback(&self) -> Option> { + self.playback .lock() .unwrap_or_else(|error| error.into_inner()) .clone() @@ -199,19 +179,18 @@ pub(super) fn request_active_speaker_cancel( playback_probe: &PlaybackProbe, expected_speaker_pubkey: &str, ) -> bool { - let Some(player) = playback_probe.player() else { + let Some(playback) = playback_probe.playback() else { return false; }; - let _ops = lock_player_ops(&playback_probe.player_ops); - let playback_live = - !player.empty() || playback_probe.synthesis_in_flight.load(Ordering::Acquire); - request_active_speaker_cancel_while_locked( - generations, - active_speaker, - cancellation, - playback_live, - expected_speaker_pubkey, - ) + playback.with_playback_live(|playback_live| { + request_active_speaker_cancel_while_locked( + generations, + active_speaker, + cancellation, + playback_live, + expected_speaker_pubkey, + ) + }) } fn request_active_speaker_cancel_while_locked( @@ -475,10 +454,8 @@ fn log_cancelled_route(route_id: u64, reason: &str) { /// Check for cancel or shutdown. Returns `true` if the caller should break/continue. /// On cancel: drains the text queue and clears the cancel flag. /// -/// `player` pairs the Player with the `player_ops` mutex shared with the -/// barge-in monitor thread; the cancel/shutdown clear runs under that lock so -/// it is serialized with the monitor's stale-branch re-check (see the monitor -/// block in `tts_worker`). +/// `playback` is the coordinator shared with the barge-in monitor; replacing +/// playback is serialized with append and with the monitor's stale observation. pub(super) fn handle_cancel_or_shutdown( cancel_signals: CancelSignals<'_>, shutdown: &AtomicBool, @@ -486,7 +463,7 @@ pub(super) fn handle_cancel_or_shutdown( text_state: CancelTextState<'_>, voice_change_ack: &VoiceChangeAck, active_route_id: Option, - player: Option<(&rodio::Player, &Mutex<()>)>, + playback: Option<&PlaybackCoordinator>, ) -> bool { let (cancel, voice_cancel) = cancel_signals; let (text_rx, deferred_text, current_text) = text_state; @@ -495,11 +472,7 @@ pub(super) fn handle_cancel_or_shutdown( "buzz-desktop: tts stage=cancellation reason=shutdown route_id={}", active_route_id.unwrap_or(0) ); - if let Some((p, ops)) = player { - let _ops = lock_player_ops(ops); - p.clear(); - } - tts_active.store(false, Ordering::Release); + release_playback(playback, tts_active); return true; } if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { @@ -525,33 +498,27 @@ pub(super) fn handle_cancel_or_shutdown( }) .flatten(); retain_cancelled_text(deferred_text, current_text, text_rx, preserve_generation); - if let Some((p, ops)) = player { - let _ops = lock_player_ops(ops); - // `Player::clear()` removes queued sources AND pauses the player - // (rodio 0.22 `clear()` ends with `self.pause()`). With one - // persistent Player for the worker's lifetime, the un-pause is - // mandatory: without `play()`, every append after a barge-in - // would queue silently forever. - p.clear(); - p.play(); - // Consume the flag under the lock: once released with - // `cancel == false`, the monitor's stale branch no-ops instead - // of clearing the fresh post-cancel utterance. - } - tts_active.store(false, Ordering::Release); + // Consume the flag at the coordinator serialization point: once + // released with `cancel == false`, a stale monitor observation cannot + // replace fresh post-cancel playback. + release_playback(playback, tts_active); return true; } false } -/// Acquire the `player_ops` lock, recovering from poison. -/// -/// The data under the mutex is `()` — it only serializes Player mutations — -/// so a panicked holder leaves nothing inconsistent to observe and recovery -/// is always safe. Without this, a worker panic would wedge the monitor (or -/// vice versa) on `unwrap()`. -pub(super) fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { - ops.lock().unwrap_or_else(PoisonError::into_inner) +/// Silence playback and release the mic gate as one transition. When a player +/// is live the release is published inside the replacement, so an append that +/// wins the coordinator handoff cannot have its own activity publication +/// overwritten by this `false`. With nothing live there is no transition to +/// join and the gate is released directly. +fn release_playback(playback: Option<&PlaybackCoordinator>, tts_active: &AtomicBool) { + let released = playback.is_some_and(|playback| { + playback.cancel_if_live(|| true, || tts_active.store(false, Ordering::Release)) + }); + if !released { + tts_active.store(false, Ordering::Release); + } } #[cfg(test)] @@ -562,16 +529,16 @@ mod speaker_generation_tests { let channels = std::num::NonZero::new(1).expect("non-zero channels"); let sample_rate = std::num::NonZero::new(24_000).expect("non-zero sample rate"); let (mixer, _mixer_source) = rodio::mixer::mixer(channels, sample_rate); - let player = Arc::new(rodio::Player::connect_new(&mixer)); + let playback = Arc::new(PlaybackCoordinator::new(&mixer)); if playback_live { - player.append(rodio::buffer::SamplesBuffer::new( - channels, - sample_rate, - vec![0.0; 24_000], - )); + playback.append_if( + rodio::buffer::SamplesBuffer::new(channels, sample_rate, vec![0.0; 24_000]), + |_| true, + || {}, + ); } let probe = PlaybackProbe::new(); - probe.install(player); + probe.install(playback); probe } @@ -586,6 +553,43 @@ mod speaker_generation_tests { } } + /// A cancellation releases the mic gate whether or not there was audio to + /// silence. With a live player the release rides inside the replacement; + /// with nothing live there is no transition to join, and skipping the + /// release would strand the gate open with the worker already past the + /// utterance. + #[test] + fn cancellation_releases_the_mic_gate_with_or_without_live_playback() { + for playback_live in [false, true] { + let probe = playback_probe(playback_live); + let playback = probe.playback().expect("installed coordinator"); + let cancel = AtomicBool::new(true); + let voice_cancel = AtomicBool::new(false); + let shutdown = AtomicBool::new(false); + let tts_active = AtomicBool::new(true); + let voice_change_ack = Arc::new(Mutex::new(None)); + let (_text_tx, text_rx) = mpsc::channel(); + let mut deferred_text = VecDeque::new(); + let mut current_text = None; + + assert!(handle_cancel_or_shutdown( + (&cancel, &voice_cancel), + &shutdown, + &tts_active, + (&text_rx, &mut deferred_text, &mut current_text), + &voice_change_ack, + None, + Some(&playback), + )); + + assert!( + !tts_active.load(Ordering::Acquire), + "cancellation must release the mic gate (playback_live={playback_live})" + ); + assert!(playback.empty(), "cancellation silences any queued audio"); + } + } + #[test] fn removing_a_speaker_invalidates_only_that_speakers_queued_text() { let generations = Arc::new(Mutex::new(HashMap::new())); diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index ff9367641a..7f46ff2a7d 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -233,7 +233,10 @@ fn generated_passphrase_respects_word_count_and_separator() { WORDLIST.lines().filter(|l| !l.is_empty()).collect(); assert_eq!(words.len(), 1296, "EFF short wordlist 2.0 has 1296 words"); - for (count, separator) in [(3, "-"), (4, "-"), (6, " "), (5, "."), (10, "")] { + // Use separators that cannot appear in the EFF wordlist so a generated + // word such as "yo-yo" cannot be mistaken for two words (see the same + // guard in generated_passphrase_clamps_word_count and issue #6249). + for (count, separator) in [(3, "|"), (4, "|"), (6, " "), (5, "."), (10, "")] { let phrase = generate_passphrase(count, separator).unwrap(); if separator.is_empty() { // No separator to split on; length gate below still applies. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cefdccfd69..f8601b66d0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -26,9 +26,13 @@ mod migration; #[cfg(test)] mod model_tests; mod models; +mod native_relay_client; mod native_websocket; +mod native_websocket_batch; mod nostr_bind; pub mod nostr_convert; +mod observed_unread; +mod persona_catalog; mod prevent_sleep; mod ptt_shortcut; mod relay; @@ -42,6 +46,7 @@ mod terminal_runtime; mod terminal_transport; #[cfg(target_os = "macos")] mod tray_menu; +mod unread_catch_up; mod util; #[cfg(target_os = "linux")] pub mod webkit_rendering; @@ -57,17 +62,16 @@ use deep_link::{ take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingEntityDeepLinks, PendingNavigationDeepLinks, }; -use huddle::audio_output::{ - get_audio_output_device, list_audio_output_devices, set_audio_output_device, -}; -use huddle::reconnect::reconnect_huddle_audio; use huddle::{ - add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, - download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, - get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle, - open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, - set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, - start_huddle, start_stt_pipeline, HuddlePhase, + add_agent_to_huddle, + audio_output::{get_audio_output_device, list_audio_output_devices, set_audio_output_device}, + check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, download_voice_models, + end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, + interrupt_huddle_speech, join_huddle, leave_huddle, open_huddle_window, push_audio_pcm, + reconnect::reconnect_huddle_audio, + remove_agent_from_huddle, set_huddle_manual_mic_unmuted, set_huddle_transcription_enabled, + set_tts_enabled, set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, + HuddlePhase, }; use initial_window::*; use managed_agents::{ @@ -198,95 +202,10 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()); - // The global-shortcut plugin is omitted from test builds: linking it into - // the lib-test binary makes it fail to load on Windows (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs. - #[cfg(not(test))] - let builder = builder.plugin({ - use tauri_plugin_global_shortcut::ShortcutState; - - // Generation counter for the release delay task. Incremented on - // every press — a delayed release only fires if the generation - // hasn't changed (i.e. no new press happened during the delay). - // This prevents press→release→press within 200 ms from having - // the first release clobber the second press. - let ptt_press_gen = Arc::new(std::sync::atomic::AtomicU64::new(0)); - - tauri_plugin_global_shortcut::Builder::new() - .with_handler(move |app, _shortcut, event| { - let state = match app.try_state::() { - Some(s) => s, - None => return, - }; - - // Only act if a huddle is active and mode is PTT. - let (is_ptt_mode, is_active) = match state.huddle_state.lock() { - Ok(hs) => ( - hs.voice_input_mode == huddle::VoiceInputMode::PushToTalk, - matches!( - hs.phase, - huddle::HuddlePhase::Connected | huddle::HuddlePhase::Active - ), - ), - Err(_) => return, - }; - - if !is_ptt_mode || !is_active { - return; - } - - match event.state { - ShortcutState::Pressed => { - // Bump generation — invalidates any pending release delay. - ptt_press_gen.fetch_add(1, std::sync::atomic::Ordering::Release); - - if let Ok(hs) = state.huddle_state.lock() { - hs.ptt_active - .store(true, std::sync::atomic::Ordering::Release); - // Only cancel TTS if it's actually playing — avoids - // a stale cancel flag that drops the next queued message. - if hs.tts_active.load(std::sync::atomic::Ordering::Acquire) { - hs.tts_cancel - .store(true, std::sync::atomic::Ordering::Release); - } - } - // Emit ptt-state=true to the frontend. - // The React side plays the press audio cue on this event - // (Web Audio API via HuddleContext). Rust-side rodio audio - // was considered but rejected: the rodio OutputStream must - // outlive the handler and sharing it across the shortcut - // closure adds lifecycle complexity for marginal gain. - // The React implementation is sufficient and simpler. - let _ = app.emit("ptt-state", true); - } - ShortcutState::Released => { - // Capture generation at release time. - let gen_at_release = - ptt_press_gen.load(std::sync::atomic::Ordering::Acquire); - let gen_arc = Arc::clone(&ptt_press_gen); - let app_handle = app.clone(); - // 200 ms release delay — captures the tail of the utterance. - // Only applies if no new press happened during the delay. - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - // Check generation — if it changed, a new press arrived. - if gen_arc.load(std::sync::atomic::Ordering::Acquire) != gen_at_release - { - return; // Superseded by a new press. - } - if let Some(state) = app_handle.try_state::() { - if let Ok(hs) = state.huddle_state.lock() { - hs.ptt_active - .store(false, std::sync::atomic::Ordering::Release); - } - } - // Emit ptt-state=false — React plays the release audio cue. - let _ = app_handle.emit("ptt-state", false); - }); - } - } - }) - .build() - }); + // The push-to-talk global-shortcut plugin lives in `ptt_shortcut`, next to + // the registration lifecycle it drives. Installing it is a no-op in test + // builds; see that module for why. + let builder = ptt_shortcut::install(builder); // Register the updater only in configured release builds; omit it locally. #[cfg(buzz_updater_enabled)] @@ -312,6 +231,9 @@ pub fn run() { .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) .manage(terminal_runtime::TerminalSessions::default()) + .manage(archive::sync::ArchiveSyncState::default()) + .manage(native_relay_client::NativeRelayClient::default()) + .manage(observed_unread::ObservedUnreadStore::default()) .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] @@ -371,13 +293,12 @@ pub fn run() { // present), all owner-keyed side effects (event sync, agent restore, // relay publish) are skipped. The frontend shows a recovery screen; // the user must relaunch after restoring the identity. - let identity_lost = state + let recovery_mode = state .identity_lost - .load(std::sync::atomic::Ordering::Acquire); - let keyring_locked = state - .keyring_locked - .load(std::sync::atomic::Ordering::Acquire); - let recovery_mode = identity_lost || keyring_locked; + .load(std::sync::atomic::Ordering::Acquire) + || state + .keyring_locked + .load(std::sync::atomic::Ordering::Acquire); // Backfill the pinned persona snapshot for any pre-existing agent // that predates the record-authoritative-spawn cutover (persona_id @@ -647,11 +568,14 @@ pub fn run() { get_user_notes, get_git_identity, get_project_repo_snapshot, + get_project_repo_file_content, get_project_repo_diff, get_project_local_repo_diff, get_project_local_repo_snapshot, + get_project_local_repo_file_content, get_project_repo_sync_status, list_project_local_repositories, + open_project_repository_folder, clone_project_repository, create_project_remote_branch, delete_project_remote_branch, @@ -694,6 +618,7 @@ pub fn run() { nip44_encrypt_to_self, nip44_decrypt_from_self, get_channels, + get_open_channel_directory, create_channel, ensure_starter_channels, open_dm, @@ -786,6 +711,7 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, + persist_agent_effort_level, get_global_agent_config, set_global_agent_config, mesh_start_node, @@ -797,6 +723,10 @@ pub fn run() { update_managed_agent, discover_backend_providers, probe_backend_provider, + persona_catalog::fetch_persona_catalog, + unread_catch_up::unread_catch_up, + observed_unread::observed_unread_open_scope, + observed_unread::observed_unread_ingest, list_personas, create_persona, update_persona, @@ -911,6 +841,9 @@ pub fn run() { archive::index_observer_channel_id, archive::read_unindexed_observer_rows, archive::get_agent_usage_series, + archive::sync::announce_archive_sync_epoch, + archive::sync::start_archive_sync, + archive::sync::stop_archive_sync, is_auto_update_supported, set_window_vibrancy, #[cfg(target_os = "macos")] diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index f70c714323..f0a4fabfed 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -223,6 +223,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 5b51c52255..4b734ce159 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -56,6 +56,13 @@ pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot"; /// this are stored as a URL reference instead. const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB +/// Maximum edge (px) for the PNG image body. The body is only a card +/// thumbnail — the manifest keeps the full-resolution source reference — so a +/// large avatar is downscaled here to keep the encoded snapshot well under +/// `MAX_SNAPSHOT_PNG_BYTES`. Mirrors the frontend SVG rasterizer's 512×512 cap +/// in `snapshotAvatarPng.ts`. +const MAX_PNG_BODY_EDGE: u32 = 512; + /// Format discriminator — used for sniffing and validation. pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot"; @@ -328,7 +335,7 @@ pub(crate) fn encode_chunk_payload_png( // there is no avatar or it cannot be decoded. let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { Some(bytes) => { - let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") && png_within_body_cap(bytes) { inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) }) @@ -449,6 +456,11 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result, S } /// Transcode a decodable avatar to PNG and add the snapshot manifest chunk. +/// +/// The decoded image is downscaled so its longest edge is at most +/// `MAX_PNG_BODY_EDGE` before PNG re-encoding. The body is only a card +/// thumbnail — this keeps a large source avatar (e.g. a 4K webp) from +/// producing a PNG that blows `MAX_SNAPSHOT_PNG_BYTES`. fn transcode_avatar_to_png_with_text( avatar_bytes: &[u8], keyword: &str, @@ -456,6 +468,7 @@ fn transcode_avatar_to_png_with_text( ) -> Result, String> { let image = image::load_from_memory(avatar_bytes) .map_err(|e| format!("Failed to decode avatar image: {e}"))?; + let image = downscale_to_body_cap(image); let mut png_bytes = Vec::new(); image .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) @@ -463,6 +476,32 @@ fn transcode_avatar_to_png_with_text( inject_text_chunk(&png_bytes, keyword, text) } +/// Downscale so the longest edge is at most `MAX_PNG_BODY_EDGE`, preserving +/// aspect ratio. Images already within the cap are returned untouched. +fn downscale_to_body_cap(image: image::DynamicImage) -> image::DynamicImage { + if image.width() <= MAX_PNG_BODY_EDGE && image.height() <= MAX_PNG_BODY_EDGE { + return image; + } + image.resize( + MAX_PNG_BODY_EDGE, + MAX_PNG_BODY_EDGE, + image::imageops::FilterType::Lanczos3, + ) +} + +/// Whether an already-PNG avatar is within the body dimension cap and can be +/// carried as-is (via a cheap tEXt-chunk injection) instead of being decoded +/// and downscaled. Undecodable headers fall through to the transcode path. +fn png_within_body_cap(png_bytes: &[u8]) -> bool { + Decoder::new(Cursor::new(png_bytes)) + .read_info() + .map(|reader| { + let info = reader.info(); + info.width <= MAX_PNG_BODY_EDGE && info.height <= MAX_PNG_BODY_EDGE + }) + .unwrap_or(false) +} + /// Inject a tEXt chunk into an existing PNG by re-encoding it. /// /// Re-decodes the image data via the `png` crate and writes a fresh PNG with 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 751452aa7b..de2f71577a 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -417,6 +417,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: 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 fca15111d0..9f234749bc 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -73,6 +73,7 @@ fn minimal_record() -> ManagedAgentRecord { definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, + effort_level: None, } } @@ -233,7 +234,60 @@ fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { assert_eq!((reader.info().width, reader.info().height), (3, 2)); } -// ── PNG memory parity ───────────────────────────────────────────────────── +#[test] +fn png_snapshot_downscales_oversize_avatar_under_cap() { + // A large avatar (mirrors Gurney's 2764×4096 image that encoded to ~26 MB) + // must be downscaled for the PNG body so the snapshot stays under the + // 10 MiB cap — while the manifest keeps the untouched source reference. + // An already-PNG oversize avatar exercises the `png_within_body_cap` guard + // that routes it through the downscaling transcode path. + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(2764, 4096, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8]) + })); + let mut source_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut source_bytes), image::ImageFormat::Png) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&source_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap(); + + assert!( + png_bytes.len() + <= super::MAX_PNG_BODY_EDGE as usize * super::MAX_PNG_BODY_EDGE as usize * 4, + "downscaled snapshot ({} bytes) must be far under the 10 MiB cap", + png_bytes.len() + ); + + let reader = Decoder::new(Cursor::new(png_bytes)).read_info().unwrap(); + let (width, height) = (reader.info().width, reader.info().height); + assert!( + width <= 512 && height <= 512, + "body dimensions {width}×{height} must fit the 512px cap" + ); + // Aspect ratio preserved: the longest edge (height) is clamped to the cap. + assert_eq!(height, 512, "longest edge should hit the 512px cap"); + + // The manifest keeps the untouched full-resolution source reference — only + // the PNG body is downscaled. The oversize source bytes exceed the inline + // cap, so the manifest falls back to the record's `avatar_url`. + let manifest = + decode_snapshot_png(&encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap()).unwrap(); + assert_eq!( + manifest.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png"), + "manifest must preserve the untouched source avatar reference" + ); + assert!( + manifest.profile.avatar_data_url.is_none(), + "oversize source bytes must not be inlined into the manifest" + ); +} #[test] fn png_round_trip_with_core_memory() { diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs new file mode 100644 index 0000000000..647ea56209 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -0,0 +1,53 @@ +//! Claude Code agent spawn-time env helpers. +//! +//! A1 contract: `ANTHROPIC_MODEL` is the single startup model authority for +//! local Claude Code agents. `BUZZ_ACP_MODEL` is removed from the spawned +//! env so the harness never sees two model authorities simultaneously. +//! +//! B5 contract: `BUZZ_ACP_EFFORT_LEVEL` is the canonical persisted startup +//! effort authority for all local agents. Written after `descriptor.env` so +//! user-supplied entries cannot shadow a persisted canonical value. + +/// The spawn-time env var carrying startup effort. Shared by the spawn +/// application ([`apply_effort_env`]) and the snapshot projection +/// (`spawn_snapshot::effective_effort`) so the value the harness receives and +/// the value the restart badge compares are named from one place. +pub const EFFORT_LEVEL_ENV_VAR: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +/// Apply the A1 model authority: inject `ANTHROPIC_MODEL` from `effective_model` +/// (or remove it if `None`) and strip `BUZZ_ACP_MODEL` from the spawned env. +/// +/// Must be called after `descriptor.env` is written so that any user-supplied +/// `ANTHROPIC_MODEL` is overridden by the Buzz-resolved value. +pub fn apply_claude_model_env(command: &mut std::process::Command, effective_model: Option<&str>) { + // Remove BUZZ_ACP_MODEL — the catalog-switch path is for live ACP switches + // only; at spawn time ANTHROPIC_MODEL is the sole authority. + command.env_remove("BUZZ_ACP_MODEL"); + match effective_model { + Some(m) => { + command.env("ANTHROPIC_MODEL", m); + } + None => { + command.env_remove("ANTHROPIC_MODEL"); + } + } +} + +/// Apply the B5 effort authority: inject `BUZZ_ACP_EFFORT_LEVEL` from +/// `effort_level` (or leave it untouched if `None`). +/// +/// Must be called after `descriptor.env` is written so the canonical persisted +/// value wins over any user-supplied `BUZZ_ACP_EFFORT_LEVEL` entry. When +/// `effort_level` is `None` there is no canonical value to assert; the command +/// env is left untouched so a user-supplied value from `descriptor.env` +/// legitimately seeds startup effort. +pub fn apply_effort_env(command: &mut std::process::Command, effort_level: Option<&str>) { + if let Some(e) = effort_level { + command.env(EFFORT_LEVEL_ENV_VAR, e); + } + // None: no canonical value — leave whatever descriptor.env wrote intact. +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs new file mode 100644 index 0000000000..f6f0f90cb2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -0,0 +1,127 @@ +use super::{apply_claude_model_env, apply_effort_env}; + +/// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after +/// `apply_claude_model_env`, even if it was set before (dual-authority defect). +/// ANTHROPIC_MODEL must be set to the resolved model. +#[test] +fn a1_buzz_acp_model_absent_anthropic_model_present_after_env_apply() { + let mut cmd = std::process::Command::new("true"); + // Simulate descriptor.env writing BUZZ_ACP_MODEL (the pre-A1 path). + cmd.env("BUZZ_ACP_MODEL", "claude-opus-4"); + apply_claude_model_env(&mut cmd, Some("claude-opus-4")); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + + // BUZZ_ACP_MODEL must be removed. Command::get_envs returns None for + // explicitly-removed keys. + let buzz_acp = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_MODEL")); + assert!( + buzz_acp.is_none() || buzz_acp.unwrap().is_none(), + "BUZZ_ACP_MODEL must be absent (or explicitly removed) after A1 policy" + ); + + // ANTHROPIC_MODEL must be set to the resolved model value. + let anthropic = env_map.get(std::ffi::OsStr::new("ANTHROPIC_MODEL")); + assert!(anthropic.is_some(), "ANTHROPIC_MODEL must be present"); + assert_eq!( + anthropic.unwrap().unwrap_or_default(), + "claude-opus-4", + "ANTHROPIC_MODEL must equal the effective model" + ); +} + +/// A1: when no model is resolved, ANTHROPIC_MODEL must be removed so Claude +/// uses its own default rather than inheriting a stale env value. +#[test] +fn a1_anthropic_model_removed_when_no_effective_model() { + let mut cmd = std::process::Command::new("true"); + // Pre-set a stale value that might have leaked in. + cmd.env("ANTHROPIC_MODEL", "claude-3-5-sonnet"); + cmd.env("BUZZ_ACP_MODEL", "claude-3-5-sonnet"); + apply_claude_model_env(&mut cmd, None); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + + let anthropic = env_map.get(std::ffi::OsStr::new("ANTHROPIC_MODEL")); + assert!( + anthropic.is_none() || anthropic.unwrap().is_none(), + "ANTHROPIC_MODEL must be absent when no effective model" + ); + let buzz_acp = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_MODEL")); + assert!( + buzz_acp.is_none() || buzz_acp.unwrap().is_none(), + "BUZZ_ACP_MODEL must always be absent after A1 policy" + ); +} + +// ── B5 effort-authority contract tests ────────────────────────────────────── +// +// These tests verify that `apply_effort_env`, called after `descriptor.env`, +// makes the canonical persisted effort win over any user-supplied value. + +/// B5 (local): canonical effort wins when user env supplies a conflicting value. +/// Simulates the defect scenario: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low, +/// then apply_effort_env is called with the canonical "high". The canonical value +/// must be what survives in the spawned-child env. +#[test] +fn b5_canonical_effort_wins_over_user_env_collision() { + let mut cmd = std::process::Command::new("true"); + // Simulate descriptor.env writing a user-supplied value (the pre-fix + // ordering: effort written before the loop, then loop overwrote it, or + // equivalently: effort written post-loop but with user value also post-loop). + cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); + + // Post-loop canonical application — the fix. + apply_effort_env(&mut cmd, Some("high")); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); + assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); + assert_eq!( + effort.unwrap().unwrap_or_default(), + "high", + "canonical effort must win over the user-supplied 'low' — B5 authority ordering" + ); +} + +/// B5 (local): when no canonical effort is persisted (effort_level is None), +/// user env passthrough is preserved — the descriptor.env entry seeds startup effort. +/// Simulates: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low (already in command), +/// then apply_effort_env(None) is called — user value must survive. +#[test] +fn b5_user_effort_env_survives_when_no_canonical_value() { + let mut cmd = std::process::Command::new("true"); + // Simulate descriptor.env loop having written a user-supplied value first. + cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); + + // No canonical value — apply_effort_env(None) is a no-op so the user + // value already written by the descriptor.env loop survives intact. + apply_effort_env(&mut cmd, None); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); + assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); + assert_eq!( + effort.unwrap().unwrap_or_default(), + "low", + "user-supplied effort must survive when no canonical value is persisted" + ); +} + +/// B5 (local): canonical effort is present in the spawned env even when user +/// env did NOT supply a conflicting value (basic injection contract). +#[test] +fn b5_canonical_effort_injected_when_no_user_collision() { + let mut cmd = std::process::Command::new("true"); + // No user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env. + apply_effort_env(&mut cmd, Some("medium")); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); + assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); + assert_eq!( + effort.unwrap().unwrap_or_default(), + "medium", + "canonical effort must be injected when no collision" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs index 449197a3b3..b54297df80 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -1,10 +1,28 @@ use super::types::{ExtensionEntry, RuntimeFileConfig}; -/// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`. -pub(super) fn read_config_file() -> Option { +/// Read Claude Code config from `settings.json` and `.claude.json`. +/// +/// `config_dir` — when `Some`, reads both `settings.json` and `.claude.json` +/// from that directory (the agent's effective `CLAUDE_CONFIG_DIR`). +/// Defaults to `~/.claude/settings.json` and `~/.claude.json` when `None`. +/// +/// Both files are resolved from the same directory: the claude 2.1.x binary +/// resolves `.claude.json` as `join(process.env.CLAUDE_CONFIG_DIR || homedir(), +/// ".claude.json")`, mirroring the `settings.json` resolver. A user-set +/// `CLAUDE_CONFIG_DIR` therefore remaps both files — honoring only +/// `settings.json` would misrepresent the agent's actual MCP config. +pub(super) fn read_config_file(config_dir: Option<&std::path::Path>) -> Option { let home = dirs::home_dir()?; - let settings_path = home.join(".claude").join("settings.json"); - let mcp_path = home.join(".claude.json"); + + // #3493: honor user-set CLAUDE_CONFIG_DIR for both settings.json and + // .claude.json — the binary resolves both relative to CLAUDE_CONFIG_DIR. + // Panel reflects the actual config the agent reads. + let settings_path = config_dir + .map(|d| d.join("settings.json")) + .unwrap_or_else(|| home.join(".claude").join("settings.json")); + let mcp_path = config_dir + .map(|d| d.join(".claude.json")) + .unwrap_or_else(|| home.join(".claude.json")); let settings = read_json_file(&settings_path); let mcp_config = read_json_file(&mcp_path); @@ -74,6 +92,22 @@ mod tests { } } + /// #3493: read_config_file(Some(dir)) must read settings.json from the + /// custom dir, not ~/.claude/settings.json — proves CLAUDE_CONFIG_DIR + /// actually remaps the settings read (not just the reported MCP path). + #[test] + fn reads_settings_from_custom_config_dir() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let mut f = std::fs::File::create(dir.path().join("settings.json")).unwrap(); + f.write_all(br#"{"model": "claude-opus-4", "effortLevel": "high"}"#) + .unwrap(); + + let cfg = read_config_file(Some(dir.path())).expect("settings.json in custom dir is read"); + assert_eq!(cfg.model.as_deref(), Some("claude-opus-4")); + assert_eq!(cfg.thinking_effort.as_deref(), Some("high")); + } + #[test] fn parse_model_from_settings() { let cfg = parse_settings(r#"{"model": "claude-sonnet-4-20250514"}"#); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index c51f325cf3..93827635e9 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -9,11 +9,16 @@ use super::types::*; /// persona and global tiers assembled at the command boundary. Each field /// builder constructs its own candidate list and resolves via /// `resolve_with_override`. +/// +/// `claude_config_dir` — when `Some`, the panel reads claude `settings.json` +/// and `.claude.json` from that directory (the agent's effective +/// `CLAUDE_CONFIG_DIR`) instead of `~/.claude/`. Ignored for non-claude runtimes. pub(crate) fn read_config_surface( record: &ManagedAgentRecord, runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, tiers: &InheritedConfigTiers, + claude_config_dir: Option<&std::path::Path>, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); @@ -22,7 +27,7 @@ pub(crate) fn read_config_surface( .map(|m| m.id) .and_then(|id| match id { "goose" => super::goose::read_config_file().map(|c| (c, true)), - "claude" => super::claude::read_config_file().map(|c| (c, true)), + "claude" => super::claude::read_config_file(claude_config_dir).map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), _ => None, @@ -49,7 +54,14 @@ pub(crate) fn read_config_surface( .or_else(|| find_config_option_value(c, "model")) }); let acp_mode = session_cache.and_then(|c| find_config_option_value(c, "mode")); - let acp_effort = session_cache.and_then(|c| find_config_option_value(c, "effort")); + + // B5: the adapter-advertised effort control, selected ONCE by its category. + // The adapter defines it as category `thought_level` with its own config id + // (Claude Code emits `id="effort"`); reading by the literal category `effort` + // would miss it entirely. The running value, the write config id, and the + // picker options all derive from this single entry. + let effort_option = session_cache.and_then(find_effort_option); + let acp_effort = effort_option.and_then(|o| o.current_value.clone()); let model_overridden = session_cache.is_some_and(|c| c.model_overridden); @@ -79,9 +91,9 @@ pub(crate) fn read_config_surface( record, &file_config.thinking_effort, &acp_effort, + effort_option.map(|o| o.config_id.as_str()), thinking_env_var, is_pre_spawn, - session_cache, tiers, ), max_output_tokens: build_numeric_env_field( @@ -145,10 +157,9 @@ pub(crate) fn read_config_surface( }); } - let config_file_path = runtime_meta - .and_then(|m| m.config_file_path) - .map(resolve_tilde); - let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime); + let config_file_path = config_file_path_for_runtime(runtime_meta, claude_config_dir); + let mcp_config_file_path = + runtime_meta.and_then(|m| mcp_config_file_path_for_runtime(m, claude_config_dir)); let extensions = file_config.extensions.clone(); let sources = ConfigSourceReport { @@ -181,6 +192,12 @@ pub(crate) fn read_config_surface( mcp_config_file_path, }; + // B5: the adapter-advertised effort control, discovered once above. The UI + // uses `effort_config_id` to send `set_config_option` and renders + // `effort_options` instead of hardcoded values (never hardcoded here). + let effort_config_id = effort_option.map(|o| o.config_id.clone()); + let effort_options = effort_option.map(|o| o.options.clone()).unwrap_or_default(); + RuntimeConfigSurface { runtime_id: runtime_meta.map(|m| m.id.to_string()), runtime_label: runtime_meta.map(|m| m.label.to_string()), @@ -189,15 +206,52 @@ pub(crate) fn read_config_surface( advanced, extensions, sources, + claude_config_dir_custom: claude_config_dir.is_some(), + effort_config_id, + effort_options, } } -fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option { +/// Resolve the reported `settings.json` path. #3493: for a claude agent with a +/// custom `CLAUDE_CONFIG_DIR`, the reader reads `/settings.json`, so the +/// reported path must point there — not the static `~/.claude/settings.json` +/// from the runtime metadata. All other runtimes (and claude with no custom +/// dir) use the static metadata path. +fn config_file_path_for_runtime( + runtime_meta: Option<&KnownAcpRuntime>, + claude_config_dir: Option<&std::path::Path>, +) -> Option { + let runtime = runtime_meta?; + if runtime.id == "claude" { + if let Some(dir) = claude_config_dir { + return Some(dir.join("settings.json").to_string_lossy().into_owned()); + } + } + runtime.config_file_path.map(resolve_tilde) +} + +fn mcp_config_file_path_for_runtime( + runtime: &KnownAcpRuntime, + claude_config_dir: Option<&std::path::Path>, +) -> Option { match runtime.id { "goose" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } - "claude" => Some(resolve_tilde("~/.claude.json")), + // #3493: the claude 2.1.x binary resolves .claude.json as + // join(CLAUDE_CONFIG_DIR || homedir(), ".claude.json"), so the MCP + // config file moves with a user-set CLAUDE_CONFIG_DIR. + "claude" => Some( + claude_config_dir + .map(|d| d.join(".claude.json")) + .unwrap_or_else(|| { + dirs::home_dir() + .map(|h| h.join(".claude.json")) + .unwrap_or_default() + }) + .to_string_lossy() + .into_owned(), + ), "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } @@ -486,12 +540,20 @@ fn build_thinking_field( record: &ManagedAgentRecord, file_effort: &Option, acp_effort: &Option, + effort_config_id: Option<&str>, thinking_env_var: Option<&str>, is_pre_spawn: bool, - session_cache: Option<&SessionConfigCache>, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: record env > ACP > persona env > global env > definition env > config file. + // Tier ordering: + // record env > record.effort_level (canonical Buzz-persisted) > ACP > + // persona env > global env > definition env > config file. + // + // `record.effort_level` is the B5 canonical value: the effort a spawn will + // actually apply at next session start (via `apply_effort_env`). Sitting it + // above ACP means the panel shows the *configured* value the agent will + // launch with rather than a stale live-session reading — the record can't + // be masked by, nor mask, the running value silently. let [rec_env, pers_env, glob_env, def_env] = thinking_env_var .map(|k| { env_candidates( @@ -504,8 +566,11 @@ fn build_thinking_field( }) .unwrap_or([None, None, None, None]); + let canonical_effort = record.effort_level.as_deref(); + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ (rec_env, ConfigOrigin::BuzzExplicit), + (canonical_effort, ConfigOrigin::BuzzExplicit), (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), (pers_env, ConfigOrigin::PersonaDefault), (glob_env, ConfigOrigin::GlobalDefault), @@ -514,16 +579,14 @@ fn build_thinking_field( ]; let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; - let write_via = if !is_pre_spawn && has_config_option(session_cache, "effort") { - ConfigWriteMechanism::AcpSetConfigOption { - config_id: "effort".to_string(), - } - } else if let Some(env_key) = thinking_env_var { - ConfigWriteMechanism::RespawnWithEnvVar { + let write_via = match (is_pre_spawn, effort_config_id, thinking_env_var) { + (false, Some(config_id), _) => ConfigWriteMechanism::AcpSetConfigOption { + config_id: config_id.to_string(), + }, + (_, _, Some(env_key)) => ConfigWriteMechanism::RespawnWithEnvVar { env_key: env_key.to_string(), - } - } else { - ConfigWriteMechanism::ReadOnly + }, + _ => ConfigWriteMechanism::ReadOnly, }; Some(NormalizedField { @@ -677,6 +740,19 @@ fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Optio .and_then(|o| o.current_value.clone()) } +/// Selects the adapter-advertised effort control from the session cache. +/// +/// The adapter emits effort under category `thought_level` with its own +/// config id (Claude Code uses `id="effort"`). Selecting by category — not by +/// a hardcoded id — is what lets the running value, the write config id, and +/// the picker options all derive from one entry. +fn find_effort_option(cache: &SessionConfigCache) -> Option<&AcpConfigOptionEntry> { + cache + .config_options + .iter() + .find(|o| o.category.as_deref() == Some("thought_level")) +} + fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool { cache.is_some_and(|c| { c.config_options 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 0e7070724d..36b6022b53 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 @@ -116,6 +116,7 @@ fn test_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, @@ -168,7 +169,7 @@ fn persona_and_global_env_tiers( fn pre_spawn_surface_reports_pending_acp_tiers() { let record = test_record(); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!(surface.is_pre_spawn); assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending); @@ -184,7 +185,7 @@ fn surface_reports_mcp_specific_config_path() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(None, || { - read_config_surface(&record, Some(runtime), None, &no_tiers()) + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) }); let path = surface @@ -203,7 +204,7 @@ fn goose_mcp_config_path_follows_path_root_override() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || { - read_config_surface(&record, Some(runtime), None, &no_tiers()) + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) }); let expected_path = Path::new("/tmp/buzz-goose-root") @@ -227,7 +228,7 @@ fn claude_surface_uses_mcp_config_path_not_settings_path() { config_file_path: Some("~/.claude/settings.json"), ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!(surface .sources @@ -247,7 +248,7 @@ fn record_model_overrides_file_model() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -260,7 +261,7 @@ fn provider_locked_shows_locked() { provider_locked: true, ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)")); assert_eq!(provider.origin, ConfigOrigin::HarnessConstraint); @@ -286,7 +287,7 @@ fn post_spawn_with_model_config_option_uses_acp() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); assert!(!surface.is_pre_spawn); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("claude-opus-4")); @@ -310,7 +311,7 @@ fn acp_model_overrides_file_model_with_override_tracking() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("acp-model")); assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); @@ -331,7 +332,7 @@ fn persona_model_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("persona-model")); @@ -347,7 +348,7 @@ fn global_model_tier_produces_global_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -363,7 +364,7 @@ fn persona_provider_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("anthropic")); @@ -379,7 +380,7 @@ fn persona_prompt_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!( @@ -416,7 +417,7 @@ fn runtime_override_wins_display_when_model_overridden_is_true() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // Override wins the display value with a runtime-override origin. @@ -448,7 +449,7 @@ fn no_runtime_override_when_model_overridden_is_false() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // model_overridden is false => the override branch is not taken. @@ -480,7 +481,7 @@ fn no_false_positive_override_when_persona_edited_mid_life() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // model_overridden is false => no RuntimeOverride, even though @@ -539,7 +540,7 @@ fn explicit_record_model_not_retagged_when_already_present() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); @@ -562,7 +563,7 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { .insert("SPROUT_ACP_MEMORY".to_string(), "mem-value".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -601,7 +602,7 @@ fn extra_env_var_skipped_when_already_in_file_config_extra() { .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -662,7 +663,7 @@ fn buzz_agent_max_output_tokens_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -683,7 +684,7 @@ fn buzz_agent_context_limit_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("100000")); @@ -701,7 +702,7 @@ fn buzz_agent_max_tokens_absent_when_no_env_var_or_file() { let record = test_record(); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!( surface.normalized.max_output_tokens.is_none(), @@ -726,7 +727,7 @@ fn buzz_agent_max_tokens_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -747,7 +748,7 @@ fn buzz_agent_thinking_effort_from_env_is_buzz_explicit() { .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.thinking_effort.unwrap(); assert_eq!(field.value.as_deref(), Some("high")); @@ -768,7 +769,7 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -830,7 +831,7 @@ fn global_effort_surfaces_as_global_default_when_record_has_none() { let runtime = buzz_agent_rt(); let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -847,7 +848,7 @@ fn persona_effort_shadows_global_and_tags_persona_default() { let runtime = buzz_agent_rt(); let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -871,7 +872,7 @@ fn record_effort_outranks_persona_and_global_keeps_buzz_explicit() { let runtime = buzz_agent_rt(); let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -887,7 +888,7 @@ fn no_effort_anywhere_yields_no_thinking_effort_field() { let record = test_record(); let runtime = buzz_agent_rt(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!( surface.normalized.thinking_effort.is_none(), @@ -897,6 +898,9 @@ fn no_effort_anywhere_yields_no_thinking_effort_field() { /// AC-5 (conflicting-ACP): inherited effort set (global=high) + live ACP effort=low /// → ACP wins as primary (AcpConfigOption), global is the overridden secondary. +/// +/// The ACP entry uses the real adapter shape: category `thought_level` with an +/// adapter-defined config id (`effort`), NOT category `effort`. #[test] fn acp_effort_wins_over_inherited_global_effort_as_secondary() { let record = test_record(); @@ -904,7 +908,7 @@ fn acp_effort_wins_over_inherited_global_effort_as_secondary() { let cache = SessionConfigCache { config_options: vec![AcpConfigOptionEntry { config_id: "effort".to_string(), - category: Some("effort".to_string()), + category: Some("thought_level".to_string()), display_name: Some("Effort".to_string()), current_value: Some("low".to_string()), options: vec![], @@ -918,7 +922,7 @@ fn acp_effort_wins_over_inherited_global_effort_as_secondary() { }; let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let effort = surface .normalized @@ -942,7 +946,7 @@ fn numeric_max_tokens_inherits_from_global_env() { let runtime = buzz_agent_runtime(); let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("16384")); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 8613124f25..f86793f91a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -16,7 +16,7 @@ fn numeric_context_limit_inherits_from_persona_env() { let runtime = buzz_agent_runtime(); let tiers = persona_env_tiers("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("200000")); @@ -33,7 +33,7 @@ fn record_max_tokens_overrides_global_env_with_secondary() { let runtime = buzz_agent_runtime(); let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -64,7 +64,7 @@ fn global_env_prompt_wins_over_persona_structured_prompt() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!(prompt.value.as_deref(), Some("global-env-prompt")); @@ -87,7 +87,7 @@ fn persona_env_model_wins_over_persona_structured_model() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); // persona env outranks persona struct because env candidates precede struct @@ -106,7 +106,7 @@ fn structured_fallback_intact_when_no_env_representation() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("struct-persona-model")); @@ -130,7 +130,7 @@ fn post_sanitization_empty_global_env_falls_through_to_persona_tier() { // No global env (stripped); persona provides the valid fallback. let tiers = persona_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); // Persona value surfaces instead of the stripped global value. let effort = surface.normalized.thinking_effort.unwrap(); @@ -157,7 +157,7 @@ fn record_env_prompt_wins_over_record_struct_prompt_as_buzz_explicit() { ); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!(prompt.value.as_deref(), Some("env-prompt-B")); @@ -189,7 +189,7 @@ fn definition_env_beats_structured_persona_model() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("harness-model")); @@ -222,7 +222,7 @@ fn global_env_beats_definition_env() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -249,10 +249,272 @@ fn reserved_key_absent_from_definition_env_falls_through() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); // Falls through to persona structured model. assert_eq!(model.value.as_deref(), Some("persona-struct-model")); assert_eq!(model.origin, ConfigOrigin::PersonaDefault); } + +// ── B4/B5 canonical effort_level tier tests ──────────────────────────────── +// +// record.effort_level is the Buzz-canonical seeded value (the effort a spawn +// applies at next session start via `apply_effort_env`). It must surface as +// BuzzExplicit and take precedence over the config-file tier, but not over a +// record env var override. + +/// B4: record.effort_level surfaces as BuzzExplicit when no env var is set. +#[test] +fn b4_canonical_effort_level_surfaces_as_buzz_explicit() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from canonical record tier"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: record.effort_level shadows the config-file tier. +#[test] +fn b4_canonical_effort_level_shadows_file_tier() { + let mut record = test_record(); + record.effort_level = Some("medium".to_string()); + // No env var set — the config-file tier would win if canonical were absent. + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("canonical effort must shadow file tier"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: a record env var override still wins over record.effort_level, which +/// becomes the overridden baseline. +#[test] +fn b4_record_env_var_wins_over_canonical_effort_level() { + let mut record = test_record(); + record.effort_level = Some("low".to_string()); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("env var must win over canonical effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!(effort.overridden_value.as_deref(), Some("low")); +} + +/// B4: None effort_level does not introduce a spurious tier. +#[test] +fn b4_none_canonical_effort_does_not_surface() { + let record = test_record(); // effort_level defaults to None + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + assert!( + surface.normalized.thinking_effort.is_none(), + "effort field must be absent when no tier has a value" + ); +} + +// ── CLAUDE_CONFIG_DIR path resolution (#3493) ───────────────────────────────── + +#[test] +fn claude_mcp_config_path_honors_custom_claude_config_dir() { + // #3493: mcp_config_file_path_for_runtime must use the custom dir when + // claude_config_dir is Some, not fall back to ~/.claude.json. + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let custom_dir = std::path::PathBuf::from("/custom/config/dir"); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), Some(&custom_dir)); + + let mcp_path = surface + .sources + .mcp_config_file_path + .expect("mcp_config_file_path must be present for claude runtime"); + assert_eq!( + std::path::Path::new(&mcp_path), + custom_dir.join(".claude.json"), + "mcp config path must be /.claude.json when CLAUDE_CONFIG_DIR is set" + ); + assert!( + surface.claude_config_dir_custom, + "claude_config_dir_custom must be true when a custom dir was passed" + ); +} + +#[test] +fn claude_config_dir_none_falls_back_to_home_claude_json() { + // #3493: None (i.e. the caller stripped an empty string) must resolve to + // the default ~/.claude.json path, matching Claude's `CLAUDE_CONFIG_DIR || homedir()`. + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + assert!( + !surface.claude_config_dir_custom, + "claude_config_dir_custom must be false when dir is None (unset)" + ); + assert!( + surface + .sources + .mcp_config_file_path + .as_deref() + .is_some_and(|p| p.ends_with(".claude.json")), + "mcp path must fall back to ~/.claude.json when no custom dir" + ); +} + +/// F1 regression: the effort control is selected by its `thought_level` category, +/// and the running value, the write config id, and the picker options all derive +/// from that single entry — even when the adapter's config id is a nonliteral +/// value and differs from the canonical (configured) effort. +/// +/// Live shape: `id="thinking-level", category="thought_level", currentValue="default"` +/// while canonical `record.effort_level=high`. Both facts must render: configured +/// `high` as the value and running `default` as the overridden secondary; the +/// write mechanism must carry the adapter's real id, never a hardcoded `"effort"`. +#[test] +fn effort_option_selected_by_category_drives_all_facts() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = buzz_agent_rt(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking-level".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Thinking level".to_string()), + current_value: Some("default".to_string()), + options: vec![ + AcpConfigOptionValue { + value: "default".to_string(), + display_name: Some("Default".to_string()), + }, + AcpConfigOptionValue { + value: "high".to_string(), + display_name: Some("High".to_string()), + }, + ], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = InheritedConfigTiers::default(); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + + // Two-facts display: configured `high` wins, running `default` is the secondary. + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface with both configured and running facts"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!(effort.overridden_value.as_deref(), Some("default")); + assert_eq!( + effort.overridden_origin, + Some(ConfigOrigin::AcpConfigOption) + ); + + // Write mechanism carries the adapter's real id, never a hardcoded "effort". + match &effort.write_via { + ConfigWriteMechanism::AcpSetConfigOption { config_id } => { + assert_eq!(config_id, "thinking-level"); + } + other => panic!("expected AcpSetConfigOption with adapter id, got {other:?}"), + } + + // Picker metadata derives from the same entry. + assert_eq!(surface.effort_config_id.as_deref(), Some("thinking-level")); + assert_eq!( + surface + .effort_options + .iter() + .map(|o| o.value.as_str()) + .collect::>(), + vec!["default", "high"], + ); +} + +// ── #3493: config_file_path follows a custom CLAUDE_CONFIG_DIR ───────────────── + +#[test] +fn claude_custom_config_dir_reports_isolated_settings_path() { + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let custom = std::path::Path::new("/tmp/iso-config"); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), Some(custom)); + + // The reported settings path is rooted at the custom dir the reader used, + // not the static ~/.claude/settings.json metadata. Compare as paths so the + // separator is native (Windows joins with `\`, not `/`). + assert_eq!( + surface + .sources + .config_file_path + .as_deref() + .map(std::path::Path::new), + Some(custom.join("settings.json").as_path()), + ); + // And the MCP file attribution follows the same custom root. + assert_eq!( + surface + .sources + .mcp_config_file_path + .as_deref() + .map(std::path::Path::new), + Some(custom.join(".claude.json").as_path()), + ); +} + +#[test] +fn claude_default_config_dir_reports_static_settings_path() { + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + + // With no custom dir, the settings path resolves the static tilde metadata. + // Compare the trailing components as a path so the check is separator-native. + assert!(surface + .sources + .config_file_path + .as_deref() + .map(std::path::Path::new) + .is_some_and(|p| p.ends_with(".claude/settings.json"))); + assert!(surface + .sources + .config_file_path + .as_deref() + .is_some_and(|p| !p.starts_with('~'))); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 6ca2592538..d96736fb69 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -76,8 +76,21 @@ pub enum ConfigOrigin { } /// How a config field can be written back to the runtime. +/// +/// `rename_all_fields` is load-bearing, not decoration: on an internally +/// tagged enum `rename_all` renames the *variants*, never the variants' +/// fields, so without it `RespawnWithEnvVar` serializes as +/// `{"type":"respawnWithEnvVar","env_key":"…"}` while +/// `desktop/src/shared/api/types.ts` declares `envKey`. `invokeTauri` is an +/// unchecked cast, so `tsc` cannot see the mismatch — the reader just gets +/// `undefined`. `wire_format_matches_typescript_contract` below pins the exact +/// bytes. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum ConfigWriteMechanism { /// Update record env vars, save, stop + restart agent. RespawnWithEnvVar { env_key: String }, @@ -175,6 +188,25 @@ pub struct RuntimeConfigSurface { pub advanced: Vec, pub extensions: Vec, pub sources: ConfigSourceReport, + /// #3493: `true` when the panel is reading from a user-set `CLAUDE_CONFIG_DIR` + /// rather than the default `~/.claude/`. Used to show the Keychain caveat + /// note in the panel: a custom config dir means a fresh Keychain namespace + /// (hash-suffixed), so the agent will be logged out unless the user also + /// manages `CLAUDE_SECURESTORAGE_CONFIG_DIR`. + #[serde(default)] + pub claude_config_dir_custom: bool, + /// B5: the real `configId` for the `thought_level` ACP config option, + /// as advertised by the adapter in `session/new`. Present only for claude + /// runtimes after the first session is created. The UI uses this to send + /// `set_config_option` without hardcoding the configId. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_config_id: Option, + /// B5/I-7: the adapter-advertised option values for the `thought_level` + /// config option. Present when `effort_config_id` is Some. The UI renders + /// these instead of hardcoded low/medium/high so model-specific option sets + /// are reflected correctly. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effort_options: Vec, } /// Raw config values extracted from a runtime's config file. @@ -244,3 +276,105 @@ pub struct AcpModelEntry { pub name: Option, pub description: Option, } + +#[cfg(test)] +mod wire_format_tests { + use super::*; + use serde_json::json; + + /// Every `ConfigWriteMechanism` variant, as `desktop/src/shared/api/types.ts` + /// declares it. Whole-value comparison, not a key-set check: a key-set + /// assertion still passes if the variant *name* regresses, and the `type` + /// discriminant is what every `switch (writeVia.type)` reads. Compared as + /// `serde_json::Value` rather than as text, because JSON object order is + /// not semantic and the contract is the keys and values, not the encoder's + /// field order. + #[test] + fn wire_format_matches_typescript_contract() { + let cases = [ + ( + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + }, + json!({"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}), + ), + ( + ConfigWriteMechanism::AcpSetConfigOption { + config_id: "model".into(), + }, + json!({"type": "acpSetConfigOption", "configId": "model"}), + ), + ( + ConfigWriteMechanism::AcpSetSessionModel, + json!({"type": "acpSetSessionModel"}), + ), + ( + ConfigWriteMechanism::GooseNativeConfigWrite { + config_key: "goose.model".into(), + }, + json!({"type": "gooseNativeConfigWrite", "configKey": "goose.model"}), + ), + (ConfigWriteMechanism::ReadOnly, json!({"type": "readOnly"})), + ]; + for (mechanism, expected) in cases { + assert_eq!( + serde_json::to_value(&mechanism).expect("serialize"), + expected + ); + } + } + + /// The renderer never sees a bare mechanism — it arrives nested inside + /// `NormalizedField`, which is where the mismatch used to hide: the + /// enclosing struct's `writeVia` / `overriddenValue` / `isRequired` all + /// renamed correctly, so only the variant's own field was snake_case. + #[test] + fn nested_field_is_camel_case_all_the_way_down() { + let field = NormalizedField { + value: Some("v".into()), + origin: ConfigOrigin::EnvVar, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + }, + overridden_value: Some("o".into()), + overridden_origin: Some(ConfigOrigin::ConfigFile), + is_required: true, + }; + assert_eq!( + serde_json::to_value(&field).expect("serialize"), + json!({ + "value": "v", + "origin": "envVar", + "writeVia": {"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}, + "overriddenValue": "o", + "overriddenOrigin": "configFile", + "isRequired": true, + }) + ); + } + + /// The contract is singular: the shape the renderer sends back round-trips, + /// and the old snake_case spelling is no longer accepted. Without the + /// second half, a future revert would still deserialize and the read path + /// would look healthy. + #[test] + fn camel_case_round_trips_and_snake_case_is_rejected() { + let parsed: ConfigWriteMechanism = + serde_json::from_str(r#"{"type":"respawnWithEnvVar","envKey":"GOOSE_MODE"}"#) + .expect("the TypeScript shape must deserialize"); + assert_eq!( + parsed, + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + } + ); + + assert!( + serde_json::from_str::( + r#"{"type":"respawnWithEnvVar","env_key":"GOOSE_MODE"}"# + ) + .is_err(), + "the pre-fix snake_case spelling must not be accepted" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index e53c9114ab..f7e233fbe9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -284,13 +284,13 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } #[test] fn record_agent_command_own_runtime_wins_over_persona() { - // A record with its own materialized runtime never consults the - // persona list — the unified-model resolution. + // A record with its own runtime never consults the persona list. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let record = record_with(Some("claude"), Some("p1"), None); assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp"); 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 ee18e554c3..5b048b815c 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -89,6 +89,7 @@ fn record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], 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 b2a56870c7..65cde47f26 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -349,6 +349,7 @@ fn bare_record() -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 16234aa3d6..272c03348b 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -9,6 +9,7 @@ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; mod backend; +pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; mod definition_validation; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a9..72cf466427 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -11,10 +11,12 @@ use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRec #[cfg(test)] use super::{BackendKind, RespondTo}; use crate::app_state::AppState; -use crate::relay::relay_ws_url_with_override; +use crate::commands::{capture_relay_target, fetch_archived_pubkeys_at}; +use std::collections::HashSet; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use tauri::{AppHandle, Manager}; use crate::managed_agents::discovery::known_skill_dirs; @@ -523,19 +525,35 @@ fn escape_md_cell(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") } +/// True iff the relay has archived this instance's identity. Membership is +/// tested against the relay's `kind:13535` snapshot (lowercased hex); an empty +/// set (relay unreachable) fails open — see [`regenerate_nest_context`]. +fn is_archived(record: &ManagedAgentRecord, archived: &HashSet) -> bool { + archived.contains(&record.pubkey.to_ascii_lowercase()) +} + pub fn render_dynamic_section( personas: &[AgentDefinition], agents: &[ManagedAgentRecord], + archived: &HashSet, relay_url: &str, ) -> String { - let active_agents = if agents.is_empty() { + // Every managed agent is eligible on every community — `relay_url` is a + // legacy creation-era field that `effective_agent_relay_url()` deliberately + // ignores, and snapshot-imported records store it empty by design. The only + // roster filter is identity-archive. + let live: Vec<&ManagedAgentRecord> = agents + .iter() + .filter(|a| !is_archived(a, archived)) + .collect(); + let active_agents = if live.is_empty() { "## Active Agents\n\n*(No agents deployed yet. Add agents in the Buzz desktop app.)*" .to_string() } else { let mut table = "## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|" .to_string(); - for agent in agents { + for agent in live { let role = agent .persona_id .as_deref() @@ -645,7 +663,124 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io Ok(()) } -pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { +/// Serializes nest-context writes so a slow, stale regeneration cannot roll the +/// file back over a newer one. This is an ordered, latest-request-wins gate — +/// not a work coalescer: every superseded generation still performs its relay +/// reads, then drops its result at commit time. Adding a true dirty-loop owner +/// would be a larger change and is unwarranted at this user-driven trigger rate. +/// +/// Each regeneration request claims a monotonic generation *synchronously* at +/// request time (see [`NestRegenGate::claim`]), so the generation encodes +/// program order: boot's regen is claimed before `apply_workspace`'s, an edit's +/// regen before the next edit's. The claimed generation travels with the +/// spawned task and gates its write in [`NestRegenGate::commit`]: a task drops +/// its result once a *newer generation has been requested*, even if that newer +/// generation later fails before it writes. Gating on the highest *requested* +/// generation — not the highest *written* one — is what stops a slow, stale +/// pre-edit render from publishing after a newer post-edit render was claimed +/// and then failed during its relay work (which would otherwise leave the +/// obsolete roster authoritative until the next unrelated trigger). Declared +/// semantic: once a newer regeneration is requested, no older one publishes; +/// if that newer one fails, the file simply waits for the next trigger. +/// +/// `claim` and `commit` share one lock, so the "is this still the newest +/// request?" compare is atomic with the synchronous file write. A bare atomic +/// watermark checked separately from the write would let a new claim slip +/// between an older task's eligibility check and its write; holding the lock +/// across both closes that window (no `await` occurs while it is held). +struct NestRegenGate { + /// Highest generation *requested* so far (`0` = none yet). Advanced by + /// [`claim`] and read by [`commit`]; guarding both under this single lock + /// keeps the eligibility compare atomic with the file write. + highest_requested: Mutex, +} + +impl NestRegenGate { + const fn new() -> Self { + Self { + highest_requested: Mutex::new(0), + } + } + + /// Claim the next generation. Call synchronously at request time so the + /// value reflects when the regeneration was requested, not when its task + /// happens to run. Advancing the shared watermark here is what lets a later + /// [`commit`] recognize — and drop — any older generation's stale render. + fn claim(&self) -> u64 { + let mut requested = self + .highest_requested + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *requested += 1; + *requested + } + + /// Non-blocking [`claim`] against the *exact* lock `claim` takes. Returns + /// `Some(generation)` if it acquired the lock — i.e. a claim could proceed + /// with no contention — or `None` if the lock is already held, meaning a + /// concurrent claim would block on it. Because `claim` and `commit` share + /// `highest_requested`, calling this from inside `commit_hooked`'s + /// under-lock hook reports `None`: the eligibility compare and the write + /// are serialized against any new claim. A design that advanced the + /// watermark under a separate lock (or a lock-free atomic) would report + /// `Some` here — the regression this probe proves absent, with no reliance + /// on elapsed time or thread scheduling. + #[cfg(test)] + fn try_claim(&self) -> Option { + match self.highest_requested.try_lock() { + Ok(mut requested) => { + *requested += 1; + Some(*requested) + } + Err(std::sync::TryLockError::WouldBlock) => None, + Err(std::sync::TryLockError::Poisoned(poisoned)) => { + let mut requested = poisoned.into_inner(); + *requested += 1; + Some(*requested) + } + } + } + + /// Commit `content` for `generation`, dropping the write once a newer + /// generation has been *requested* (regardless of whether that newer + /// generation has written or ever will). Returns whether the file was + /// written. The lock spans the compare and the write so the check-and-write + /// is atomic and no await occurs while it is held. + fn commit(&self, agents_md: &Path, content: &str, generation: u64) -> io::Result { + self.commit_hooked(agents_md, content, generation, || {}) + } + + /// [`commit`] with a hook invoked while the lock is held, after the + /// eligibility compare and before the write. Production passes a no-op, so + /// this is exactly [`commit`]; tests pass a hook that calls [`try_claim`] + /// to prove no claim can land inside the compare-then-write window — the + /// probe reports the lock held here, whereas the flawed + /// separate-watermark/separate-write-lock design would report it free. The + /// `impl FnOnce` monomorphizes the no-op away. + fn commit_hooked( + &self, + agents_md: &Path, + content: &str, + generation: u64, + under_lock: impl FnOnce(), + ) -> io::Result { + let requested = self + .highest_requested + .lock() + .map_err(|_| io::Error::other("nest regen gate lock poisoned"))?; + if generation < *requested { + return Ok(false); + } + under_lock(); + upsert_managed_section(agents_md, content)?; + Ok(true) + } +} + +/// Process-wide ordered write gate for nest-context regeneration. +static NEST_REGEN: NestRegenGate = NestRegenGate::new(); + +pub async fn regenerate_nest_context(app: &AppHandle, generation: u64) -> Result<(), String> { let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; let agents_md = nest.join("AGENTS.md"); @@ -656,23 +791,51 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { let personas = load_personas(app)?; let agents = load_managed_agents(app)?; let state = app.state::(); - let relay_url = relay_ws_url_with_override(&state); - let content = render_dynamic_section(&personas, &agents, &relay_url); - upsert_managed_section(&agents_md, &content) + // Capture the relay target once, before any network work, so this + // generation's rendered footer, NIP-11 signer, and snapshot query all + // belong to one relay even if a workspace switch changes the override + // between the two archive awaits below. + let target = capture_relay_target(&state); + // Identity-archived agents live only in the relay's `kind:13535` snapshot; + // local records all read `is_active: true`. Fails open (empty set → render + // everyone) so an unreachable relay can't blank the roster. The archive read + // uses the same captured target as the rendered relay; a later generation's + // task always wins the commit, so a fallback-relay boot render cannot bury a + // later apply_workspace render. + let archived: HashSet = fetch_archived_pubkeys_at(&state, &target) + .await + .into_iter() + .collect(); + let content = render_dynamic_section(&personas, &agents, &archived, &target.ws_url); + NEST_REGEN + .commit(&agents_md, &content, generation) .map_err(|e| format!("regenerate nest context: {e}"))?; Ok(()) } -/// Convenience wrapper: regenerates nest context, logging a warning on failure. +/// Convenience wrapper: claims a regeneration generation, then regenerates on a +/// spawned task, logging a warning on failure. /// /// All call sites treat regeneration as fire-and-forget — agents run fine with /// a stale AGENTS.md, so we warn and continue rather than propagating the error. +/// The generation is claimed *here*, synchronously, so it encodes call order; +/// the spawned task carries it into [`NestRegenGate::commit`], which drops +/// a stale render rather than letting a slow task overwrite a newer file. +/// Archive/unarchive trigger this directly, but the regen races the relay's +/// `kind:13535` snapshot update, so a just-archived agent may still linger for +/// one cycle until the next regen (any agent/team edit or the next launch). pub fn try_regenerate_nest(app: &AppHandle) { - if let Err(error) = regenerate_nest_context(app) { - eprintln!("buzz-desktop: nest context regeneration failed: {error}"); - } + let generation = NEST_REGEN.claim(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = regenerate_nest_context(&app, generation).await { + eprintln!("buzz-desktop: nest context regeneration failed: {error}"); + } + }); } +#[cfg(test)] +mod render_tests; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs new file mode 100644 index 0000000000..ed4ee2c1f9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -0,0 +1,713 @@ +//! Tests for the dynamic AGENTS.md section renderer, the managed-section +//! upsert, and the regeneration gate. Split from `tests.rs` to keep +//! each test file under the repository's per-file line ratchet. + +use super::*; +use std::collections::HashSet; + +/// Relay URL passed to render calls. Since the roster no longer filters on +/// `relay_url`, this is only echoed into the Workspace footer. +const TEST_RELAY: &str = "ws://example.com:3000"; + +fn make_persona(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: name.to_string(), + persona_id: persona_id.map(|s| s.to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: TEST_RELAY.to_string(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + env_vars: std::collections::BTreeMap::new(), + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + } +} + +#[test] +fn test_render_dynamic_section_with_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Kit", Some("p1"))]; + let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("| Name | Persona | How to address |")); + assert!(output.contains("## Workspace")); +} + +#[test] +fn test_render_dynamic_section_empty() { + let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY); + assert!(output.contains("No agents deployed yet")); +} + +#[test] +fn test_render_dynamic_section_agent_no_persona() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Scout", Some("nonexistent"))]; + let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Scout | — | @Scout |")); +} + +#[test] +fn test_render_excludes_archived_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let mut live = make_agent("Live", Some("p1")); + live.pubkey = "aa".repeat(32); + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "bb".repeat(32); + let archived: HashSet = [gone.pubkey.clone()].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY); + + assert!(output.contains("| Live | Builder | @Live |")); + assert!( + !output.contains("Archived"), + "archived agent must not render" + ); +} + +#[test] +fn test_render_archived_match_is_case_insensitive() { + let personas = vec![make_persona("p1", "Builder")]; + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "AB".repeat(32); // uppercase hex in the record + // Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`. + let archived: HashSet = ["ab".repeat(32)].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY); + + assert!( + output.contains("No agents deployed yet"), + "all-archived roster renders the empty placeholder" + ); +} + +#[test] +fn test_render_empty_archived_set_renders_all() { + let personas = vec![make_persona("p1", "Builder")]; + let mut a = make_agent("Kit", Some("p1")); + a.pubkey = "cc".repeat(32); + // Fail-open: an empty snapshot (relay unreachable) must render everyone. + let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); +} + +#[test] +fn test_render_keeps_agent_with_legacy_foreign_relay_pin() { + // `relay_url` is a legacy creation-era field that `effective_agent_relay_url()` + // deliberately ignores — every agent is eligible on every community. A record + // whose stored pin points at a now-defunct relay must still render on the + // active workspace; only identity-archive removes an agent. + let personas = vec![make_persona("p1", "Builder")]; + let here = make_agent("Local", Some("p1")); + let mut elsewhere = make_agent("Foreign", Some("p1")); + elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string(); + + let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY); + + assert!(output.contains("| Local | Builder | @Local |")); + assert!( + output.contains("| Foreign | Builder | @Foreign |"), + "a legacy foreign relay pin must not hide an agent — the pin is ignored" + ); +} + +#[test] +fn test_render_keeps_snapshot_imported_agent_with_empty_relay_pin() { + // Snapshot-imported records store `relay_url: ""` by design; they resolve + // to the workspace relay at runtime. Such an agent must appear on the active + // workspace, not be hidden by an empty pin. + let personas = vec![make_persona("p1", "Builder")]; + let mut imported = make_agent("Imported", Some("p1")); + imported.relay_url = String::new(); + + let output = render_dynamic_section(&personas, &[imported], &HashSet::new(), TEST_RELAY); + + assert!( + output.contains("| Imported | Builder | @Imported |"), + "an empty relay_url (snapshot-import shape) must still render" + ); +} + +#[test] +fn test_upsert_managed_section_with_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("")); + assert!(result.contains("new section")); + assert!(!result.contains("old section")); + assert!(result.contains("# Header")); + assert!(result.contains("some content")); + assert!(result.contains("after")); +} + +#[test] +fn test_upsert_managed_section_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write(&file, "# Header\n\nexisting content\n").unwrap(); + + upsert_managed_section(&file, "injected section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("# Header")); + assert!(result.contains("existing content")); + assert!(result.contains("")); + assert!(result.contains("injected section")); + let begin_pos = result.find("\nsome middle content\n\nold section\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("new section"), + "new content must be present" + ); + assert!( + result.contains("some middle content"), + "content between markers must survive" + ); + + // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + + // The single BEGIN marker must have a matching END marker after it. + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); + assert!( + end_pos.is_some(), + "an END marker must appear after the appended BEGIN marker" + ); +} + +#[test] +fn test_upsert_begin_only_no_end() { + // A file with BEGIN but no END has an orphan marker. + // find_managed_markers returns None (no END found after BEGIN), + // so strip_orphan_begin_marker removes the BEGIN line. + // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, + // not the body that came after it). + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\norphaned section without end marker\n", + ) + .unwrap(); + + upsert_managed_section(&file, "fresh section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("some content"), + "original body must survive" + ); + assert!( + result.contains("fresh section"), + "new content must be present" + ); + + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result.find(END_MARKER).expect("END marker must be present"); + assert!( + begin_pos < end_pos, + "the appended BEGIN marker must precede the appended END marker" + ); + + // Exactly one BEGIN marker after orphan cleanup. + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); +} + +#[test] +fn test_upsert_duplicate_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "replaced").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains("replaced"), + "replacement content must be present" + ); + assert!( + !result.contains("first block"), + "first block must be replaced" + ); + assert!( + result.contains("second block"), + "second pair content must survive" + ); + assert!( + result.contains("between blocks"), + "text between pairs must survive" + ); +} + +#[test] +fn test_upsert_marker_in_code_block() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. + fs::write( + &file, + "# Header\n\n \n\nReal content here\n", + ) + .unwrap(); + + upsert_managed_section(&file, "appended content").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains(" "), + "indented marker inside code block must be preserved verbatim" + ); + assert!( + result.contains("appended content"), + "new content must be appended" + ); + assert!( + result.contains("Real content here"), + "existing body must survive" + ); + + // The real markers appended at the end must be at line-start (column 0). + let begin_pos = result + .find("\nexisting section\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_first = fs::read_to_string(&file).unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_second = fs::read_to_string(&file).unwrap(); + + assert_eq!( + after_first, after_second, + "upsert must be idempotent: second call must not alter the file" + ); +} + +/// Write an AGENTS.md skeleton with an empty managed section and return its path. +fn agents_md_with_markers(dir: &Path) -> PathBuf { + let file = dir.join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\n\n\n", + ) + .unwrap(); + file +} + +#[test] +fn commit_newer_generation_wins_over_a_stale_finisher() { + // Models the CRUD race: generation A snapshots pre-edit state and its relay + // fetch is slow; generation B snapshots post-edit state and commits first. + // When A finally finishes and commits LAST, its lower generation is dropped + // so the file still reflects B. Ordering of *finishing* is the only variable — + // the generation, claimed at request time, decides the winner. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen_a = gate.claim(); // pre-edit request + let gen_b = gate.claim(); // post-edit request + assert!(gen_a < gen_b); + + // B (newer) commits first. + assert!(gate.commit(&file, "post-edit roster", gen_b).unwrap()); + // A (older) finishes last and must be dropped. + assert!( + !gate.commit(&file, "pre-edit roster", gen_a).unwrap(), + "a stale (lower-generation) render must not overwrite a newer one" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("post-edit roster")); + assert!( + !content.contains("pre-edit roster"), + "final file must reflect the newer generation, not the stale finisher" + ); +} + +#[test] +fn commit_boot_fallback_relay_cannot_bury_apply_workspace_relay() { + // Models boot→apply_workspace relay switching: the boot regen (generation 1, + // fallback relay) is claimed first but finishes last; the apply_workspace + // regen (generation 2, workspace relay) commits first. The workspace relay + // render must survive even though the fallback-relay task writes afterward. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let boot_gen = gate.claim(); // boot, fallback relay + let apply_gen = gate.claim(); // apply_workspace, workspace relay + + // apply_workspace's render lands first. + assert!(gate + .commit( + &file, + "## Workspace\n- Relay: wss://workspace.example", + apply_gen, + ) + .unwrap()); + // Boot's slower fallback-relay render finishes last and is dropped. + assert!(!gate + .commit( + &file, + "## Workspace\n- Relay: wss://fallback.example", + boot_gen, + ) + .unwrap()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("wss://workspace.example")); + assert!( + !content.contains("wss://fallback.example"), + "the fallback-relay boot render must not overwrite the workspace-relay render" + ); +} + +#[test] +fn commit_failed_newer_request_still_supersedes_older_snapshot() { + // Carl 4954831197, case 1: a newer request that never writes must still + // permanently supersede an older snapshot. gen1 (pre-edit) is claimed and + // its relay work is slow; an edit claims gen2 (post-edit); gen2 then FAILS + // during its relay work, so it never commits. When gen1 finally finishes, + // it must NOT publish its obsolete roster — gating on highest-*requested* + // (advanced by gen2's claim) drops it, whereas gating on highest-*written* + // (0, since gen2 never wrote) would wrongly let gen1 publish. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen1 = gate.claim(); // pre-edit request + let gen2 = gate.claim(); // post-edit request + assert!(gen1 < gen2); + + // gen2 fails during relay work and never reaches commit — nothing written. + + // gen1 finishes last; its stale render must be dropped. + assert!( + !gate.commit(&file, "pre-edit roster", gen1).unwrap(), + "an older snapshot must not publish once a newer generation was requested, \ + even if that newer generation failed before writing" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!( + !content.contains("pre-edit roster"), + "the obsolete pre-edit roster must never become authoritative" + ); +} + +#[test] +fn commit_claim_at_the_older_tasks_cutover_supersedes_it() { + // Carl 4954831197, case 2: a claim arriving at the older task's commit + // cutover must not slip between the eligibility compare and the write. + // gen1 becomes eligible and enters `commit`; while it holds the lock + // (after the compare, before the write) a claim is attempted. The correct + // single-lock gate shares `highest_requested` between `claim` and + // `commit`, so that claim cannot acquire the lock until gen1's write + // releases it — the flawed separate-watermark/separate-write-lock design + // Carl warned about would let the claim proceed immediately. + // + // Determinism: the under-lock hook calls `try_claim`, a non-blocking claim + // against the exact lock `claim` takes, and asserts it reports the lock + // held (`None`). This is a direct statement about the gate's locking with + // no thread, channel, or sleep — the correct design necessarily returns + // `None` and the separate-watermark design necessarily returns `Some`, so + // the discriminator cannot be flipped by scheduler timing. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen1 = gate.claim(); + + let wrote_gen1 = gate + .commit_hooked(&file, "gen1 roster", gen1, || { + // We are past the eligibility compare and hold the lock. A claim + // attempted now must find the shared lock held — proving the + // compare and the write are atomic against any new claim. + assert!( + gate.try_claim().is_none(), + "a claim must not acquire the gate while an older commit holds \ + the shared lock between its eligibility check and its write — \ + the eligibility compare is not atomic with the write \ + (separate-watermark design)" + ); + }) + .unwrap(); + assert!( + wrote_gen1, + "gen1 was still the highest request when it entered commit, so its write \ + is legitimate; the newer request only lands after the lock releases" + ); + + // The lock is free once commit returns, so a newer request now claims and + // may publish over gen1. + let gen2 = gate.claim(); + assert!(gen1 < gen2); + assert!(gate.commit(&file, "gen2 roster", gen2).unwrap()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("gen2 roster")); + assert!(!content.contains("gen1 roster")); +} + +#[test] +fn commit_equal_generation_is_allowed() { + // The gate rejects only strictly-lower generations. Re-committing the same + // generation (e.g. a retried request) is permitted and refreshes the file. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen = gate.claim(); + assert!(gate.commit(&file, "first", gen).unwrap()); + assert!( + gate.commit(&file, "second", gen).unwrap(), + "an equal generation must still be allowed to write" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("second")); +} + +#[test] +fn commit_poisoned_lock_returns_error_instead_of_panicking() { + // A poisoned gate lock must degrade to an io::Error so the fire-and-forget + // caller warns and continues, never panicking the desktop process (root + // AGENTS.md: no new expect() in production paths). Poison the lock by + // panicking a thread while it holds the guard, then assert commit yields + // Err rather than unwinding. + let gate = std::sync::Arc::new(NestRegenGate::new()); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + let gen = gate.claim(); + + let poisoner = gate.clone(); + let _ = std::thread::spawn(move || { + let _guard = poisoner.highest_requested.lock().unwrap(); + panic!("poison the gate lock"); + }) + .join(); + + let result = gate.commit(&file, "after poison", gen); + assert!( + result.is_err(), + "a poisoned lock must surface as an error, not a panic" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 67cdb5fbaf..bc67a5b69e 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -422,415 +422,6 @@ fn ensure_cli_symlink_does_not_clobber_regular_file_dev() { ); } -fn make_persona(id: &str, display_name: &str) -> AgentDefinition { - AgentDefinition { - id: id.to_string(), - display_name: display_name.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - env_vars: std::collections::BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: String::new(), - updated_at: String::new(), - } -} - -fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: String::new(), - name: name.to_string(), - persona_id: persona_id.map(|s| s.to_string()), - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: String::new(), - agent_command: String::new(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 0, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::default(), - backend_agent_id: None, - provider_policy_pending: false, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::default(), - respond_to_allowlist: vec![], - env_vars: std::collections::BTreeMap::new(), - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } -} - -#[test] -fn test_render_dynamic_section_with_agents() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Kit", Some("p1"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Kit | Builder | @Kit |")); - assert!(output.contains("| Name | Persona | How to address |")); - assert!(output.contains("## Workspace")); -} - -#[test] -fn test_render_dynamic_section_empty() { - let output = render_dynamic_section(&[], &[], "ws://example.com:3000"); - assert!(output.contains("No agents deployed yet")); -} - -#[test] -fn test_render_dynamic_section_agent_no_persona() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Scout", Some("nonexistent"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Scout | — | @Scout |")); -} - -#[test] -fn test_upsert_managed_section_with_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("")); - assert!(result.contains("new section")); - assert!(!result.contains("old section")); - assert!(result.contains("# Header")); - assert!(result.contains("some content")); - assert!(result.contains("after")); -} - -#[test] -fn test_upsert_managed_section_without_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write(&file, "# Header\n\nexisting content\n").unwrap(); - - upsert_managed_section(&file, "injected section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("# Header")); - assert!(result.contains("existing content")); - assert!(result.contains("")); - assert!(result.contains("injected section")); - let begin_pos = result.find("\nsome middle content\n\nold section\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("new section"), - "new content must be present" - ); - assert!( - result.contains("some middle content"), - "content between markers must survive" - ); - - // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); - - // The single BEGIN marker must have a matching END marker after it. - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); - assert!( - end_pos.is_some(), - "an END marker must appear after the appended BEGIN marker" - ); -} - -#[test] -fn test_upsert_begin_only_no_end() { - // A file with BEGIN but no END has an orphan marker. - // find_managed_markers returns None (no END found after BEGIN), - // so strip_orphan_begin_marker removes the BEGIN line. - // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, - // not the body that came after it). - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\norphaned section without end marker\n", - ) - .unwrap(); - - upsert_managed_section(&file, "fresh section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("some content"), - "original body must survive" - ); - assert!( - result.contains("fresh section"), - "new content must be present" - ); - - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result.find(END_MARKER).expect("END marker must be present"); - assert!( - begin_pos < end_pos, - "the appended BEGIN marker must precede the appended END marker" - ); - - // Exactly one BEGIN marker after orphan cleanup. - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); -} - -#[test] -fn test_upsert_duplicate_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "replaced").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains("replaced"), - "replacement content must be present" - ); - assert!( - !result.contains("first block"), - "first block must be replaced" - ); - assert!( - result.contains("second block"), - "second pair content must survive" - ); - assert!( - result.contains("between blocks"), - "text between pairs must survive" - ); -} - -#[test] -fn test_upsert_marker_in_code_block() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. - fs::write( - &file, - "# Header\n\n \n\nReal content here\n", - ) - .unwrap(); - - upsert_managed_section(&file, "appended content").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains(" "), - "indented marker inside code block must be preserved verbatim" - ); - assert!( - result.contains("appended content"), - "new content must be appended" - ); - assert!( - result.contains("Real content here"), - "existing body must survive" - ); - - // The real markers appended at the end must be at line-start (column 0). - let begin_pos = result - .find("\nexisting section\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_first = fs::read_to_string(&file).unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_second = fs::read_to_string(&file).unwrap(); - - assert_eq!( - after_first, after_second, - "upsert must be idempotent: second call must not alter the file" - ); -} - #[test] fn refresh_agents_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index a6a50540bb..734772d73d 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -118,6 +118,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } 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 682fbef62f..af8cfe6618 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -59,6 +59,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c005510907..f7f5d5c5d0 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1465,9 +1465,8 @@ mod tests { #[test] fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // A record whose env_vars explicitly set provider/model must win over - // any baked defaults. In OSS test builds the baked map is empty, so - // this test validates the user-env layer is present in the output. + // User env_vars must win over baked defaults; in OSS builds baked map is empty, + // so this validates the user-env layer is present in the output. let mut env_vars = BTreeMap::new(); env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); env_vars.insert( @@ -1531,6 +1530,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, }; let runtime = known_acp_runtime_exact("buzz-agent"); diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 895aad712a..a225f492d3 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -373,7 +373,7 @@ pub async fn restore_managed_agents_on_launch( .lock() .map_err(|error| error.to_string())?; - let mut successfully_spawned: Vec = Vec::new(); + let mut successfully_spawned: Vec<(String, String)> = Vec::new(); for (pubkey, outcome) in spawn_results { match outcome { @@ -404,8 +404,15 @@ pub async fn restore_managed_agents_on_launch( record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; - runtimes.insert(key, super::ManagedAgentPairRuntime::starting(*process)); - successfully_spawned.push(pubkey); + runtimes.insert( + key.clone(), + super::ManagedAgentPairRuntime::starting(*process), + ); + // Carry the spawn key's relay into profile reconciliation so + // the background task queries/publishes on the relay this + // spawn was actually keyed to — not whatever workspace is + // active when the task eventually executes. + successfully_spawned.push((pubkey, key.relay_url.clone())); } SpawnOutcome::Failed(error) => { let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { @@ -425,7 +432,7 @@ pub async fn restore_managed_agents_on_launch( let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> = successfully_spawned .iter() - .filter_map(|pubkey| { + .filter_map(|(pubkey, spawn_relay)| { let record = records.iter().find(|r| r.pubkey == *pubkey)?; // Resolve the effective harness for the avatar-fallback // derivation (the snapshot may be empty/stale for an inherited @@ -438,7 +445,10 @@ pub async fn restore_managed_agents_on_launch( private_key_nsec: record.private_key_nsec.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), - target_relay_url: None, + // Pin the relay this spawn was keyed to (see the + // successfully_spawned push above) so the deferred + // task cannot resolve a post-switch workspace. + target_relay_url: Some(spawn_relay.clone()), avatar_url: record.avatar_url.clone(), auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 8b57b161b7..0ce5ca7b21 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -14,6 +14,7 @@ use crate::{ util::now_iso, }; +use super::claude_config::{apply_claude_model_env, apply_effort_env}; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; @@ -67,6 +68,8 @@ mod lifecycle; #[cfg(test)] use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; +mod spawn_key; // production spawn-key derivation + its regressions +pub(crate) use spawn_key::bound_runtime_key; /// Classify an agent's persona against the live catalog for the Agents-menu /// drift indicator. Returns `(out_of_date, orphaned)`. @@ -775,17 +778,8 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); - // ── Git credential helper for Buzz relay ────────────────────────── - // - // Agents need to clone/push repos hosted on the Buzz relay's git - // server, which authenticates via NIP-98. The `git-credential-nostr` - // binary signs auth events using the agent's nostr key. - // - // We configure git via GIT_CONFIG_COUNT env vars (ephemeral, no - // filesystem writes) scoped to the relay's git URL so we don't - // interfere with other remotes (e.g. GitHub). - // - // NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY — keep in sync. + // Git credential helper: NIP-98 auth for Buzz relay git via git-credential-nostr. + // Ephemeral GIT_CONFIG_COUNT env vars scoped to relay HTTP URL; NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY. if let Some(cred_helper) = resolve_command("git-credential-nostr") { let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url); @@ -810,17 +804,27 @@ pub fn spawn_agent_child( ); } - // ── User env vars: definition floor + global + live persona + agent overrides ── - // - // `descriptor.env` is the fully-layered result from `resolve_effective_harness_descriptor`: - // baked floor → runtime metadata → definition env (harness author defaults) → - // global → live persona → per-agent, with reserved-key and malformed-key filtering - // applied. Writing it last lets user-provided values win over every Buzz-set env - // written above — reserved keys were already stripped from descriptor.env so they - // cannot clobber BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, etc. + // User env (descriptor.env): fully-layered floor→runtime→definition→global→persona→agent, + // reserved-key filtered. Written last so user-explicit values win over Buzz-set env. for (key, value) in &descriptor.env { command.env(key, value); } + + // B5: carry persisted effort; harness resolves thought_level configId at first session. + // Written AFTER descriptor.env so the canonical persisted value wins over any + // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern + // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is + // None there is no canonical value to assert, so env passthrough stands — user env + // legitimately seeds startup effort in that case. + apply_effort_env(&mut command, record.effort_level.as_deref()); + + // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. + // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env + // would be ambiguous). + if record.backend == super::BackendKind::Local && runtime_meta.is_some_and(|r| r.id == "claude") + { + apply_claude_model_env(&mut command, effective_model.as_deref()); + } configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible @@ -932,21 +936,20 @@ fn child_rust_log_filter() -> String { } } +/// Spawn (or adopt) the runtime pair for `record` on the caller's bound +/// workspace relay. `workspace_relay` can only be produced by +/// `bind_expected_relay_scope`, so this spawn consumes — by construction — the +/// exact workspace-relay read the caller's scope assertion passed on; it never +/// re-reads the mutable override (see `relay::scope`). The key comes from +/// [`bound_runtime_key`] — the seam the spawn-key regressions exercise. pub fn start_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, owner_hex: Option<&str>, + workspace_relay: &crate::relay::ScopedWorkspaceRelay, ) -> Result<(), String> { - let relay_url = { - use tauri::Manager; - let state = app.state::(); - crate::relay::effective_agent_relay_url( - &record.relay_url, - &crate::relay::relay_ws_url_with_override(&state), - ) - }; - let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url)?; + let key = bound_runtime_key(record, workspace_relay)?; if let Some(runtime) = runtimes.get_mut(&key) { if runtime .child diff --git a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs new file mode 100644 index 0000000000..fe302ffc67 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs @@ -0,0 +1,84 @@ +//! Production spawn-key derivation — split from `runtime.rs` (file-size +//! guard). The regression tests live beside the function so they exercise +//! the exact seam production spawn keys on. + +use crate::managed_agents::types::ManagedAgentRecord; +use crate::managed_agents::ManagedAgentRuntimeKey; + +/// The one production derivation from a caller-bound workspace relay to the +/// runtime-pair key `start_managed_agent_process` spawns and persists under. +/// Extracted so the regression suite exercises the exact seam production +/// uses: a mutation that keys the spawn to anything but the bound value now +/// fails the tests below, instead of leaving them green while a painted +/// guard watches the door. +pub(crate) fn bound_runtime_key( + record: &ManagedAgentRecord, + workspace_relay: &crate::relay::ScopedWorkspaceRelay, +) -> Result { + let relay_url = + crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay.as_str()); + ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) +} + +#[cfg(test)] +mod tests { + use super::bound_runtime_key; + use crate::managed_agents::types::ManagedAgentRecord; + + fn record(pubkey: &str, relay_url: &str) -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": pubkey, + "name": "test", + "private_key_nsec": "nsec1fake", + "relay_url": relay_url, + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "", + "updated_at": "" + })) + .expect("record fixture") + } + + #[test] + fn production_spawn_key_derives_from_the_bound_relay_not_the_post_switch_workspace() { + // Round-8 regression: the previous test reconstructed the key + // derivation by hand, so hard-coding a wrong tenant inside production + // spawn stayed green. This calls `bound_runtime_key` — the exact + // function `start_managed_agent_process` keys its spawn, receipt, and + // runtimes-map insert on — so that mutation now fails here. + let record = record(&"aa".repeat(32), ""); // never-pinned record + let mut workspace = "wss://tenant-a.example".to_string(); + let bound = crate::relay::bind_expected_relay_scope( + Some("wss://tenant-a.example"), + workspace.clone(), + ) + .expect("scope matches at bind time"); + workspace = "wss://tenant-b.example".to_string(); // the switch lands post-check + + let key = bound_runtime_key(&record, &bound).expect("keyable record and relay"); + assert_eq!(key.relay_url, "wss://tenant-a.example"); + assert_eq!(key.pubkey, "aa".repeat(32)); + assert_ne!( + key.relay_url, workspace, + "the production spawn key must be unrepresentable for the post-switch tenant" + ); + } + + #[test] + fn production_spawn_key_ignores_a_legacy_record_pin() { + // agents-everywhere (#2122): the stored per-record pin never + // contributes; the bound workspace relay is the only input. Pins the + // same contract at the production seam so a regression re-honoring + // the pin fails loudly. + let record = record(&"bb".repeat(32), "wss://stale-pin.example"); + let bound = + crate::relay::bind_expected_relay_scope(None, "wss://tenant-a.example".to_string()) + .expect("unscoped bind"); + + let key = bound_runtime_key(&record, &bound).expect("keyable record and relay"); + assert_eq!(key.relay_url, "wss://tenant-a.example"); + } +} 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 792a275b05..9076766b2e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -90,5 +90,6 @@ pub(super) fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index edb4fad422..b54c0e7a05 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1206,7 +1206,7 @@ fn receipt_invalid_when_process_not_running() { ); } -// ── Test helpers ──────────────────────────────────────────────────────────── +// ── Test helpers (spawn-key regressions: see `runtime/spawn_key.rs`) ─────── fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord { serde_json::from_str(&format!( diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 357f5f1e26..8a6f68a693 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -31,6 +31,7 @@ use std::collections::BTreeMap; use serde::Serialize; use super::{ + claude_config::EFFORT_LEVEL_ENV_VAR, effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, persona_events::preview_prospective_persona_snapshot, @@ -126,6 +127,31 @@ pub(crate) struct SpawnConfigSnapshot { pub idle_timeout_seconds: Option, pub max_turn_duration_seconds: Option, pub parallelism: u32, + /// The startup effort the harness will actually apply, resolved by + /// [`effective_effort`]: the persisted canonical `record.effort_level` when + /// present, else the user-seeded `BUZZ_ACP_EFFORT_LEVEL` from the layered + /// env. This is the *sole* representation of effort in the snapshot — the + /// key is stripped from `env` (see `from_inputs`) so an authority handoff + /// that leaves the effective value unchanged (canonical `low` replacing a + /// user env `low`, or the reverse) produces no spurious drift entry, and an + /// env-only edit still surfaces as exactly one `effort_level` entry. + pub effort_level: Option, +} + +/// The startup effort a spawn would actually apply, mirroring `apply_effort_env` +/// exactly: the persisted canonical `record.effort_level` wins, and only when it +/// is absent does a user-supplied `BUZZ_ACP_EFFORT_LEVEL` from the layered env +/// seed startup effort. This is the resolver input for the snapshot's single +/// `effort_level` representation; the same precedence runs at spawn time in +/// `runtime.rs`, so badge and process can never disagree. +pub(crate) fn effective_effort( + record: &ManagedAgentRecord, + descriptor_env: &BTreeMap, +) -> Option { + record + .effort_level + .clone() + .or_else(|| descriptor_env.get(EFFORT_LEVEL_ENV_VAR).cloned()) } impl SpawnConfigSnapshot { @@ -151,7 +177,17 @@ impl SpawnConfigSnapshot { .and_then(|runtime| runtime.mcp_command) .unwrap_or("") .to_string(), - env: descriptor.env.clone(), + // Effort has ONE representation in the snapshot: `effort_level` + // below, always holding `effective_effort`. Stripping the env key + // here means a canonical/user-env authority handoff at the same + // value is a no-op (no phantom `env.BUZZ_ACP_EFFORT_LEVEL` add or + // remove) and an env-only effort edit surfaces as exactly one + // `effort_level` entry rather than a duplicate under `env.`. + env: { + let mut env = descriptor.env.clone(); + env.remove(EFFORT_LEVEL_ENV_VAR); + env + }, relay_url: relay_url.to_string(), team_instructions: team_instructions.map(str::to_string), system_prompt: system_prompt.map(str::to_string), @@ -178,6 +214,11 @@ impl SpawnConfigSnapshot { // pool and must badge. The diff surface consequently displays the // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), + // Sole effort representation — see the field doc and the `env` + // strip above. Resolver reads the record's canonical value and the + // raw descriptor env (before the strip), so a user-seeded env value + // is preserved as the effective effort when no canonical is set. + effort_level: effective_effort(record, &descriptor.env), } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs index a61eb92e2e..0ae3009bae 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs @@ -103,6 +103,7 @@ fn policy_for(path: &str) -> MaskPolicy { // acp_command / command / mcp_command — resolved binary names // session_title — display chrome // model / provider — catalog ids + // effort_level — non-secret effort enum // respond_to / respond_to_allowlist — gate mode + pubkeys // idle_timeout_seconds / max_turn_duration_seconds / parallelism // — numeric limits diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index a7a8cab93e..e21dc4735c 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -28,6 +28,7 @@ fn base() -> SpawnConfigSnapshot { idle_timeout_seconds: Some(600), max_turn_duration_seconds: Some(7200), parallelism: 1, + effort_level: Some("high".into()), } } @@ -70,6 +71,7 @@ fn mutations() -> Vec { s.max_turn_duration_seconds = None }), ("parallelism", |s| s.parallelism = 8), + ("effort_level", |s| s.effort_level = None), ] } @@ -570,3 +572,37 @@ fn unstamped_agent_yields_no_badge_and_no_entries() { ); } } + +// ── B5 effort lifecycle: restart-diff and re-stamp ─────────────────────── + +#[test] +fn tracked_running_old_effort_edited_to_new_yields_effort_level_diff() { + // A process was stamped at effort `high`; the record's canonical effort is + // later edited to `low`. Until a restart re-stamps, the tracked pair must + // light the badge and name exactly `effort_level`. + let stamped = base(); // effort_level = high + let mut current = base(); + current.effort_level = Some("low".into()); + let (needs_restart, entries) = eligible(false, &stamped, ¤t, None, None); + assert!(needs_restart); + assert_eq!(fields(&entries), vec!["effort_level"]); + assert_eq!( + change_at(&entries, "effort_level"), + &RestartChange::Value { + before: Value::String("high".into()), + after: Value::String("low".into()), + } + ); +} + +#[test] +fn restart_restamps_effort_and_clears_the_badge() { + // After the edit above, a restart stamps the new effort, so stamped and + // current agree again: the badge clears and no entry remains. + let mut restamped = base(); + restamped.effort_level = Some("low".into()); + let current = restamped.clone(); + let (needs_restart, entries) = eligible(false, &restamped, ¤t, None, None); + assert!(!needs_restart); + assert!(entries.is_empty()); +} 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 89bba15cee..b007e0b2ff 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -34,6 +34,12 @@ fn snapshot( snapshot_with_policy(record, personas, teams, workspace_relay, global, false) } +/// `snapshot` with the fixed no-persona/no-team/default-global shape the effort +/// tests share, so their call sites read as `snap(&record)` instead of wrapping. +fn snap(record: &ManagedAgentRecord) -> serde_json::Value { + snapshot(record, &[], &[], "wss://ws.example", &Default::default()) +} + fn record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "p".repeat(64), @@ -90,6 +96,7 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } @@ -925,3 +932,7 @@ fn openclaw_cap_crossing_parallelism_snapshots_differ() { "parallelism 8 (clamps to 5) and 3 (runs as 3) must produce different snapshots" ); } + +#[cfg(test)] +#[path = "tests_ext.rs"] +mod ext; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs new file mode 100644 index 0000000000..dd708b6e59 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -0,0 +1,189 @@ +//! B5 effort lifecycle tests split out of `spawn_snapshot/tests.rs` to hold +//! that file under the 1000-line file-size ratchet. +//! +//! Included as `mod ext` inside `tests.rs`, so `use super::*` gives access to +//! its `record`, `snap`, and `record_with_env_effort` helpers. + +use super::*; + +#[test] +fn effort_set_then_cleared_round_trips_to_no_effort_projection() { + // Persist a canonical effort, then clear it: the projection must return to + // the exact no-effort baseline, so the badge lights on set and clears on + // clear rather than sticking. + let baseline = snap(&record()); + let mut set = record(); + set.effort_level = Some("high".into()); + assert_ne!(baseline, snap(&set), "setting canonical effort must badge"); + // Clear the SAME record back to None — the projection must return to the + // exact no-effort baseline, proving the round-trip clears rather than a + // fresh record merely matching baseline. + set.effort_level = None; + assert_eq!( + baseline, + snap(&set), + "clearing canonical effort restores the no-effort projection" + ); +} + +#[test] +fn shadowed_user_env_effort_edit_under_canonical_is_empty_diff() { + // Canonical `high` shadows the user env seed. Editing that seed low→medium + // changes nothing effective (canonical wins and the env key is stripped), + // so the projections are identical and no badge lights. + let mut low_env = record_with_env_effort("low"); + low_env.effort_level = Some("high".into()); + let mut medium_env = record_with_env_effort("medium"); + medium_env.effort_level = Some("high".into()); + assert_eq!( + snap(&low_env), + snap(&medium_env), + "editing a canonical-shadowed user env must not badge" + ); +} + +#[test] +fn clearing_canonical_reveals_env_fallback_and_creates_a_diff() { + // Canonical `high` over a user env seed `low`: clearing the canonical drops + // the effective effort to the env fallback `low`, a real change that badges. + let mut canonical = record_with_env_effort("low"); + canonical.effort_level = Some("high".into()); + let env_only = record_with_env_effort("low"); + assert_ne!( + snap(&canonical), + snap(&env_only), + "clearing canonical must reveal the env fallback and badge" + ); +} + +// ── B5 effort: single canonical representation ─────────────────────────── +// +// `effective_effort` and the snapshot's `effort_level` field are the sole +// carrier of startup effort. `BUZZ_ACP_EFFORT_LEVEL` is stripped from the +// snapshot `env` so an authority handoff at an unchanged effective value +// (canonical replacing a user-env seed, or the reverse) raises no spurious +// restart badge, while a genuine effort change surfaces exactly once. + +/// Look up the `env.BUZZ_ACP_EFFORT_LEVEL` leaf of a canonical snapshot, if any. +fn effort_env_leaf(canonical: &serde_json::Value) -> Option<&serde_json::Value> { + canonical + .get("env") + .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")) +} + +/// A record whose user env seeds `BUZZ_ACP_EFFORT_LEVEL` (the pre-canonical +/// authority: no persisted `effort_level`, effort comes from user env_vars). +fn record_with_env_effort(value: &str) -> ManagedAgentRecord { + let mut rec = record(); + rec.env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into()); + rec +} + +#[test] +fn effective_effort_prefers_persisted_canonical_over_user_env() { + // Canonical wins, mirroring spawn's `apply_effort_env` (written after the + // user env layer). The env value is ignored when a canonical is present. + let mut rec = record(); + rec.effort_level = Some("high".into()); + let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); + assert_eq!(effective_effort(&rec, &env).as_deref(), Some("high")); +} + +#[test] +fn effective_effort_falls_back_to_user_env_when_no_canonical() { + // No persisted canonical → the user-seeded env value is the effective + // startup effort, exactly what a spawn would leave in place. + let rec = record(); + let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); + assert_eq!(effective_effort(&rec, &env).as_deref(), Some("low")); +} + +#[test] +fn effective_effort_is_none_without_canonical_or_env() { + assert_eq!(effective_effort(&record(), &BTreeMap::new()), None); +} + +#[test] +fn snapshot_carries_effort_in_field_not_env() { + // Always-canonicalize: a user-seeded effort reaches the snapshot ONLY as + // the `effort_level` field; the raw env key is stripped so effort has one + // representation, never two. + let canonical = snap(&record_with_env_effort("low")); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("low"), + "effective effort must land in the effort_level field" + ); + assert_eq!( + effort_env_leaf(&canonical), + None, + "BUZZ_ACP_EFFORT_LEVEL must be stripped from the snapshot env" + ); +} + +#[test] +fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { + // User env `low` (no canonical) → persisted canonical `low` while the env + // seed remains: the effective effort is `low` either way, so a restart + // would change nothing. Old raw-env snapshots would have shown drift; the + // single canonical representation makes the projections identical. + let env_authority = record_with_env_effort("low"); + let mut canonical_authority = record_with_env_effort("low"); + canonical_authority.effort_level = Some("low".into()); + assert_eq!( + snap(&env_authority), + snap(&canonical_authority), + "an authority handoff at the same effort value must not badge" + ); +} + +#[test] +fn equal_value_effort_authority_handoff_canonical_to_env_is_no_op() { + // The reverse direction: canonical `low` (env seed present) → env `low` + // only (canonical cleared). Effective effort stays `low`; no badge. + let mut canonical_authority = record_with_env_effort("low"); + canonical_authority.effort_level = Some("low".into()); + let env_authority = record_with_env_effort("low"); + assert_eq!( + snap(&canonical_authority), + snap(&env_authority), + "clearing the canonical while the env seed holds the same value must not badge" + ); +} + +#[test] +fn env_only_effort_edit_changes_effort_level_not_env() { + // An env-only effort edit (no canonical) moves the single `effort_level` + // representation and never reintroduces an `env.BUZZ_ACP_EFFORT_LEVEL` + // leaf, so the diff names `effort_level` once rather than duplicating it. + let low = snap(&record_with_env_effort("low")); + let high = snap(&record_with_env_effort("high")); + assert_ne!( + low, high, + "an env-only effort edit must change the snapshot" + ); + assert_eq!( + low.get("effort_level").and_then(|v| v.as_str()), + Some("low") + ); + assert_eq!( + high.get("effort_level").and_then(|v| v.as_str()), + Some("high") + ); + assert_eq!(effort_env_leaf(&low), None); + assert_eq!(effort_env_leaf(&high), None); +} + +#[test] +fn canonical_effort_edit_changes_snapshot() { + let mut low = record(); + low.effort_level = Some("low".into()); + let mut high = record(); + high.effort_level = Some("high".into()); + assert_ne!( + snap(&low), + snap(&high), + "a canonical effort edit must trip the restart badge" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 5073d9c407..2b6918b16e 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -310,6 +310,7 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index d66e68979c..ff7900d392 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -214,6 +214,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3b0641cb67..9049482de3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -154,6 +154,7 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + effort_level: None, } } } @@ -439,24 +440,10 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, -} - -/// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`]. -/// -/// Feature-independent on purpose: the field is always present in the record -/// schema so saved agents round-trip identically whether or not the `mesh-llm` -/// feature is compiled in. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RelayMeshConfig { - /// The served model id this agent routes to (e.g. "Qwen3"). - /// - /// `alias` because this struct crosses two boundaries with different - /// casing conventions: the TS create request sends camelCase - /// (`relayMesh: { modelRef }` — `rename_all` on the request does not - /// recurse into nested structs), while persisted records use snake_case. - /// Serialization stays `model_ref` so saved records are stable. - #[serde(alias = "modelRef")] - pub model_ref: String, + /// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn + /// so the harness applies it via `session/set_config_option` at session creation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_level: Option, } #[derive(Debug)] @@ -991,6 +978,8 @@ pub fn resolve_mint_behavioral_defaults( mod catalog_source; pub use catalog_source::CatalogSource; +mod relay_mesh; +pub use relay_mesh::RelayMeshConfig; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs new file mode 100644 index 0000000000..a9ec2d2838 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +/// Typed relay-mesh configuration carried on a [`super::ManagedAgentRecord`]. +/// +/// Feature-independent on purpose: the field is always present in the record +/// schema so saved agents round-trip identically whether or not the `mesh-llm` +/// feature is compiled in. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RelayMeshConfig { + /// The served model id this agent routes to (e.g. "Qwen3"). + /// + /// `alias` because this struct crosses two boundaries with different + /// casing conventions: the TS create request sends camelCase + /// (`relayMesh: { modelRef }` — `rename_all` on the request does not + /// recurse into nested structs), while persisted records use snake_case. + /// Serialization stays `model_ref` so saved records are stable. + #[serde(alias = "modelRef")] + pub model_ref: String, +} diff --git a/desktop/src-tauri/src/native_relay_client.rs b/desktop/src-tauri/src/native_relay_client.rs new file mode 100644 index 0000000000..2237076a92 --- /dev/null +++ b/desktop/src-tauri/src/native_relay_client.rs @@ -0,0 +1,962 @@ +//! Shared native relay session. +//! +//! Owns the authenticated relay socket for backend features that need live +//! subscriptions (archive sync today; persona catalog and catch-up next). One +//! session per (relay, pubkey) scope, multiplexing every subscription over a +//! single socket — a second socket per feature would multiply relay connection +//! slots and duplicate the NIP-42 handshake for no benefit. +//! +//! Built on `buzz-ws-client`, which owns the wire format and the NIP-42 +//! handshake. That crate is request/response shaped (one caller, `next_event` +//! off a buffer); the session lifecycle lives here instead of being pushed down +//! into it, because `buzz-cli` and `buzz-test-client` consume that crate and do +//! not want subscription bookkeeping. +//! +//! # Caller contract +//! +//! A subscription id's filter is immutable for the life of a session: to change +//! a filter, use a new id. See [`Subscription::id`] for why this cannot be +//! relaxed from inside this module. + +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; + +use buzz_ws_client_pkg::{NostrWsConnection, RelayMessage}; +use nostr::{Event, Keys}; +use tokio::{ + sync::{mpsc, oneshot, Mutex}, + time::Instant, +}; +use tokio_util::sync::CancellationToken; + +/// Backoff floor for reconnect attempts. +const RECONNECT_BASE_DELAY: Duration = Duration::from_millis(500); +/// Backoff ceiling. Matches the renderer session's ceiling so a relay outage +/// produces one retry cadence across the app rather than two competing ones. +const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(30); +/// How long a read may block before the loop re-checks cancellation. Not a +/// connection timeout: an idle relay is normal, so a lapsed read just loops. +const READ_TIMEOUT: Duration = Duration::from_secs(30); +/// Backoff floor for reopening a subscription the relay CLOSED. Matches +/// `RETRY_BASE_DELAY_MS` in `relayClosedRecovery.ts`. +const CLOSED_RETRY_BASE_DELAY: Duration = Duration::from_secs(1); +/// Backoff ceiling for reopening a CLOSED subscription. Matches +/// `RETRY_MAX_DELAY_MS` in `relayClosedRecovery.ts`. +const CLOSED_RETRY_MAX_DELAY: Duration = Duration::from_secs(30); +/// Delay for a `rate-limited:` CLOSED that carries no `retry in Ns` hint. +/// Matches `DEFAULT_RATE_LIMIT_SECONDS` on both sides of the client. +const CLOSED_RATE_LIMIT_DEFAULT: Duration = Duration::from_secs(10); + +/// A live subscription request: a filter plus where its events go. +#[derive(Clone)] +pub(crate) struct Subscription { + /// Caller-stable key. Reused verbatim as the relay subscription id so a + /// resubscribe after reconnect replaces rather than duplicates. + /// + /// **An id's filter is immutable for the life of a session.** To change a + /// filter, use a new id — as `archive::sync` does by hashing scope and + /// kinds into the id. Reusing an id for a different filter is unsound and + /// cannot be made sound here: a CLOSED frame carries only the id, so a + /// rejection caused by the old filter is indistinguishable from one caused + /// by the new one, and would latch backoff (or a terminal stop) onto a + /// subscription that never failed. + pub(crate) id: String, + pub(crate) filter: serde_json::Value, +} + +/// An event delivered to the session owner, tagged with the subscription that +/// matched it. Callers demultiplex on `subscription_id`. +#[derive(Clone)] +pub(crate) struct MatchedEvent { + pub(crate) subscription_id: String, + pub(crate) event: Box, +} + +/// App-wide owner of the one native socket for the active `(relay, pubkey)` +/// scope. Features subscribe independently. +/// +/// Only the archive lifecycle may replace the installed scope, and only while +/// holding [`crate::archive::sync::ArchiveOwnership`]; see [`Self::session`] +/// for why finite callers get a non-destructive lease instead. +#[derive(Default)] +pub(crate) struct NativeRelayClient { + current: Mutex>, +} + +struct ManagedSession { + scope: (String, String), + session: Arc, +} + +/// A session borrowed by a finite-request caller, plus whether that caller owns +/// it. Dropping the lease shuts down a private session and leaves a shared one +/// running for the feature that installed it. +/// +/// Exists because a finite caller cannot be trusted to shut the session down by +/// hand: it must not call [`RelaySession::shutdown`] on the shared session, and +/// it must call it on a private one or the socket outlives the request. Tying +/// both to the drop makes the correct behavior the only reachable one. +pub(crate) struct SessionLease { + session: Arc, + /// Set only for a session this lease alone can see, which is therefore the + /// lease's to cancel. + private: bool, +} + +impl std::ops::Deref for SessionLease { + type Target = RelaySession; + + fn deref(&self) -> &Self::Target { + &self.session + } +} + +impl SessionLease { + /// Clones the underlying handle for a task that outlives this binding, as + /// the catch-up fan-out does. Only the lease cancels the session, so the + /// clone must not outlive it. + pub(crate) fn handle(&self) -> Arc { + Arc::clone(&self.session) + } +} + +impl Drop for SessionLease { + fn drop(&mut self) { + if self.private { + self.session.shutdown(); + } + } +} + +impl NativeRelayClient { + /// Installs the session for `scope`, shutting down whatever scope held the + /// slot. Destructive on entry, so every caller must already hold proof it + /// is the current owner — today that is + /// [`crate::archive::sync::ArchiveOwnership`]. + async fn ensure_session(&self, relay_url: String, keys: Keys) -> Arc { + let scope = (relay_url.clone(), keys.public_key().to_hex()); + let mut current = self.current.lock().await; + if let Some(managed) = current.as_ref().filter(|managed| managed.scope == scope) { + return Arc::clone(&managed.session); + } + if let Some(previous) = current.take() { + previous.session.shutdown(); + } + let session = start_managed(relay_url, keys, None); + *current = Some(ManagedSession { + scope, + session: Arc::clone(&session), + }); + session + } + + /// Leases a session for a finite request, never displacing another scope. + /// + /// Finite callers (persona catalog, unread catch-up) hold no ownership + /// proof and cannot obtain one: they are not part of the archive lifecycle. + /// So this is the non-destructive half of the split — it shares the + /// installed session when the scope matches, and otherwise runs the request + /// on a private session that the lease shuts down on drop. + /// + /// A mismatch is deliberately NOT treated as "the caller is stale". These + /// commands are not ordered against archive lifecycle in either direction: + /// a catalog fetch for the community the user just opened routinely arrives + /// *before* that community's `start_archive_sync`, while the previous + /// scope's session is still installed. From inside this lock an early + /// caller and a late one are indistinguishable — both differ from the + /// installed scope — so refusing (or fencing on a generation counter, which + /// answers the same question) would fail the current caller as often as the + /// stale one. Serving both on their own socket is correct for either, and + /// whichever is genuinely stale has its result discarded by the scope + /// re-check each command performs before returning. + /// + /// Filling an empty slot is deliberate: at startup the catalog fetch + /// commonly precedes archive sync, and installing here means the archive + /// start that follows reuses this socket instead of opening a second one. + pub(crate) async fn session(&self, relay_url: String, keys: Keys) -> SessionLease { + let scope = (relay_url.clone(), keys.public_key().to_hex()); + let mut current = self.current.lock().await; + if let Some(managed) = current.as_ref() { + return if managed.scope == scope { + SessionLease { + session: Arc::clone(&managed.session), + private: false, + } + } else { + SessionLease { + session: start_managed(relay_url, keys, None), + private: true, + } + }; + } + let session = start_managed(relay_url, keys, None); + *current = Some(ManagedSession { + scope, + session: Arc::clone(&session), + }); + SessionLease { + session, + private: false, + } + } + + /// Returns the shared session for `(relay_url, keys)` plus the archive + /// event stream, replacing any session for a different scope. + /// + /// Requires proof of archive-sync ownership because both halves are + /// destructive on entry: `ensure_session` shuts down a different scope's + /// socket, and `attach_archive` replaces the session's archive sender, so a + /// superseded caller would steal the live stream from the current owner. + /// The token is un-constructible outside `archive::sync` and holds the + /// ownership locks for its lifetime, so a stale start cannot reach this + /// call. See [`crate::archive::sync::ArchiveOwnership`]. + pub(crate) async fn archive_session( + &self, + relay_url: String, + keys: Keys, + _ownership: &crate::archive::sync::ArchiveOwnership<'_>, + ) -> (Arc, mpsc::Receiver) { + let session = self.ensure_session(relay_url, keys).await; + let event_rx = session.attach_archive().await; + (session, event_rx) + } +} + +pub(crate) struct RelaySession { + state: Arc>, + requests: Arc>>, + /// The archive is the sole persistent-event consumer. Sending through its + /// bounded channel is awaited by the socket loop, preserving the + /// backpressure required by live-only (`limit: 0`) subscriptions: dropping + /// an event here cannot be repaired by replaying it later. + archive_events: Arc>>>, + wake: mpsc::Sender<()>, + cancel: CancellationToken, +} + +struct PendingRequest { + events: Vec, + complete: oneshot::Sender, String>>, +} + +/// Desired set plus the write-time record of what has left it. +/// +/// One lock covers both because reconcile must read them together: snapshotting +/// the desired set and draining `removed` in separate acquisitions lets a +/// `set_subscriptions` land in the gap, so the drain would be consumed against +/// a stale snapshot and could reopen a subscription the caller just dropped. +#[derive(Default)] +struct SessionState { + desired: Vec, + transient: Vec, + /// Ids whose exact subscription has left `desired` since the last + /// reconcile drained this. Written here rather than derived at reconcile + /// time because reconcile cannot derive it: wakes coalesce, so a remove + /// followed by a re-add is observed as a single pass whose desired set + /// never lost the id. See the eviction table on `retries`. + removed: HashSet, +} + +impl SessionState { + /// Installs a new desired set, recording every departure. + /// + /// Returns the ids whose filter changed under a reused id — a violation of + /// the immutable-filter-per-id contract on [`Subscription::id`]. This is + /// the only place that can detect one: the write side alone holds the old + /// and new filter for an id. Behavior after a violation is deliberately + /// unspecified; detection is all this offers. + fn replace_desired(&mut self, subscriptions: Vec) -> Vec { + let mut violations = Vec::new(); + for previous in std::mem::replace(&mut self.desired, subscriptions) { + // Departure is keyed on the exact subscription, not the id alone: + // the relay replaces by id, so a changed filter retires the old + // subscription just as surely as dropping the id would, and its + // backoff must not be inherited. + let survivor = self.desired.iter().find(|next| next.id == previous.id); + if survivor.is_some_and(|next| next.filter == previous.filter) { + continue; + } + if survivor.is_some() { + violations.push(previous.id.clone()); + } + self.removed.insert(previous.id); + } + violations + } +} + +impl RelaySession { + async fn attach_archive(&self) -> mpsc::Receiver { + let (events, receiver) = mpsc::channel(256); + *self.archive_events.lock().await = Some(events); + receiver + } + + /// Fetches one finite page over this session without disturbing persistent + /// feature subscriptions. Request ids are fresh, so CLOSED/backoff history + /// can never leak between pages or into a long-lived subscription. + pub(crate) async fn fetch_events( + &self, + filter: serde_json::Value, + timeout: Duration, + ) -> Result, String> { + let id = format!("native-fetch-{}", uuid::Uuid::new_v4()); + let (complete, result) = oneshot::channel(); + self.requests.lock().await.insert( + id.clone(), + PendingRequest { + events: Vec::new(), + complete, + }, + ); + { + let mut state = self.state.lock().await; + state.transient.push(Subscription { + id: id.clone(), + filter, + }); + } + let _ = self.wake.try_send(()); + + let outcome = tokio::select! { + _ = self.cancel.cancelled() => Err("relay session cancelled".to_string()), + value = tokio::time::timeout(timeout, result) => match value { + Ok(Ok(value)) => value, + Ok(Err(_)) => Err("relay request ended before EOSE".to_string()), + Err(_) => Err("relay request timed out".to_string()), + } + }; + self.finish_request(&id).await; + outcome + } + + async fn finish_request(&self, id: &str) { + self.requests.lock().await.remove(id); + let mut state = self.state.lock().await; + state.transient.retain(|subscription| subscription.id != id); + state.removed.insert(id.to_string()); + drop(state); + let _ = self.wake.try_send(()); + } + + /// Replaces the desired subscription set and wakes the loop to reconcile. + /// + /// Reconciliation is declarative rather than incremental: callers state + /// what they want and the loop diffs. An incremental add/remove API would + /// have to be replayed in order across a reconnect, which is exactly the + /// bug class this avoids. + /// + /// It is also why `open` needs no revision/generation guard. Every + /// reconcile re-reads the current desired set, so a change that lands + /// mid-pass is picked up by the wake it queued rather than having to + /// invalidate work already in flight. + /// + /// That argument holds only for state that is a function of the final + /// desired set. It does not hold for `retries`, whose validity depends on + /// the id having been *continuously* desired — history that coalescing + /// erases. So departures are recorded here, at the only point that can see + /// them. + pub(crate) async fn set_subscriptions(&self, subscriptions: Vec) { + let violations = self.state.lock().await.replace_desired(subscriptions); + for id in violations { + eprintln!( + "buzz-desktop: native_relay_client: subscription {id} changed filter under a \ + reused id; ids must be derived from their filter" + ); + } + // A full channel already means "reconcile pending", so a failed send + // is success: the loop has not yet consumed the previous wake. + let _ = self.wake.try_send(()); + } + + pub(crate) fn shutdown(&self) { + self.cancel.cancel(); + } +} + +/// Starts a session against `relay_url` authenticated as `keys`. +/// +/// Returns the handle plus the receiver for matched events. The session +/// reconnects on drop with exponential backoff and resubscribes the current +/// desired set — never a snapshot captured at connect time, so a subscription +/// change during an outage is honored by the reconnect that follows. +#[cfg(test)] +pub(crate) async fn start( + relay_url: String, + keys: Keys, + auth_tag: Option, +) -> (Arc, mpsc::Receiver) { + let session = start_managed(relay_url, keys, auth_tag); + let events = session.attach_archive().await; + (session, events) +} + +fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> Arc { + let (wake, wake_rx) = mpsc::channel(1); + let session = Arc::new(RelaySession { + state: Arc::new(Mutex::new(SessionState::default())), + requests: Arc::new(Mutex::new(HashMap::new())), + archive_events: Arc::new(Mutex::new(None)), + wake, + cancel: CancellationToken::new(), + }); + + tauri::async_runtime::spawn(run_session( + relay_url, + keys, + auth_tag, + Arc::clone(&session), + wake_rx, + )); + + session +} + +async fn run_session( + relay_url: String, + keys: Keys, + auth_tag: Option, + session: Arc, + mut wake_rx: mpsc::Receiver<()>, +) { + let mut delay = RECONNECT_BASE_DELAY; + loop { + if session.cancel.is_cancelled() { + return; + } + + match NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()).await { + Ok(conn) => { + // A connection that authenticated is healthy regardless of how + // long it then lived, so backoff resets here rather than on + // clean exit — a socket that drops after one event must not + // inherit the previous failure's delay. + delay = RECONNECT_BASE_DELAY; + run_connection(conn, &session, &mut wake_rx).await; + } + Err(error) => { + eprintln!("buzz-desktop: native_relay_client: connect failed: {error}"); + } + } + + if session.cancel.is_cancelled() { + return; + } + tokio::select! { + _ = session.cancel.cancelled() => return, + _ = tokio::time::sleep(delay) => {} + } + delay = (delay * 2).min(RECONNECT_MAX_DELAY); + } +} + +/// Drives one connected socket until it drops or the session is cancelled. +async fn run_connection( + mut conn: NostrWsConnection, + session: &RelaySession, + wake_rx: &mut mpsc::Receiver<()>, +) { + // Subscription ids currently open ON THIS SOCKET. Deliberately local: a new + // socket has none, so reconnect resubscribes the full desired set without + // any explicit "resubscribe" path that could drift from the normal one. + let mut open: HashMap = HashMap::new(); + // Reopen schedule for ids the relay CLOSED, keyed the same way and equally + // local — for the same reason and one more. Backoff state cannot live in + // `desired`: that set is reloaded from SQLite by the archive task, so a + // subscription deleted there is re-added by the next reload. The JS port + // could delete from its subscription map because that map WAS the desired + // set; here the two are separate, and only this one is per-socket. + // + // An entry is valid only while its id has been continuously desired since + // the CLOSED that created it, which makes eviction the whole design: + // + // | Eviction trigger | Where | Why it is the right edge | + // |---|---|---| + // | event delivered | the EVENT arm below | the subscription is demonstrably healthy | + // | EOSE | the EOSE arm below | the relay served it, so the cause has cleared | + // | id leaves the desired set, including intermediate states the loop never observes | `SessionState::removed`, drained at the top of `reconcile` | validity depends on history, and coalesced wakes erase it — see `set_subscriptions` | + // | socket drops | this map is per-connection | relay policy and our own auth can change across a reconnect | + // + // Reconcile deliberately does NOT also prune ids merely absent from the + // desired snapshot. That clause is unreachable: entries are minted only for + // ids present in `open` (the CLOSED arm's guard below), ids enter `open` + // only from a desired snapshot, and every departure from desired is + // recorded at write time. It would kill no mutant these tests do not + // already kill, while masking the drain that does the work. + let mut retries: HashMap = HashMap::new(); + + if !reconcile(&mut conn, session, &mut open, &mut retries).await { + return; + } + + loop { + // Earliest pending reopen, or `None` when nothing is scheduled. The arm + // below is disabled in that case rather than sleeping on a far-future + // instant, so an idle connection never wakes on this branch. + let retry_at = retries.values().filter_map(|retry| retry.due_at).min(); + + tokio::select! { + _ = session.cancel.cancelled() => { + let _ = conn.disconnect().await; + return; + } + Some(()) = wake_rx.recv() => { + if !reconcile(&mut conn, session, &mut open, &mut retries).await { + return; + } + } + // The edge that makes a CLOSED recoverable. Without it, nothing + // re-enters `reconcile` unless the desired set changes again, and + // for a stable set that means the subscription is dead for the life + // of the socket. + _ = tokio::time::sleep_until(retry_at.unwrap_or_else(Instant::now)), + if retry_at.is_some() => + { + for retry in retries.values_mut() { + if retry.due_at.is_some_and(|due| due <= Instant::now()) { + retry.due_at = None; + } + } + if !reconcile(&mut conn, session, &mut open, &mut retries).await { + return; + } + } + message = conn.next_event(READ_TIMEOUT) => { + match message { + Ok(RelayMessage::Event { subscription_id, event }) => { + // Only forward events for a subscription we still want. + // A CLOSE races in flight with events already queued at + // the relay, so this is the last line of defense + // against delivering out-of-scope events after a change. + // + // This arm drops rather than heals: an event for an id + // we do not have open is generation-ambiguous — it may + // predate a deletion — so it cannot serve as the fence + // an EOSE does. The EOSE arm below is where an + // open-map mismatch is repaired. + if !open.contains_key(&subscription_id) { + continue; + } + let pending = session + .requests + .lock() + .await + .contains_key(&subscription_id); + if pending { + // Reject forged finite-request events before + // retaining them, bounding memory at the transport + // seam. The catalog re-verifies defensively before + // head selection. + if event.verify().is_err() { + continue; + } + if let Some(request) = session + .requests + .lock() + .await + .get_mut(&subscription_id) + { + request.events.push(*event); + } + continue; + } + // Delivery proves the subscription is healthy, so any + // accumulated backoff for it is stale. Mirrors the JS + // port's per-event `closedRetryAttempt = 0`. + retries.remove(&subscription_id); + // Persistent archive subscriptions are live-only, so + // losing an event cannot be repaired with a later REQ. + // Await the bounded archive channel to push back on the + // socket read loop instead. Finite catalog requests are + // fulfilled above and never enter this channel. + // Because this await is outside the session-cancel select, + // teardown depends on `run_sync` dropping its receiver; moving + // ownership or spawning that teardown can strand the socket loop. + let sender = session.archive_events.lock().await.clone(); + if let Some(sender) = sender { + let _ = sender + .send(MatchedEvent { + subscription_id, + event, + }) + .await; + } + } + Ok(RelayMessage::Closed { subscription_id, message }) => { + // The relay dropped it; forget it so a reopen re-sends + // REQ rather than assuming it is still live. + // + // A CLOSED for a subscription this socket is not + // running is stale — our own CLOSE raced it, exactly as + // the EVENT arm above guards. Minting retry state from + // it would resurrect the entry the drain just pruned, + // and nothing would evict it: the id is gone from + // `desired`, so no future removal can record it again. + if open.remove(&subscription_id).is_none() { + continue; + } + if let Some(request) = session.requests.lock().await.remove(&subscription_id) { + let _ = request.complete.send(Err(format!("relay closed request: {message}"))); + let mut state = session.state.lock().await; + state.transient.retain(|subscription| subscription.id != subscription_id); + state.removed.insert(subscription_id.clone()); + drop(state); + let _ = session.wake.try_send(()); + continue; + } + let retry = retries.entry(subscription_id.clone()).or_default(); + retry.schedule(&message); + eprintln!( + "buzz-desktop: native_relay_client: relay closed {subscription_id}: {message}" + ); + } + Ok(RelayMessage::Eose { subscription_id }) => { + // The relay served this subscription, so whatever + // caused an earlier CLOSED has cleared. Same reset the + // JS port performs in `handleSubscriptionEose`, and it + // is what keeps an intermittent relay from ratcheting + // its way to the 30s ceiling and staying there. + let was_open = open.contains_key(&subscription_id); + if let Some(request) = session.requests.lock().await.remove(&subscription_id) { + let _ = request.complete.send(Ok(request.events)); + let mut state = session.state.lock().await; + state.transient.retain(|subscription| subscription.id != subscription_id); + state.removed.insert(subscription_id.clone()); + drop(state); + let _ = session.wake.try_send(()); + continue; + } + retries.remove(&subscription_id); + // The relay is running a subscription this socket does + // not think is open, so the two disagree. EOSE is the + // fence that makes this recoverable: frames on one + // socket are ordered, so a stale CLOSED from a previous + // generation of this id necessarily precedes the + // recreated generation's EOSE. Without this wake a + // terminal stale CLOSED is a blackhole — it clears + // `open`, sets no `due_at`, and so leaves no edge back + // into reconcile while the relay delivers events the + // EVENT arm silently drops. + // + // Deliberately not on the EVENT arm: an event for an + // absent id may belong to the old generation, so it is + // not a fence. Converges rather than storms — the + // reconcile this triggers reopens the id, and the + // replacement EOSE then finds it open. + if !was_open { + let _ = session.wake.try_send(()); + } + } + Ok(_) => {} + Err(error) => { + if !is_read_timeout(&error) { + eprintln!("buzz-desktop: native_relay_client: read failed: {error}"); + return; + } + } + } + } + } + } +} + +/// Brings the socket's open subscriptions in line with the desired set. +/// +/// Returns false when the socket failed and the caller should reconnect. +async fn reconcile( + conn: &mut NostrWsConnection, + session: &RelaySession, + open: &mut HashMap, + retries: &mut HashMap, +) -> bool { + // Snapshot and drain in ONE acquisition. Taking them separately would let a + // `set_subscriptions` land in the gap, spending its removal against a + // desired set captured before it — reopening a subscription the caller had + // just dropped, with no record left to catch it on the next pass. + let (desired, removed) = { + let mut state = session.state.lock().await; + let removed = std::mem::take(&mut state.removed); + ( + state + .desired + .iter() + .chain(&state.transient) + .cloned() + .collect::>(), + removed, + ) + }; + + // Retry state is only valid while its id has been continuously desired + // since the CLOSED that created it. Every departure is here even when the + // id is desired again now, because the loop cannot see the gap: coalesced + // wakes make remove-then-re-add one pass whose desired set never lost it. + for id in removed { + retries.remove(&id); + } + + for id in open.keys().cloned().collect::>() { + if desired.iter().any(|s| s.id == id) { + continue; + } + if conn + .send_raw(&serde_json::json!(["CLOSE", id])) + .await + .is_err() + { + return false; + } + open.remove(&id); + } + + for sub in desired { + // A filter change under the same id must reopen, not be skipped: the + // relay replaces a subscription by id, so re-sending REQ is the update. + if open.get(&sub.id) == Some(&sub.filter) { + continue; + } + // Held back by a CLOSED: either waiting out its backoff, or terminal + // and never to be retried on this socket. Both are `is_blocked`, which + // is what keeps a relay that rejects on policy from being re-asked at + // the speed of the event loop. + if retries.get(&sub.id).is_some_and(ClosedRetry::is_blocked) { + continue; + } + if conn + .send_raw(&serde_json::json!(["REQ", sub.id, sub.filter])) + .await + .is_err() + { + return false; + } + open.insert(sub.id, sub.filter); + } + + true +} + +/// Reopen schedule for one subscription the relay CLOSED. +#[derive(Default)] +struct ClosedRetry { + /// When the reopen is due. `None` means "not waiting": either the delay has + /// elapsed and reconcile may re-send, or `terminal` latched. + due_at: Option, + /// Consecutive CLOSEDs, driving the exponential delay. Reset by a delivered + /// event or EOSE, both of which drop the whole entry. + attempts: u32, + /// The relay rejected this filter for a reason retrying cannot change. + terminal: bool, +} + +impl ClosedRetry { + /// True while reconcile must leave this subscription closed. + fn is_blocked(&self) -> bool { + self.terminal || self.due_at.is_some_and(|due| due > Instant::now()) + } + + /// Records a CLOSED and schedules the reopen its class calls for. + fn schedule(&mut self, message: &str) { + match classify_closed(message) { + // Auth, access, or filter errors will fail identically until + // something outside this socket changes, so stop asking. Scoped to + // this socket by construction: the state lives in `run_connection`, + // so a reconnect retries once through the normal path. That is + // deliberate — relay policy and our own auth can change across a + // reconnect, and one REQ per reconnect is bounded. + ClosedClass::Terminal => { + self.terminal = true; + self.due_at = None; + } + ClosedClass::RateLimited => { + // Arm the process-wide gate so the HTTP bridge backs off too, + // rather than keeping a second private notion of the same + // relay's back-pressure. + let hint = parse_retry_in_seconds(message); + crate::relay_admission::activate_rate_limit(hint); + let hinted = hint + .map(Duration::from_secs) + .unwrap_or(CLOSED_RATE_LIMIT_DEFAULT); + // The longer of the two: a short hint must not undercut a + // backoff already grown by repeated rejections. + self.due_at = Some(Instant::now() + self.backoff().max(hinted)); + self.attempts = self.attempts.saturating_add(1); + } + ClosedClass::Retryable => { + self.due_at = Some(Instant::now() + self.backoff()); + self.attempts = self.attempts.saturating_add(1); + } + } + } + + /// Exponential delay for the current attempt, capped. The shift is bounded + /// before it is taken, so a long-lived rejection cannot overflow its way + /// back down to a short delay. + fn backoff(&self) -> Duration { + CLOSED_RETRY_BASE_DELAY + .saturating_mul(1_u32 << self.attempts.min(16)) + .min(CLOSED_RETRY_MAX_DELAY) + } +} + +/// How a CLOSED message should be handled. +/// +/// Ported from `classifyRelayClosed` in `relayClosedPolicy.ts`; the prefixes are +/// the relay's own machine-readable NIP-01 classes and must stay in step with +/// that file. +#[derive(Debug, PartialEq, Eq)] +enum ClosedClass { + Retryable, + RateLimited, + Terminal, +} + +fn classify_closed(message: &str) -> ClosedClass { + let normalized = message.trim().to_ascii_lowercase(); + if normalized.starts_with("rate-limited:") { + return ClosedClass::RateLimited; + } + // `auth-required:` is deliberately absent, i.e. retryable: it occurs + // transiently when a REQ races the AUTH handshake after a reconnect, and + // the backoff reopen re-sends once authenticated. A session that is + // genuinely unauthenticated fails at `connect_authenticated` instead, so + // this cannot loop forever. + if [ + "restricted:", + "blocked:", + "invalid:", + "pow:", + "duplicate:", + "unsupported:", + "error: mixed search", + "error: too many subscriptions", + ] + .iter() + .any(|prefix| normalized.starts_with(prefix)) + { + return ClosedClass::Terminal; + } + ClosedClass::Retryable +} + +/// Parses the relay's canonical `retry in Ns` hint. Same format the HTTP bridge +/// parses in `relay::extract_retry_in_hint`. +fn parse_retry_in_seconds(message: &str) -> Option { + let after = &message[message.find("retry in ")? + "retry in ".len()..]; + after + .chars() + .take_while(char::is_ascii_digit) + .collect::() + .parse() + .ok() +} + +/// A lapsed read is an idle relay, not a failure. Distinguished by variant +/// rather than by message text so a reworded error cannot turn every idle +/// period into a reconnect storm. +fn is_read_timeout(error: &buzz_ws_client_pkg::WsClientError) -> bool { + matches!(error, buzz_ws_client_pkg::WsClientError::Timeout) +} + +#[cfg(test)] +#[path = "native_relay_client_tests.rs"] +mod closed_recovery_tests; + +#[cfg(test)] +mod relay_backed_tests { + use super::*; + use nostr::{EventBuilder, Tag}; + + /// Relay-backed proof that the session's wire shape is one a real relay + /// accepts and answers. + /// + /// Every other test in this commit drives `run_sync` through a fake + /// [`crate::archive::sync::ArchiveSyncIo`], which is the right default: + /// batching and demultiplexing are the logic worth pinning, and they must + /// not need a socket. But a fake cannot fail the one way this layer + /// actually can — by sending a REQ the relay rejects, or by filtering on a + /// tag key that matches nothing. The JS manager's filters were validated by + /// years of production traffic; this port's have been validated by my + /// reading of that code, which is exactly the claim a real relay can check + /// and I cannot. + /// + /// `#[ignore]`d because it needs a relay on `BUZZ_TEST_RELAY_URL`. Run: + /// + /// ```text + /// ./scripts/start-isolated-test-relay.sh # ws://localhost:3030 + /// BUZZ_TEST_RELAY_URL=ws://localhost:3030 \ + /// cargo test -p buzz-desktop -- --ignored archive_sync_session + /// ``` + #[tokio::test] + #[ignore = "requires a local relay (set BUZZ_TEST_RELAY_URL)"] + async fn archive_sync_session_receives_live_events_from_a_real_relay() { + let Ok(relay_url) = std::env::var("BUZZ_TEST_RELAY_URL") else { + panic!("set BUZZ_TEST_RELAY_URL to a running relay"); + }; + + let owner = Keys::generate(); + let author = Keys::generate(); + let owner_pk = owner.public_key(); + + // Kind 1 rather than the archive's own kind 24200. Publishing a real + // observer frame requires a registered agent-owner binding in the + // relay's database — a relay ACL concern that says nothing about this + // layer. What this test can prove, and what no fake can, is the wire + // shape: that the `#p` tag key and the `limit: 0` live tail produce a + // REQ a real relay accepts and answers. Scope demultiplexing on the + // archive side is covered in `archive/sync_tests.rs`. + let (session, mut events) = start(relay_url.clone(), owner.clone(), None).await; + session + .set_subscriptions(vec![Subscription { + id: "archive:owner_p:test".to_string(), + filter: serde_json::json!({ + "kinds": [1], + "limit": 0, + "#p": [owner_pk.to_hex()], + }), + }]) + .await; + + // The subscription must be live at the relay before the event is + // published. A `limit: 0` filter is a live tail: it replays nothing, + // so anything published into a not-yet-open subscription is missed. + // That is the same ordering hazard the renderer start gate exists to + // prevent for the ephemeral archive kind. + tokio::time::sleep(Duration::from_secs(1)).await; + + let mut publisher = NostrWsConnection::connect_authenticated(&relay_url, &author, None) + .await + .expect("publisher connect"); + let frame = EventBuilder::text_note("archive-sync-probe") + .tag(Tag::public_key(owner_pk)) + .sign_with_keys(&author) + .expect("sign event"); + let frame_id = frame.id.to_hex(); + let ok = publisher.send_event(frame).await.expect("publish frame"); + assert!( + ok.accepted, + "relay rejected the observer frame, so a delivery timeout below would \ + blame the subscription for a publish failure: {}", + ok.message + ); + + let received = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .expect("timed out waiting for the relay to deliver the frame") + .expect("session channel closed"); + + assert_eq!( + received.subscription_id, "archive:owner_p:test", + "delivered event must carry the subscription id the loop demultiplexes on" + ); + assert_eq!( + received.event.id.to_hex(), + frame_id, + "must deliver the published frame" + ); + + session.shutdown(); + } +} diff --git a/desktop/src-tauri/src/native_relay_client_tests.rs b/desktop/src-tauri/src/native_relay_client_tests.rs new file mode 100644 index 0000000000..96ec39a4bf --- /dev/null +++ b/desktop/src-tauri/src/native_relay_client_tests.rs @@ -0,0 +1,896 @@ +//! Lifecycle tests for [`super`]'s CLOSED recovery and subscription bookkeeping. +//! +//! Split out of `native_relay_client.rs` to keep that file under the desktop +//! file-size ratchet. Same `#[path]` sibling-module convention as +//! `archive/sync.rs` and its `sync_tests.rs`. + +use super::*; +use futures_util::{SinkExt, StreamExt}; +use nostr::EventBuilder; +use tokio_tungstenite::tungstenite::protocol::Message; + +/// The subscription id every test below drives. +const PROBE_ID: &str = "archive:probe"; + +/// Minimal relay that completes the NIP-42 handshake, records every REQ, +/// and sends a CLOSED only when the test asks it to. +/// +/// A real socket rather than a fake `NostrWsConnection`, because the bug +/// this covers lives in the lifecycle between frames — the loop's only +/// reconcile triggers — and a fake that hands the loop a `Closed` value +/// cannot show that a REQ went back out over the wire afterwards. Same +/// `accept_async` stub shape as `native_websocket.rs`'s live-TCP tests. +/// +/// CLOSED is test-driven rather than a scripted reply to the first REQ so +/// the test can wait for the session to go quiet first. `set_subscriptions` +/// queues a wake that may still be pending when an immediate CLOSED lands, +/// and that wake reopens the subscription on its own — which made the first +/// version of this test pass against the unfixed code. +/// +/// `frames` reports REQ and CLOSE in wire order, not REQ alone: the +/// lifecycle tests below assert that a CLOSE was sent before the REQ that +/// follows it, which a REQ-only channel cannot express. +async fn stub_relay() -> (String, mpsc::Receiver, mpsc::Sender) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stub relay"); + let address = listener.local_addr().expect("stub relay address"); + let (req_tx, req_rx) = mpsc::channel(16); + let (closed_tx, mut closed_rx) = mpsc::channel::(4); + + tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("websocket handshake"); + + socket + .send(Message::Text(r#"["AUTH","stub-challenge"]"#.into())) + .await + .expect("send challenge"); + + loop { + tokio::select! { + incoming = socket.next() => { + let Some(Ok(Message::Text(text))) = incoming else { return }; + let Ok(frame) = serde_json::from_str::(&text) else { + continue; + }; + match frame[0].as_str() { + Some("AUTH") => { + let id = frame[1]["id"].as_str().unwrap_or_default(); + socket + .send(Message::Text( + serde_json::json!(["OK", id, true, ""]).to_string().into(), + )) + .await + .expect("send auth ok"); + } + Some("REQ") => { + let id = frame[1].as_str().unwrap_or_default().to_string(); + if req_tx.send(Frame::Req(id)).await.is_err() { + return; + } + } + Some("CLOSE") => { + let id = frame[1].as_str().unwrap_or_default().to_string(); + if req_tx.send(Frame::Close(id)).await.is_err() { + return; + } + } + _ => {} + } + } + Some(command) = closed_rx.recv() => { + let frame = match command { + StubCommand::Closed(id, message) => { + serde_json::json!(["CLOSED", id, message]) + } + StubCommand::Eose(id) => serde_json::json!(["EOSE", id]), + StubCommand::Event(id, event) => { + serde_json::json!(["EVENT", id, event]) + } + }; + socket + .send(Message::Text(frame.to_string().into())) + .await + .expect("send stub frame"); + } + } + } + }); + + (format!("ws://{address}"), req_rx, closed_tx) +} + +/// A client→relay frame the stub observed, in wire order. +#[derive(Debug, PartialEq, Eq)] +enum Frame { + Req(String), + Close(String), +} + +/// A relay→client frame the test asks the stub to emit. +enum StubCommand { + Closed(String, String), + Eose(String), + Event(String, serde_json::Value), +} + +fn probe_subscription() -> Subscription { + Subscription { + id: PROBE_ID.to_string(), + filter: serde_json::json!({ "kinds": [1], "limit": 0 }), + } +} + +async fn next_frame(frames: &mut mpsc::Receiver, label: &str) -> Frame { + tokio::time::timeout(Duration::from_secs(10), frames.recv()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {label}")) + .unwrap_or_else(|| panic!("stub relay closed before {label}")) +} + +/// Waits for the next REQ, tolerating the CLOSE frames a reconcile sends +/// first. Asserting on `Frame::Req` directly would couple every test to +/// whether a particular reconcile also had cleanup to do. +async fn next_req(frames: &mut mpsc::Receiver, label: &str) -> String { + loop { + if let Frame::Req(id) = next_frame(frames, label).await { + return id; + } + } +} + +/// Waits out the wake `set_subscriptions` queued, so a CLOSED sent after +/// this cannot be reopened by anything but the CLOSED path itself. +/// +/// A pending wake is harmless while the subscription is still open — that +/// reconcile is a no-op — so draining it before the CLOSED is what makes +/// the assertion below attributable. +async fn settle() { + tokio::time::sleep(Duration::from_millis(500)).await; +} + +/// C's acceptance edge: a finite request shares the authenticated real socket +/// with a persistent subscription, completes on wire EOSE, and does not steal +/// later persistent delivery. A fake connection cannot establish any of those +/// transport/lifetime properties. +#[tokio::test] +async fn finite_fetch_multiplexes_with_persistent_delivery_on_a_real_websocket() { + let (relay_url, mut frames, commands) = stub_relay().await; + let (session, mut events) = start(relay_url, Keys::generate(), None).await; + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the persistent REQ").await, PROBE_ID); + + let fetch = { + let session = Arc::clone(&session); + tokio::spawn(async move { + session + .fetch_events( + serde_json::json!({ "kinds": [buzz_core_pkg::kind::KIND_PERSONA], "limit": 500 }), + Duration::from_secs(10), + ) + .await + }) + }; + let request_id = next_req(&mut frames, "the finite fetch REQ").await; + assert_ne!(request_id, PROBE_ID); + + let relay_keys = Keys::generate(); + let mut forged = EventBuilder::text_note("forged catalog page event") + .sign_with_keys(&relay_keys) + .unwrap(); + forged.content = "tampered after signing".into(); + commands + .send(StubCommand::Event( + request_id.clone(), + serde_json::to_value(forged).unwrap(), + )) + .await + .unwrap(); + let fetched = EventBuilder::text_note("catalog page event") + .sign_with_keys(&relay_keys) + .unwrap(); + commands + .send(StubCommand::Event( + request_id.clone(), + serde_json::to_value(&fetched).unwrap(), + )) + .await + .unwrap(); + commands + .send(StubCommand::Eose(request_id.clone())) + .await + .unwrap(); + + assert_eq!(fetch.await.unwrap().unwrap(), vec![fetched]); + assert_eq!( + next_frame(&mut frames, "finite fetch CLOSE").await, + Frame::Close(request_id) + ); + + let persistent = EventBuilder::text_note("persistent event after fetch") + .sign_with_keys(&relay_keys) + .unwrap(); + commands + .send(StubCommand::Event( + PROBE_ID.into(), + serde_json::to_value(&persistent).unwrap(), + )) + .await + .unwrap(); + let delivered = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(delivered.subscription_id, PROBE_ID); + assert_eq!(*delivered.event, persistent); + session.shutdown(); +} + +async fn run_persistent_burst(drain_concurrently: bool) { + const BURST: usize = 1_200; + + let (relay_url, mut frames, commands) = stub_relay().await; + let (session, mut events) = start(relay_url, Keys::generate(), None).await; + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the burst REQ").await, PROBE_ID); + + let relay_keys = Keys::generate(); + let event = EventBuilder::text_note("persistent burst event") + .sign_with_keys(&relay_keys) + .unwrap(); + let send_burst = tokio::spawn({ + let commands = commands.clone(); + let event = serde_json::to_value(&event).unwrap(); + async move { + for _ in 0..BURST { + commands + .send(StubCommand::Event(PROBE_ID.into(), event.clone())) + .await + .unwrap(); + } + } + }); + + if !drain_concurrently { + // Let the bounded archive channel fill before draining. The socket loop + // must wait here rather than evicting live-only events. + tokio::time::sleep(Duration::from_millis(100)).await; + } + for _ in 0..BURST { + tokio::time::timeout(Duration::from_secs(60), events.recv()) + .await + .expect("timed out draining persistent burst") + .expect("archive receiver closed during persistent burst"); + } + send_burst.await.unwrap(); + + let after = EventBuilder::text_note("persistent event after burst") + .sign_with_keys(&relay_keys) + .unwrap(); + commands + .send(StubCommand::Event( + PROBE_ID.into(), + serde_json::to_value(&after).unwrap(), + )) + .await + .unwrap(); + let delivered = tokio::time::timeout(Duration::from_secs(60), events.recv()) + .await + .expect("timed out after persistent burst") + .expect("archive receiver closed after persistent burst"); + assert_eq!(*delivered.event, after); + session.shutdown(); +} + +/// Persistent archive subscriptions use `limit: 0`, so an event lost during a +/// slow-consumer burst cannot be replayed. Both a fast control and a receiver +/// that starts late must therefore get the whole burst and remain live after it. +#[tokio::test] +async fn persistent_delivery_applies_backpressure_without_losing_a_burst() { + run_persistent_burst(true).await; + run_persistent_burst(false).await; +} + +/// The blocker: a CLOSED with the desired set never changing again must +/// still reopen the subscription. +/// +/// Before the fix the loop removed the id from `open` and waited on a wake +/// that only `set_subscriptions` can produce, so a stable desired set left +/// the subscription dead for the life of the socket — silent permanent +/// loss for ephemeral kind 24200. +#[tokio::test] +async fn a_closed_subscription_reopens_without_a_desired_set_change() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + // Retryable class, sent once: the reopen is answered normally, so a + // failure here means "never retried" rather than "retried into another + // rejection". + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "error: temporary".into(), + )) + .await + .expect("stub relay accepts the closed command"); + + // No `set_subscriptions` between the two REQs: the reopen must come + // from the CLOSED itself, which is exactly the edge that was missing. + assert_eq!(next_req(&mut frames, "the reopened REQ").await, PROBE_ID); + + session.shutdown(); +} + +/// A relay that rejects on policy must not be re-asked in a tight loop. +#[tokio::test] +async fn a_terminal_closed_is_not_retried_on_the_same_socket() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "restricted: not authorized".into(), + )) + .await + .expect("stub relay accepts the closed command"); + + // Long enough that a retryable class (1s base) would have reopened + // several times, so this asserts suppression rather than just slowness. + let retried = tokio::time::timeout(Duration::from_secs(5), frames.recv()).await; + assert!( + retried.is_err(), + "a terminal CLOSED must not be retried on this socket, got {retried:?}" + ); + + session.shutdown(); +} + +/// M18: a subscription deleted and recreated must get a fresh REQ, even +/// though its terminal latch says never to retry. +/// +/// The latch is scoped to the subscription that earned it. Recreating the +/// id is a new subscription that happens to share a name — `archive::sync` +/// derives the id from scope and kinds, so a delete/recreate of the same +/// saved subscription produces a byte-identical id and would otherwise +/// inherit a permanent suppression for the life of the socket. +#[tokio::test] +async fn a_recreated_subscription_does_not_inherit_a_terminal_latch() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "restricted: not authorized".into(), + )) + .await + .expect("stub relay accepts the closed command"); + settle().await; + + // Delete, then recreate — each observed as its own reconcile. + session.set_subscriptions(vec![]).await; + settle().await; + session.set_subscriptions(vec![probe_subscription()]).await; + + assert_eq!( + next_req(&mut frames, "the REQ for the recreated subscription").await, + PROBE_ID, + ); + + session.shutdown(); +} + +/// M19: the same schedule, with both writes landing before the loop +/// consumes its single wake. +/// +/// This is the mutant that discriminates the mechanism. The wake channel +/// has capacity 1 and `set_subscriptions` only ever queues "reconcile +/// pending", so the delete and the recreate collapse into ONE observed +/// reconcile whose desired set already contains the id again. A prune that +/// reads only the current desired set never sees the id absent and leaves +/// the latch in place — passing the test above while failing this one. +/// The departure is therefore recorded at write time, where it is visible. +/// +/// No `settle()` between the two writes: that gap is the whole point, and +/// adding one would silently convert this into a duplicate of M18. +#[tokio::test] +async fn a_recreated_subscription_is_not_suppressed_when_the_writes_coalesce() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "restricted: not authorized".into(), + )) + .await + .expect("stub relay accepts the closed command"); + settle().await; + + session.set_subscriptions(vec![]).await; + session.set_subscriptions(vec![probe_subscription()]).await; + + assert_eq!( + next_req(&mut frames, "the REQ for the recreated subscription").await, + PROBE_ID, + ); + + session.shutdown(); +} + +/// M20: pruning must be scoped to departures, not run every pass. +/// +/// A reconcile triggered while the id is still desired must leave its +/// pending backoff alone. Clearing wholesale would collapse the CLOSED +/// backoff — every unrelated subscription change would re-ask a relay that +/// just rejected us, at the speed of the event loop. +#[tokio::test] +async fn a_reconcile_preserves_the_backoff_of_a_still_desired_subscription() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + // Rate-limited: a long, unambiguously pending backoff, so a reopen + // inside the window is the prune and not the timer. + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "rate-limited: slow down; retry in 30s".into(), + )) + .await + .expect("stub relay accepts the closed command"); + settle().await; + + // A change that adds an unrelated subscription. The probe never leaves + // the desired set, so its backoff must survive this reconcile. + session + .set_subscriptions(vec![ + probe_subscription(), + Subscription { + id: "archive:other".to_string(), + filter: serde_json::json!({ "kinds": [7], "limit": 0 }), + }, + ]) + .await; + + assert_eq!( + next_req(&mut frames, "the REQ for the newly added subscription").await, + "archive:other", + ); + let reopened = tokio::time::timeout(Duration::from_secs(3), frames.recv()).await; + assert!( + reopened.is_err(), + "a still-desired subscription must keep its pending backoff across a \ + reconcile, got {reopened:?}" + ); + + crate::relay_admission::reset_rate_limit_gate(); + session.shutdown(); +} + +/// M21: a CLOSED that arrives after we stopped running the subscription is +/// stale and must mint nothing. +/// +/// Our CLOSE races the relay's in-flight frames — the EVENT arm already +/// guards this. Without the same guard on CLOSED, the frame recreates the +/// retry entry the drain just removed, and nothing can evict it: the id is +/// gone from the desired set, so no future departure records it again. +#[tokio::test] +async fn a_closed_arriving_after_removal_does_not_mint_retry_state() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + // Delete first, and wait for our CLOSE to reach the wire: that ordering + // is what makes the CLOSED below arrive after the drain rather than + // before it, which is the schedule M18 and M19 do not cover. + session.set_subscriptions(vec![]).await; + assert_eq!( + next_frame(&mut frames, "the CLOSE for the deleted subscription").await, + Frame::Close(PROBE_ID.to_string()), + ); + + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "restricted: not authorized".into(), + )) + .await + .expect("stub relay accepts the closed command"); + settle().await; + + session.set_subscriptions(vec![probe_subscription()]).await; + + assert_eq!( + next_req(&mut frames, "the REQ for the recreated subscription").await, + PROBE_ID, + ); + + session.shutdown(); +} + +/// M22: a stale *terminal* CLOSED landing after the id was recreated must +/// not blackhole the live subscription. +/// +/// This one survives every defense above. The CLOSED is legitimately +/// attributed — the id is open again, so the M21 guard passes it — and +/// terminal means no `due_at`, so the timer arm is disabled and no wake is +/// pending. `open` loses the id while the relay keeps delivering, and the +/// EVENT arm drops every frame in silence. +/// +/// EOSE is the recovery edge because it is the only ordered fence +/// available: frames on one socket are totally ordered, so the previous +/// generation's CLOSED necessarily precedes the new generation's EOSE. +#[tokio::test] +async fn a_stale_terminal_closed_does_not_blackhole_a_recreated_subscription() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, mut events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + // Delete and recreate, so the id is open again under a new generation. + session.set_subscriptions(vec![]).await; + assert_eq!( + next_frame(&mut frames, "the CLOSE for the deleted subscription").await, + Frame::Close(PROBE_ID.to_string()), + ); + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!( + next_req(&mut frames, "the REQ for the recreated subscription").await, + PROBE_ID, + ); + settle().await; + + // The old generation's terminal CLOSED, delayed past the new REQ. + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "restricted: not authorized".into(), + )) + .await + .expect("stub relay accepts the closed command"); + // The new generation's EOSE, which the wire orders after it. + closed + .send(StubCommand::Eose(PROBE_ID.into())) + .await + .expect("stub relay accepts the eose command"); + + // The EOSE found the id closed, so it must drive a reconcile that + // reopens it. Nothing else can: terminal schedules no timer, and the + // desired set is stable. + assert_eq!( + next_req(&mut frames, "the REQ healing the open-map mismatch").await, + PROBE_ID, + ); + + // And the heal converges rather than storming: the replacement EOSE + // finds the id open, so it wakes nothing. + closed + .send(StubCommand::Eose(PROBE_ID.into())) + .await + .expect("stub relay accepts the second eose command"); + let extra = tokio::time::timeout(Duration::from_secs(3), frames.recv()).await; + assert!( + extra.is_err(), + "an EOSE for an already-open subscription must not re-reconcile, got {extra:?}" + ); + + // The point of the heal: events flow again. + let event = EventBuilder::text_note("post-heal") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let event_id = event.id.to_hex(); + closed + .send(StubCommand::Event( + PROBE_ID.into(), + serde_json::to_value(&event).expect("serialize event"), + )) + .await + .expect("stub relay accepts the event command"); + + let delivered = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .expect("timed out waiting for an event after the heal") + .expect("session channel closed"); + assert_eq!( + delivered.event.id.to_hex(), + event_id, + "events must flow again once the open map is healed" + ); + + session.shutdown(); +} + +/// M23: reusing an id for a changed filter must be *detected*. +/// +/// This test pins detection and nothing else. Post-violation behavior — +/// whether the subscription reopens, what happens to its retry state, what +/// the relay is sent — is unspecified by design, because the wire carries +/// only the id and an in-flight CLOSED from the old filter is +/// indistinguishable from one caused by the new one. Asserting any of that +/// would turn an unsupported input into a supported one. +/// +/// It exists because the `(id, filter)` departure diff is otherwise +/// unpinned: on every supported path it is byte-equivalent to an id-only +/// diff, so a refactor could revert it, pass every other test here, and +/// silently remove the one signal that tells C and D they broke the +/// contract. +#[test] +fn a_filter_change_under_a_reused_id_is_reported_as_a_contract_violation() { + let mut state = SessionState::default(); + + assert!( + state.replace_desired(vec![probe_subscription()]).is_empty(), + "a first desired set violates nothing" + ); + assert!( + state.replace_desired(vec![probe_subscription()]).is_empty(), + "an unchanged subscription is not a filter change" + ); + + let violations = state.replace_desired(vec![Subscription { + id: PROBE_ID.to_string(), + filter: serde_json::json!({ "kinds": [7], "limit": 0 }), + }]); + + assert_eq!( + violations, + vec![PROBE_ID.to_string()], + "a filter changed under a reused id must be reported" + ); +} + +#[test] +fn closed_messages_classify_like_the_renderer_policy() { + assert_eq!( + classify_closed("rate-limited: quota exceeded; retry in 4s"), + ClosedClass::RateLimited + ); + assert_eq!( + classify_closed("restricted: not authorized"), + ClosedClass::Terminal + ); + assert_eq!( + classify_closed("error: too many subscriptions"), + ClosedClass::Terminal + ); + // Transient AUTH race, not a permanent rejection — the one prefix that + // looks terminal and deliberately is not. + assert_eq!( + classify_closed("auth-required: we can't serve unauthenticated"), + ClosedClass::Retryable + ); + assert_eq!(classify_closed(""), ClosedClass::Retryable); + // Case and padding come from the relay, not from us. + assert_eq!( + classify_closed(" RESTRICTED: nope "), + ClosedClass::Terminal + ); +} + +#[test] +fn retry_delay_grows_and_stops_at_the_ceiling() { + let mut retry = ClosedRetry::default(); + assert_eq!(retry.backoff(), CLOSED_RETRY_BASE_DELAY); + + retry.schedule("error: temporary"); + assert_eq!(retry.backoff(), CLOSED_RETRY_BASE_DELAY * 2); + + for _ in 0..40 { + retry.schedule("error: temporary"); + } + assert_eq!( + retry.backoff(), + CLOSED_RETRY_MAX_DELAY, + "backoff must saturate at the ceiling rather than wrapping" + ); +} + +#[test] +fn a_rate_limited_closed_waits_at_least_the_relay_hint() { + let mut retry = ClosedRetry::default(); + retry.schedule("rate-limited: quota exceeded; retry in 12s"); + + let due = retry.due_at.expect("rate-limited must schedule a reopen"); + // The hint dominates the 1s first backoff, so this asserts the hint was + // honored rather than that anything at all was scheduled. + assert!( + due >= Instant::now() + Duration::from_secs(11), + "a 12s hint must not be undercut by the base backoff" + ); + crate::relay_admission::reset_rate_limit_gate(); +} + +#[test] +fn a_hintless_rate_limited_closed_uses_the_shared_default() { + let mut retry = ClosedRetry::default(); + retry.schedule("rate-limited: quota exceeded"); + + let due = retry.due_at.expect("rate-limited must schedule a reopen"); + assert!( + due >= Instant::now() + CLOSED_RATE_LIMIT_DEFAULT - Duration::from_secs(1), + "a hintless rate-limit must fall back to the shared default window" + ); + crate::relay_admission::reset_rate_limit_gate(); +} + +#[test] +fn retry_hints_parse_the_relays_canonical_format() { + assert_eq!( + parse_retry_in_seconds("rate-limited: quota exceeded; retry in 4s"), + Some(4) + ); + assert_eq!(parse_retry_in_seconds("rate-limited: quota exceeded"), None); + assert_eq!(parse_retry_in_seconds("retry in s"), None); +} + +// ── Scope fencing at the client boundary ───────────────────────────────────── +// +// `ensure_session` is destructive on entry: a different scope's socket is shut +// down before the new one is installed. The archive lifecycle earns that right +// with `ArchiveOwnership`; the persona catalog and unread catch-up hold no such +// proof and reach the client through `session` instead. +// +// These tests drive `ensure_session` directly rather than `archive_session`, +// because `ArchiveOwnership` is un-constructible outside `archive::sync` — the +// compiler already enforces that half. `archive_session` delegates to +// `ensure_session` with no other effect on the slot, so this stages the exact +// state a live archive leaves behind. +// +// The relay URLs never accept a connection. Nothing here waits on a socket: +// the session task is spawned, its connect fails, and it backs off — while the +// slot bookkeeping and cancellation these tests assert on are synchronous. + +/// A scope's relay URL. Distinct ports, on a closed loopback address, so the +/// two scopes are unequal and neither can connect. +fn scope_url(port: u16) -> String { + format!("ws://127.0.0.1:{port}") +} + +async fn installed_session(client: &NativeRelayClient) -> Option> { + client + .current + .lock() + .await + .as_ref() + .map(|managed| Arc::clone(&managed.session)) +} + +/// The required regression: a finite request that resumes after the scope +/// switched must not disturb the new scope's live session. +/// +/// Staged in the order the bug needs — archive A installed, scope switches and +/// archive B installs, and only then does A's delayed fetch acquire. Against +/// the unfenced `session` (a straight `ensure_session` call) A's late arrival +/// shut B's socket down and installed its own, leaving B's archive attached to +/// a cancelled session: no events, no error, until the next lifecycle edge. +#[tokio::test] +async fn a_stale_finite_request_cannot_displace_the_new_scopes_session() { + let client = NativeRelayClient::default(); + let scope_a = (scope_url(9), Keys::generate()); + let scope_b = (scope_url(10), Keys::generate()); + + let archive_a = client + .ensure_session(scope_a.0.clone(), scope_a.1.clone()) + .await; + let archive_b = client + .ensure_session(scope_b.0.clone(), scope_b.1.clone()) + .await; + assert!( + archive_a.cancel.is_cancelled(), + "the archive lifecycle must still replace its own scope's session" + ); + + // Scope A's in-flight catalog/catch-up command, resuming late. + let stale = client.session(scope_a.0.clone(), scope_a.1.clone()).await; + + assert!( + !archive_b.cancel.is_cancelled(), + "a stale finite request cancelled the live scope's session; its archive \ + is now attached to a dead socket and will sit silent until the next \ + lifecycle edge" + ); + let installed = installed_session(&client) + .await + .expect("the slot must still hold a session"); + assert!( + Arc::ptr_eq(&installed, &archive_b), + "a stale finite request replaced the installed session, so the next \ + same-scope caller shares the wrong socket" + ); + assert!( + !Arc::ptr_eq(&stale.session, &archive_b), + "the stale request must run on its own session, not the live scope's" + ); + + // Its own session is the lease's to end, and it must actually end: an + // un-cancelled private session leaks a reconnecting socket per request. + let private = stale.handle(); + drop(stale); + assert!( + private.cancel.is_cancelled(), + "dropping a private lease must shut its session down" + ); +} + +/// The sharing half, and the mutant that matters: making every lease private +/// would satisfy the test above while quietly undoing the one-socket design and +/// letting a finite request's drop cancel the archive's session. +#[tokio::test] +async fn a_same_scope_lease_shares_the_installed_session_and_never_ends_it() { + let client = NativeRelayClient::default(); + let (relay_url, keys) = (scope_url(11), Keys::generate()); + + let archive = client.ensure_session(relay_url.clone(), keys.clone()).await; + let lease = client.session(relay_url.clone(), keys.clone()).await; + assert!( + Arc::ptr_eq(&lease.session, &archive), + "a same-scope finite request must multiplex over the installed socket \ + rather than opening a second one" + ); + + drop(lease); + assert!( + !archive.cancel.is_cancelled(), + "dropping a shared lease cancelled the archive's session" + ); + assert!( + installed_session(&client) + .await + .is_some_and(|installed| Arc::ptr_eq(&installed, &archive)), + "the shared session must stay installed after a lease is dropped" + ); +} + +/// A lease taken before any archive start installs, so the archive start that +/// follows reuses that socket instead of opening a second one. This is the +/// common boot order: the catalog fetch runs before archive sync. +#[tokio::test] +async fn the_first_lease_installs_a_session_the_archive_then_reuses() { + let client = NativeRelayClient::default(); + let (relay_url, keys) = (scope_url(12), Keys::generate()); + + let lease = client.session(relay_url.clone(), keys.clone()).await; + let leased = lease.handle(); + drop(lease); + assert!( + !leased.cancel.is_cancelled(), + "the first lease owns the slot, so dropping it must not cancel the \ + session the archive is about to reuse" + ); + + let archive = client.ensure_session(relay_url, keys).await; + assert!( + Arc::ptr_eq(&archive, &leased), + "the archive start must reuse the installed session rather than \ + replacing an identically scoped one" + ); +} diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 128f2df79d..a7a51fb290 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -2,7 +2,13 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime}; +use tauri::{ + ipc::{Channel, InvokeResponseBody}, + plugin::TauriPlugin, + Manager, Runtime, +}; + +use crate::native_websocket_batch::{is_auth_challenge, FrameBatch, BATCH_MAX_SERIALIZED_BYTES}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio_tungstenite::{ connect_async, @@ -124,7 +130,7 @@ impl WebSocketManager { async fn open_connection( manager: &WebSocketManager, url: &str, - on_message: Channel, + on_message: Channel, ) -> Result { let connect_cancel = manager.connect_cancel.lock().await.clone(); let (socket, _) = tokio::select! { @@ -176,7 +182,7 @@ async fn open_connection( async fn connect( manager: tauri::State<'_, WebSocketManager>, url: String, - on_message: Channel, + on_message: Channel, _config: Option, ) -> Result { open_connection(manager.inner(), &url, on_message).await @@ -261,11 +267,12 @@ async fn run_connection( mut socket: tokio_tungstenite::WebSocketStream, mut receiver: mpsc::Receiver, cancel: CancellationToken, - on_message: Channel, + on_message: Channel, manager: WebSocketManager, ) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { + let mut batch = FrameBatch::default(); loop { tokio::select! { _ = cancel.cancelled() => { @@ -278,6 +285,7 @@ async fn run_connection( ).await; break; } + _ = batch.due() => batch.flush(&on_message), request = receiver.recv() => { let Some(request) = request else { break }; let result = tokio::time::timeout(WRITE_TIMEOUT, socket.send(request.message)) @@ -295,13 +303,35 @@ async fn run_connection( None => OutboundMessage::Close(None), }; let terminal = matches!(message, OutboundMessage::Close(_) | OutboundMessage::Error(_)); - if let Ok(value) = serde_json::to_value(message) { - let _ = on_message.send(value); + // Classify the relay payload before it is wrapped, while its + // structure is still readable. + let urgent = match &message { + OutboundMessage::Text(payload) => is_auth_challenge(payload), + _ => false, + }; + let Ok(frame) = serde_json::to_string(&message) else { continue }; + + // Flush before appending when the frame would carry the batch + // over the direct-eval ceiling, so the oversized frame starts a + // batch of its own rather than pushing its predecessors onto the + // fetch path. A frame that exceeds the bound alone is delivered + // alone, exactly as it is today. + if batch.projected_len(&frame) > BATCH_MAX_SERIALIZED_BYTES { + batch.flush(&on_message); + } + batch.push(frame); + // Ordering is FIFO in all cases: buffered frames are flushed + // together with the frame that forced the flush, never after it. + if terminal || urgent { + batch.flush(&on_message); } if terminal { break; } } } } + // A terminal frame already flushed; this covers cancellation and send + // failure, which must not strand frames the relay already delivered. + batch.flush(&on_message); manager.remove(id).await; } @@ -338,17 +368,241 @@ pub fn init() -> TauriPlugin { #[cfg(test)] mod tests { use super::*; + use crate::native_websocket_batch::BATCH_WINDOW; use futures_util::FutureExt; use std::sync::atomic::{AtomicBool, Ordering}; - use tauri::ipc::InvokeResponseBody; use tokio::io::duplex; use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; - fn silent_channel() -> Channel { + fn silent_channel() -> Channel { Channel::new(|_: InvokeResponseBody| Ok(())) } + /// Records each delivery as its raw JSON payload, so tests assert on what + /// the renderer actually receives rather than on internal batch state. + fn recording_channel() -> ( + Channel, + Arc>>, + ) { + // A std mutex: `Channel::send` is synchronous and runs on whatever + // thread flushed, including inside the async runtime. + let deliveries = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = deliveries.clone(); + let channel = Channel::new(move |body: InvokeResponseBody| { + let payload = match body { + InvokeResponseBody::Json(json) => json, + InvokeResponseBody::Raw(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + }; + sink.lock().unwrap().push(payload); + Ok(()) + }); + (channel, deliveries) + } + + /// Drives the real `run_connection` loop over a live in-memory socket, so + /// flush policy is exercised as the loop applies it. Asserting against + /// `FrameBatch` alone cannot see the loop's decisions and lets a broken + /// policy pass. + struct LoopHarness { + server: WebSocketStream, + deliveries: Arc>>, + cancel: CancellationToken, + _sender: mpsc::Sender, + } + + impl LoopHarness { + async fn start() -> Self { + let manager = WebSocketManager::default(); + let (client_io, server_io) = duplex(256 * 1024); + let (client, server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (channel, deliveries) = recording_channel(); + // The sender is held by the harness: dropping it would end the + // loop before the test could drive it. + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let cancel = CancellationToken::new(); + // `tokio::spawn`, not `tauri::async_runtime::spawn`: the latter + // runs the task on Tauri's own runtime, where this test's paused + // clock does not apply and `advance` would silently do nothing. + tokio::spawn(run_connection( + 1, + client, + receiver, + cancel.clone(), + channel, + manager, + )); + Self { + server, + deliveries, + cancel, + _sender: sender, + } + } + + async fn relay_says(&mut self, payload: &str) { + self.server + .send(Message::Text(payload.into())) + .await + .unwrap(); + } + + /// Lets the connection task run without letting the batch timer + /// elapse, so what arrives here arrived because policy forced it out. + async fn settle(&self) { + for _ in 0..64 { + tokio::task::yield_now().await; + } + } + + fn deliveries(&self) -> Vec { + self.deliveries.lock().unwrap().clone() + } + } + + #[tokio::test(start_paused = true)] + async fn auth_challenge_does_not_wait_for_the_batch_timer() { + let mut harness = LoopHarness::start().await; + + // Control: an ordinary frame stays buffered, proving the window is + // genuinely holding frames back rather than the clock running out. + harness.relay_says(r#"["EOSE","sub"]"#).await; + harness.settle().await; + assert!( + harness.deliveries().is_empty(), + "EOSE must ride the batch window" + ); + + harness.relay_says(r#"["AUTH","challenge"]"#).await; + harness.settle().await; + + let deliveries = harness.deliveries(); + assert_eq!(deliveries.len(), 1, "AUTH must not wait for the timer"); + let frames: Vec = serde_json::from_str(&deliveries[0]).unwrap(); + assert_eq!(frames.len(), 2, "the buffered EOSE rides out with AUTH"); + assert_eq!(frames[0]["data"], r#"["EOSE","sub"]"#, "FIFO preserved"); + } + + #[tokio::test(start_paused = true)] + async fn batch_window_eventually_delivers_unforced_frames() { + let mut harness = LoopHarness::start().await; + harness.relay_says(r#"["EOSE","sub"]"#).await; + harness.settle().await; + assert!(harness.deliveries().is_empty()); + + // Same frame, once the window elapses: the control above is waiting on + // the timer, not stuck. + tokio::time::advance(BATCH_WINDOW * 2).await; + harness.settle().await; + assert_eq!(harness.deliveries().len(), 1); + } + + #[tokio::test(start_paused = true)] + async fn cancellation_delivers_frames_the_relay_already_sent() { + let mut harness = LoopHarness::start().await; + harness.relay_says(r#"["EVENT","sub",{}]"#).await; + harness.settle().await; + assert!(harness.deliveries().is_empty(), "frame is buffered"); + + // Teardown must not strand a frame that never reached the renderer. + harness.cancel.cancel(); + harness.settle().await; + + let seen = harness.deliveries().join(""); + assert!( + seen.contains("EVENT"), + "buffered frame lost on cancel: {seen}" + ); + } + + #[tokio::test(start_paused = true)] + async fn oversize_frame_does_not_drag_buffered_frames_over_the_threshold() { + let mut harness = LoopHarness::start().await; + harness.relay_says(r#"["EOSE","sub"]"#).await; + harness.settle().await; + + let big = format!( + r#"["EVENT","sub","{}"]"#, + "x".repeat(BATCH_MAX_SERIALIZED_BYTES) + ); + harness.relay_says(&big).await; + harness.settle().await; + // The small frame is forced out by the straddle; the oversize frame + // itself still rides the window. + assert_eq!( + harness.deliveries().len(), + 1, + "straddle flushes immediately" + ); + tokio::time::advance(BATCH_WINDOW * 2).await; + harness.settle().await; + + // The small frame must ship on its own rather than riding a delivery + // that crosses tauri's direct-eval threshold. + let deliveries = harness.deliveries(); + assert_eq!( + deliveries.len(), + 2, + "straddling frames must not share a batch" + ); + assert!( + deliveries[0].len() < 8192, + "first delivery {} crossed the direct-eval threshold", + deliveries[0].len() + ); + let first: Vec = serde_json::from_str(&deliveries[0]).unwrap(); + assert_eq!(first[0]["data"], r#"["EOSE","sub"]"#); + } + + #[tokio::test] + async fn eof_delivers_buffered_frames_before_the_close() { + let manager = WebSocketManager::default(); + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (channel, deliveries) = recording_channel(); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + }); + manager.connections.lock().await.insert(1, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 1, + client, + receiver, + handle.cancel.clone(), + channel, + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + server.send(Message::Text("buffered".into())).await.unwrap(); + drop(server); + + tokio::time::timeout(Duration::from_secs(2), async { + while manager.connections.lock().await.contains_key(&1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("EOF should clean up its native connection ID"); + + // A frame the relay already delivered must reach the renderer even + // though the socket closed inside the batch window. + let seen = deliveries.lock().unwrap().join(""); + assert!( + seen.contains("buffered"), + "buffered frame was dropped: {seen}" + ); + } + #[tokio::test] async fn secure_websocket_reaches_tls_without_panicking() { install_crypto_provider(); diff --git a/desktop/src-tauri/src/native_websocket_batch.rs b/desktop/src-tauri/src/native_websocket_batch.rs new file mode 100644 index 0000000000..bf82804fd2 --- /dev/null +++ b/desktop/src-tauri/src/native_websocket_batch.rs @@ -0,0 +1,265 @@ +use std::time::Duration; + +use tauri::ipc::{Channel, InvokeResponseBody}; +use tokio::time::Instant; + +/// Inbound text frames are coalesced into one `Channel::send` for this long +/// before delivery. Collapses N main-run-loop wakeups into one under a +/// catch-up storm without adding latency the relay protocol can observe. +pub(crate) const BATCH_WINDOW: Duration = Duration::from_millis(8); +/// Byte ceiling for a coalesced batch, measured on the *serialized* payload. +/// +/// `tauri::ipc::Channel::send` forks on payload size: below +/// `MAX_JSON_DIRECT_EXECUTE_THRESHOLD` (8192) it goes straight to +/// `webview.eval`; at or above it the body is parked in a `ChannelDataIpcQueue` +/// and the webview is made to call *back* into Rust over the IPC to fetch it +/// (tauri-2.11.5 `src/ipc/channel.rs:37,154-181,319-331`). That round-trip is +/// what batching is supposed to remove, so a batch must never cross the line — +/// bounding by frame count instead would put every batch on the slow path. +/// The margin absorbs the envelope; the check itself uses real serialized +/// length, because JSON escaping inflates payloads by an amount no fixed +/// per-frame estimate can bound. +pub(crate) const BATCH_MAX_SERIALIZED_BYTES: usize = 7680; + +/// Coalesces inbound frames into a single IPC delivery. +/// +/// Frames are serialized once on arrival so the batch can be bounded by its +/// true serialized length, and are concatenated into a JSON array at flush — +/// no value is serialized twice. Every delivery is an array, including the +/// single-frame case; the renderer accepts both shapes. +#[derive(Default)] +pub(crate) struct FrameBatch { + frames: Vec, + /// Serialized length of the delivered array, kept in sync with `frames`: + /// the enclosing brackets plus each frame and its separating comma. + serialized_len: usize, + deadline: Option, +} + +impl FrameBatch { + /// Serialized length of the array if `frame` were appended. + pub(crate) fn projected_len(&self, frame: &str) -> usize { + let separator = usize::from(!self.frames.is_empty()); + self.serialized_len.max(2) + separator + frame.len() + } + + pub(crate) fn push(&mut self, frame: String) { + self.serialized_len = self.projected_len(&frame); + self.frames.push(frame); + self.deadline + .get_or_insert_with(|| Instant::now() + BATCH_WINDOW); + } + + /// Resolves when the open batch is due, or never while there is none. + pub(crate) async fn due(&self) { + match self.deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } + } + + pub(crate) fn flush(&mut self, on_message: &Channel) { + if self.frames.is_empty() { + return; + } + let payload = format!("[{}]", self.frames.join(",")); + self.frames.clear(); + self.serialized_len = 0; + self.deadline = None; + let _ = on_message.send(InvokeResponseBody::Json(payload)); + } +} + +/// Whether a relay frame must reach the renderer without waiting out the batch +/// window. Only the NIP-42 challenge qualifies: it gates a round trip the +/// relay is waiting on, whereas `OK`/`EOSE` ride the window so catch-up +/// batching survives. +/// +/// Takes the relay payload, not the serialized envelope — inside the envelope +/// the payload's quotes are escaped and no plain `"AUTH"` prefix exists. +/// +/// Conservative by construction — a missed match costs at most one batch +/// window of latency against a 25s auth timeout, never correctness. +pub(crate) fn is_auth_challenge(payload: &str) -> bool { + payload + .trim_start() + .strip_prefix('[') + .unwrap_or_default() + .trim_start() + .starts_with("\"AUTH\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + /// Records each delivery as its raw JSON payload, so tests assert on what + /// the renderer actually receives rather than on internal batch state. + fn recording_channel() -> ( + Channel, + Arc>>, + ) { + // A std mutex: `Channel::send` is synchronous and runs on whatever + // thread flushed, including inside the async runtime. + let deliveries = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = deliveries.clone(); + let channel = Channel::new(move |body: InvokeResponseBody| { + let payload = match body { + InvokeResponseBody::Json(json) => json, + InvokeResponseBody::Raw(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + }; + sink.lock().unwrap().push(payload); + Ok(()) + }); + (channel, deliveries) + } + + /// Mirrors the envelope `native_websocket` serializes, so these tests bind + /// to the real wire shape rather than a convenient stand-in. + fn text_frame(payload: &str) -> String { + serde_json::json!({ "type": "Text", "data": payload }).to_string() + } + + #[test] + fn batch_bound_tracks_real_serialized_length() { + let mut batch = FrameBatch::default(); + let first = text_frame("one"); + let second = text_frame("two"); + batch.push(first.clone()); + batch.push(second.clone()); + + // The tracked length must equal the payload actually built at flush; + // an estimate that drifts from it would silently cross the 8192 fork. + let expected = format!("[{first},{second}]"); + assert_eq!(batch.serialized_len, expected.len()); + } + + #[test] + fn escape_heavy_frames_stay_under_the_direct_eval_threshold() { + // Quotes double under JSON escaping, so a bound applied to raw relay + // bytes would pass here while the serialized body crosses 8192 and + // silently moves every batch onto the fetch round-trip. + let mut batch = FrameBatch::default(); + let mut pushed = 0; + loop { + let frame = text_frame(&"\"".repeat(512)); + if batch.projected_len(&frame) > BATCH_MAX_SERIALIZED_BYTES { + break; + } + batch.push(frame); + pushed += 1; + } + + assert!( + pushed > 0, + "bound must admit at least one escape-heavy frame" + ); + assert!( + batch.serialized_len < 8192, + "serialized batch {} must stay under the direct-eval threshold", + batch.serialized_len + ); + } + + #[tokio::test] + async fn frames_within_the_window_arrive_as_one_delivery() { + let (channel, deliveries) = recording_channel(); + let mut batch = FrameBatch::default(); + batch.push(text_frame("one")); + batch.push(text_frame("two")); + batch.push(text_frame("three")); + batch.flush(&channel); + + let deliveries = deliveries.lock().unwrap(); + assert_eq!(deliveries.len(), 1, "three frames must cost one IPC wakeup"); + // Asserted as the wire shape the renderer parses, not as a Rust type. + let frames: Vec = serde_json::from_str(&deliveries[0]).unwrap(); + let texts: Vec<&str> = frames + .iter() + .map(|frame| frame["data"].as_str().expect("text frame carries data")) + .collect(); + assert_eq!(texts, ["one", "two", "three"], "FIFO order is preserved"); + } + + #[tokio::test] + async fn oversize_frame_is_delivered_alone_without_stranding_predecessors() { + let (channel, deliveries) = recording_channel(); + let mut batch = FrameBatch::default(); + batch.push(text_frame("small")); + + // A frame that cannot share a batch must flush what is buffered first, + // then travel alone — the straddle case. + let oversize = text_frame(&"x".repeat(BATCH_MAX_SERIALIZED_BYTES)); + assert!(batch.projected_len(&oversize) > BATCH_MAX_SERIALIZED_BYTES); + batch.flush(&channel); + batch.push(oversize); + batch.flush(&channel); + + let deliveries = deliveries.lock().unwrap(); + assert_eq!( + deliveries.len(), + 2, + "predecessor must not ride the oversize batch" + ); + let first: Vec = serde_json::from_str(&deliveries[0]).unwrap(); + assert_eq!(first.len(), 1); + let second: Vec = serde_json::from_str(&deliveries[1]).unwrap(); + assert_eq!(second.len(), 1); + assert!(deliveries[1].len() >= BATCH_MAX_SERIALIZED_BYTES); + } + + #[tokio::test] + async fn auth_challenge_flushes_immediately_and_keeps_earlier_frames_ahead_of_it() { + let (channel, deliveries) = recording_channel(); + let auth_payload = serde_json::json!(["AUTH", "challenge"]).to_string(); + assert!(is_auth_challenge(&auth_payload)); + + let mut batch = FrameBatch::default(); + batch.push(text_frame("earlier")); + batch.push(text_frame(&auth_payload)); + batch.flush(&channel); + + let deliveries = deliveries.lock().unwrap(); + assert_eq!(deliveries.len(), 1); + let frames: Vec = serde_json::from_str(&deliveries[0]).unwrap(); + assert_eq!( + frames.len(), + 2, + "AUTH carries buffered frames with it, in order" + ); + assert_eq!( + frames[0]["data"], "earlier", + "buffered frame stays ahead of AUTH" + ); + } + + #[test] + fn only_the_auth_challenge_bypasses_the_batch_window() { + // OK and EOSE must ride the timer, or catch-up batching collapses back + // to one delivery per frame. + for payload in [ + serde_json::json!(["OK", "id", true, ""]).to_string(), + serde_json::json!(["EOSE", "sub"]).to_string(), + serde_json::json!(["EVENT", "sub", {"content": "AUTH"}]).to_string(), + serde_json::json!(["NOTICE", "AUTH required"]).to_string(), + ] { + assert!( + !is_auth_challenge(&payload), + "{payload} must not force a flush" + ); + } + + // The serialized envelope escapes the payload's quotes, so matching + // against it would never fire — the bug this pair pins down. + let envelope = text_frame(r#"["AUTH","c"]"#); + assert!(!is_auth_challenge(&envelope)); + } + + #[tokio::test] + async fn empty_batch_never_wakes_the_renderer() { + let (channel, deliveries) = recording_channel(); + FrameBatch::default().flush(&channel); + assert!(deliveries.lock().unwrap().is_empty()); + } +} diff --git a/desktop/src-tauri/src/observed_unread.rs b/desktop/src-tauri/src/observed_unread.rs new file mode 100644 index 0000000000..3ca5948262 --- /dev/null +++ b/desktop/src-tauri/src/observed_unread.rs @@ -0,0 +1,884 @@ +//! Native observed-unread read model. +//! +//! The renderer is the only writer today, so request/response ordering is the +//! delivery mechanism: there is no push channel. If native relay ingestion adds +//! a second writer, that assumption breaks; consumers must then use the same +//! revision-gap rule here to request a fresh snapshot. +//! +//! Failure contract: sequence + revision advance in the same SQLite transaction +//! as events, markers, pruning, and migration. A lost ack is replayed as a no-op; +//! a gap is rejected; stale-scope responses are fenced in the renderer. Legacy +//! rows and their migration marker commit together, and localStorage is removed +//! only after the renderer observes that marker. + +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use rusqlite::{params, Connection, Transaction}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State}; + +const SCHEMA_VERSION: i64 = 1; +const PER_CHANNEL_CAP: i64 = 1_000; +const GLOBAL_CAP: i64 = 5_000; +const HORIZON_SECONDS: i64 = 7 * 24 * 60 * 60; + +/// Serializes the two observed-unread commands against each other. +/// +/// `Arc` because the guard is taken *inside* the blocking closure the commands +/// hand to `spawn_blocking`: a `std::sync::MutexGuard` is not `Send`, so it +/// cannot be acquired on the caller side of an await. Cloning the handle into +/// the closure keeps serialization identical while moving the wait off the +/// thread that runs the IPC handler. +#[derive(Default)] +pub(crate) struct ObservedUnreadStore { + write_lock: Arc>, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ObservedUnreadScope { + pub(crate) pubkey: String, + pub(crate) relay_url: String, +} + +impl ObservedUnreadScope { + fn key(&self) -> String { + format!( + "{}:{}", + self.pubkey.trim().to_ascii_lowercase(), + self.relay_url.trim().trim_end_matches('/') + ) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct IngestEvent { + channel_id: String, + id: String, + created_at: u64, + root_id: Option, + high_priority: bool, + counts_toward_badge: bool, + counts_toward_app_badge: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ChannelLatestUpdate { + channel_id: String, + created_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MarkerUpdate { + context_id: String, + read_at: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MembershipUpdate { + kind: String, + value: String, + present: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MembershipSeed { + participated_root_ids: Vec, + authored_root_ids: Vec, + mentioned_root_ids: Vec, + followed_root_ids: Vec, + muted_root_ids: Vec, + muted_channel_ids: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OpenScopeRequest { + scope: ObservedUnreadScope, + legacy_payload: Option, + membership_seed: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IngestRequest { + scope: ObservedUnreadScope, + sequence: u64, + base_revision: u64, + events: Vec, + channel_latest: Vec, + markers: Vec, + membership: Vec, + clear_channels: Vec, + clear_all: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChannelProjection { + channel_id: String, + latest: u64, + count: u64, + badge_count: u64, + app_badge_count: u64, + top_level_unread: bool, + high_priority_unread: bool, +} + +#[derive(Debug, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum ObservedUnreadResponse { + Snapshot { + scope: ObservedUnreadScope, + generation: String, + revision: u64, + last_acked_sequence: u64, + migration_complete: bool, + membership_seeded: bool, + channels: Vec, + }, + Delta { + scope: ObservedUnreadScope, + generation: String, + base_revision: u64, + revision: u64, + acked_sequence: u64, + upserts: Vec, + removed: Vec, + }, + SnapshotRequired { + scope: ObservedUnreadScope, + generation: String, + revision: u64, + last_acked_sequence: u64, + }, +} + +fn db_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("resolve observed-unread data dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("create observed-unread data dir: {e}"))?; + Ok(dir.join("observed-unread.db")) +} + +fn open_db(path: &Path) -> Result { + let conn = Connection::open(path).map_err(|e| format!("open observed-unread db: {e}"))?; + conn.pragma_update(None, "busy_timeout", 5_000) + .map_err(|e| format!("configure observed-unread db: {e}"))?; + conn.pragma_update(None, "journal_mode", "WAL") + .map_err(|e| format!("configure observed-unread WAL: {e}"))?; + conn.execute_batch("CREATE TABLE IF NOT EXISTS schema_meta(version INTEGER NOT NULL); + INSERT INTO schema_meta(version) SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM schema_meta); + CREATE TABLE IF NOT EXISTS scope_state( + scope TEXT PRIMARY KEY, generation TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0, + last_sequence INTEGER NOT NULL DEFAULT 0, migration_complete INTEGER NOT NULL DEFAULT 0, + membership_seeded INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS observed_events( + scope TEXT NOT NULL, event_id TEXT NOT NULL, channel_id TEXT NOT NULL, + created_at INTEGER NOT NULL, root_id TEXT, high_priority INTEGER NOT NULL, + counts_badge INTEGER NOT NULL, counts_app_badge INTEGER NOT NULL, + PRIMARY KEY(scope,event_id)); + CREATE INDEX IF NOT EXISTS observed_events_channel ON observed_events(scope,channel_id,created_at,event_id); + CREATE TABLE IF NOT EXISTS channel_latest( + scope TEXT NOT NULL, channel_id TEXT NOT NULL, created_at INTEGER NOT NULL, + PRIMARY KEY(scope,channel_id)); + CREATE TABLE IF NOT EXISTS read_markers( + scope TEXT NOT NULL, context_id TEXT NOT NULL, read_at INTEGER NOT NULL, + PRIMARY KEY(scope,context_id)); + CREATE TABLE IF NOT EXISTS unread_membership( + scope TEXT NOT NULL, kind TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY(scope,kind,value));") + .map_err(|e| format!("initialize observed-unread db: {e}"))?; + let version: i64 = conn + .query_row("SELECT version FROM schema_meta LIMIT 1", [], |row| { + row.get(0) + }) + .map_err(|e| format!("read observed-unread schema: {e}"))?; + if version != SCHEMA_VERSION { + return Err(format!( + "unsupported observed-unread schema version {version}" + )); + } + Ok(conn) +} + +fn ensure_scope(tx: &Transaction<'_>, scope: &str) -> Result<(), String> { + tx.execute( + "INSERT OR IGNORE INTO scope_state(scope,generation) VALUES(?1,?2)", + params![scope, uuid::Uuid::new_v4().to_string()], + ) + .map_err(|e| format!("initialize observed-unread scope: {e}"))?; + Ok(()) +} + +fn state(tx: &Transaction<'_>, scope: &str) -> Result<(String, u64, u64, bool, bool), String> { + tx.query_row("SELECT generation,revision,last_sequence,migration_complete,membership_seeded FROM scope_state WHERE scope=?1", [scope], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get::<_,i64>(3)? != 0,r.get::<_,i64>(4)? != 0))) + .map_err(|e| format!("read observed-unread scope state: {e}")) +} + +fn valid_legacy_event(value: &serde_json::Value, channel_id: &str) -> Option { + let object = value.as_object()?; + Some(IngestEvent { + channel_id: channel_id.to_string(), + id: object.get("id")?.as_str()?.to_string(), + created_at: object.get("createdAt")?.as_u64()?, + root_id: match object.get("rootId")? { + serde_json::Value::Null => None, + v => Some(v.as_str()?.to_string()), + }, + high_priority: object.get("highPriority")?.as_bool()?, + counts_toward_badge: object.get("countsTowardBadge")?.as_bool()?, + counts_toward_app_badge: object.get("countsTowardAppBadge")?.as_bool()?, + }) +} + +fn upsert_event(tx: &Transaction<'_>, scope: &str, event: &IngestEvent) -> Result<(), String> { + tx.execute("INSERT INTO observed_events(scope,event_id,channel_id,created_at,root_id,high_priority,counts_badge,counts_app_badge) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8) ON CONFLICT(scope,event_id) DO NOTHING", + params![scope,event.id,event.channel_id,event.created_at,event.root_id,event.high_priority,event.counts_toward_badge,event.counts_toward_app_badge]) + .map_err(|e| format!("upsert observed-unread event: {e}"))?; + Ok(()) +} + +fn seed_membership(tx: &Transaction<'_>, scope: &str, seed: &MembershipSeed) -> Result<(), String> { + // The renderer snapshot is authoritative while it remains the only writer. + // Replace transactionally so removals made while Buzz was closed are not + // silently resurrected by an insert-only seed. + tx.execute("DELETE FROM unread_membership WHERE scope=?1", [scope]) + .map_err(|e| format!("reset unread membership: {e}"))?; + for (kind, values) in [ + ("participated", &seed.participated_root_ids), + ("authored", &seed.authored_root_ids), + ("mentioned", &seed.mentioned_root_ids), + ("followed", &seed.followed_root_ids), + ("muted_root", &seed.muted_root_ids), + ("muted_channel", &seed.muted_channel_ids), + ] { + for value in values { + tx.execute( + "INSERT OR IGNORE INTO unread_membership(scope,kind,value) VALUES(?1,?2,?3)", + params![scope, kind, value], + ) + .map_err(|e| format!("seed unread membership: {e}"))?; + } + } + tx.execute( + "UPDATE scope_state SET membership_seeded=1 WHERE scope=?1", + [scope], + ) + .map_err(|e| format!("mark unread membership seeded: {e}"))?; + Ok(()) +} + +fn advance_channel_latest( + tx: &Transaction<'_>, + scope: &str, + channel_id: &str, + created_at: u64, +) -> Result<(), String> { + tx.execute( + "INSERT INTO channel_latest(scope,channel_id,created_at) VALUES(?1,?2,?3) ON CONFLICT(scope,channel_id) DO UPDATE SET created_at=MAX(created_at,excluded.created_at)", + params![scope, channel_id, created_at], + ) + .map_err(|e| format!("advance channel latest: {e}"))?; + Ok(()) +} + +fn seed_membership_once( + tx: &Transaction<'_>, + scope: &str, + membership_seeded: bool, + seed: Option<&MembershipSeed>, +) -> Result<(), String> { + if membership_seeded { + return Ok(()); + } + if let Some(seed) = seed { + seed_membership(tx, scope, seed)?; + } + Ok(()) +} + +fn prune(tx: &Transaction<'_>, scope: &str) -> Result<(), String> { + let cutoff = chrono::Utc::now().timestamp() - HORIZON_SECONDS; + tx.execute( + "DELETE FROM observed_events WHERE scope=?1 AND created_at<=?2", + params![scope, cutoff], + ) + .map_err(|e| format!("age-prune observed unread: {e}"))?; + tx.execute("DELETE FROM observed_events WHERE rowid IN (SELECT rowid FROM (SELECT rowid,ROW_NUMBER() OVER(PARTITION BY channel_id ORDER BY created_at DESC,event_id DESC) rank FROM observed_events WHERE scope=?1) WHERE rank>?2)", params![scope,PER_CHANNEL_CAP]).map_err(|e| format!("channel-prune observed unread: {e}"))?; + tx.execute("DELETE FROM observed_events WHERE rowid IN (SELECT rowid FROM observed_events WHERE scope=?1 ORDER BY created_at DESC,event_id DESC LIMIT -1 OFFSET ?2)", params![scope,GLOBAL_CAP]).map_err(|e| format!("global-prune observed unread: {e}"))?; + Ok(()) +} + +fn marker(markers: &HashMap, key: &str) -> u64 { + markers.get(key).copied().unwrap_or(0) +} + +fn projections(tx: &Transaction<'_>, scope: &str) -> Result, String> { + let mut marker_stmt = tx + .prepare("SELECT context_id,read_at FROM read_markers WHERE scope=?1") + .map_err(|e| format!("prepare unread markers: {e}"))?; + let markers: HashMap = marker_stmt + .query_map([scope], |r| Ok((r.get(0)?, r.get(1)?))) + .map_err(|e| format!("query unread markers: {e}"))? + .collect::>() + .map_err(|e| format!("read unread markers: {e}"))?; + let mut by_channel: HashMap = HashMap::new(); + let mut latest_stmt = tx + .prepare("SELECT channel_id,created_at FROM channel_latest WHERE scope=?1") + .map_err(|e| format!("prepare channel latest: {e}"))?; + for row in latest_stmt + .query_map([scope], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)) + }) + .map_err(|e| format!("query channel latest: {e}"))? + { + let (channel_id, latest) = row.map_err(|e| format!("read channel latest: {e}"))?; + by_channel.insert( + channel_id.clone(), + ChannelProjection { + channel_id, + latest, + count: 0, + badge_count: 0, + app_badge_count: 0, + top_level_unread: false, + high_priority_unread: false, + }, + ); + } + let mut stmt = tx.prepare("SELECT event_id,channel_id,created_at,root_id,high_priority,counts_badge,counts_app_badge FROM observed_events WHERE scope=?1 ORDER BY channel_id,created_at,event_id").map_err(|e| format!("prepare observed projection: {e}"))?; + let rows = stmt + .query_map([scope], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, u64>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, bool>(4)?, + r.get::<_, bool>(5)?, + r.get::<_, bool>(6)?, + )) + }) + .map_err(|e| format!("query observed projection: {e}"))?; + for row in rows { + let (id, channel, created, root, high, badge, app) = + row.map_err(|e| format!("read observed projection: {e}"))?; + let mut read_at = marker(&markers, &channel).max(marker(&markers, &format!("msg:{id}"))); + if let Some(root) = &root { + read_at = read_at.max(marker(&markers, &format!("thread:{root}"))); + } + if created <= read_at { + continue; + } + let entry = by_channel + .entry(channel.clone()) + .or_insert(ChannelProjection { + channel_id: channel, + latest: 0, + count: 0, + badge_count: 0, + app_badge_count: 0, + top_level_unread: false, + high_priority_unread: false, + }); + entry.latest = entry.latest.max(created); + entry.count += 1; + entry.badge_count += u64::from(badge); + entry.app_badge_count += u64::from(app); + entry.top_level_unread |= root.is_none(); + entry.high_priority_unread |= high; + } + let mut result: Vec<_> = by_channel.into_values().collect(); + result.sort_by(|a, b| a.channel_id.cmp(&b.channel_id)); + Ok(result) +} + +/// Runs one observed-unread SQLite unit on the blocking pool. +/// +/// A sync `#[tauri::command]` is `ExecutionContext::Blocking`, which runs the +/// body inline in the IPC handler — the main thread on macOS. The projection is +/// linear in the whole scope (measured 7.2 ms release / 27.6 ms debug at +/// 15 channels / 5000 events, and callers issue these in per-root loops during +/// catch-up), so inline execution holds the UI thread past the 16.7 ms frame +/// budget. `archive_events` next door already routes its SQLite work this way; +/// these two were the exception. +/// +/// The token is what makes that structural rather than a convention. Its field +/// is private to this module, so `OnBlockingThread` cannot be constructed +/// anywhere else — and since the bodies below require one, a command that +/// stopped going through [`blocking::run`] would not compile. That covers both +/// regressions: dropping `async` leaves no way to await this, and keeping +/// `async` while calling a body directly leaves no way to obtain the token. +mod blocking { + /// Evidence that the holder is executing on the blocking pool. + pub(super) struct OnBlockingThread(()); + + pub(super) async fn run(task: F) -> Result + where + T: Send + 'static, + F: FnOnce(OnBlockingThread) -> Result + Send + 'static, + { + tauri::async_runtime::spawn_blocking(move || task(OnBlockingThread(()))) + .await + .map_err(|error| format!("observed-unread db task failed: {error}"))? + } +} +use blocking::OnBlockingThread; + +/// Off-thread execution lets two invocations reach the lock in an order the IPC +/// arrival order no longer fixes. Nothing here depends on that order: the +/// renderer keeps one call per scope in flight, and a request that arrives +/// against a moved revision is rejected with `SnapshotRequired` rather than +/// applied — the same gate that already covers a lost ack. +#[tauri::command] +pub(crate) async fn observed_unread_open_scope( + request: OpenScopeRequest, + app: AppHandle, + store: State<'_, ObservedUnreadStore>, +) -> Result { + let write_lock = Arc::clone(&store.write_lock); + blocking::run(move |proof| open_scope_locked(proof, &write_lock, &app, request)).await +} + +fn open_scope_locked( + _proof: OnBlockingThread, + write_lock: &Mutex<()>, + app: &AppHandle, + request: OpenScopeRequest, +) -> Result { + let _guard = write_lock.lock().map_err(|e| e.to_string())?; + let mut conn = open_db(&db_path(app)?)?; + let tx = conn + .transaction() + .map_err(|e| format!("begin observed-unread open: {e}"))?; + let scope = request.scope.key(); + ensure_scope(&tx, &scope)?; + let (_, _, _, migration_complete, membership_seeded) = state(&tx, &scope)?; + if !migration_complete { + if let Some(payload) = &request.legacy_payload { + if let Some(channels) = payload + .get("eventsByChannel") + .and_then(serde_json::Value::as_object) + { + for (channel, events) in channels { + if let Some(events) = events.as_array() { + for value in events { + if let Some(event) = valid_legacy_event(value, channel) { + upsert_event(&tx, &scope, &event)?; + } + } + } + } + } + } + tx.execute( + "UPDATE scope_state SET migration_complete=1 WHERE scope=?1", + [&scope], + ) + .map_err(|e| format!("mark observed migration: {e}"))?; + } + seed_membership_once( + &tx, + &scope, + membership_seeded, + request.membership_seed.as_ref(), + )?; + prune(&tx, &scope)?; + let channels = projections(&tx, &scope)?; + let (generation, revision, last, migrated, seeded) = state(&tx, &scope)?; + tx.commit() + .map_err(|e| format!("commit observed-unread open: {e}"))?; + Ok(ObservedUnreadResponse::Snapshot { + scope: request.scope, + generation, + revision, + last_acked_sequence: last, + migration_complete: migrated, + membership_seeded: seeded, + channels, + }) +} + +#[tauri::command] +pub(crate) async fn observed_unread_ingest( + request: IngestRequest, + app: AppHandle, + store: State<'_, ObservedUnreadStore>, +) -> Result { + let write_lock = Arc::clone(&store.write_lock); + blocking::run(move |proof| ingest_locked(proof, &write_lock, &app, request)).await +} + +fn ingest_locked( + _proof: OnBlockingThread, + write_lock: &Mutex<()>, + app: &AppHandle, + request: IngestRequest, +) -> Result { + let _guard = write_lock.lock().map_err(|e| e.to_string())?; + let mut conn = open_db(&db_path(app)?)?; + let tx = conn + .transaction() + .map_err(|e| format!("begin observed ingest: {e}"))?; + let scope = request.scope.key(); + ensure_scope(&tx, &scope)?; + let (generation, revision, last, _, _) = state(&tx, &scope)?; + if request.sequence <= last { + let channels = projections(&tx, &scope)?; + tx.commit() + .map_err(|e| format!("commit observed replay: {e}"))?; + return Ok(ObservedUnreadResponse::Snapshot { + scope: request.scope, + generation, + revision, + last_acked_sequence: last, + migration_complete: true, + membership_seeded: true, + channels, + }); + } + if request.sequence != last + 1 || request.base_revision != revision { + return Ok(ObservedUnreadResponse::SnapshotRequired { + scope: request.scope, + generation, + revision, + last_acked_sequence: last, + }); + } + let before = projections(&tx, &scope)?; + let before_by_channel: HashMap<_, _> = before + .into_iter() + .map(|projection| (projection.channel_id.clone(), projection)) + .collect(); + if request.clear_all { + tx.execute("DELETE FROM observed_events WHERE scope=?1", [&scope]) + .map_err(|e| format!("clear observed scope: {e}"))?; + tx.execute("DELETE FROM channel_latest WHERE scope=?1", [&scope]) + .map_err(|e| format!("clear channel latest scope: {e}"))?; + } + for channel in &request.clear_channels { + tx.execute( + "DELETE FROM observed_events WHERE scope=?1 AND channel_id=?2", + params![scope, channel], + ) + .map_err(|e| format!("clear observed channel: {e}"))?; + tx.execute( + "DELETE FROM channel_latest WHERE scope=?1 AND channel_id=?2", + params![scope, channel], + ) + .map_err(|e| format!("clear channel latest: {e}"))?; + } + for event in &request.events { + upsert_event(&tx, &scope, event)?; + } + for update in &request.channel_latest { + advance_channel_latest(&tx, &scope, &update.channel_id, update.created_at)?; + } + for update in &request.membership { + if update.present { + tx.execute( + "INSERT OR IGNORE INTO unread_membership(scope,kind,value) VALUES(?1,?2,?3)", + params![scope, update.kind, update.value], + ) + } else { + tx.execute( + "DELETE FROM unread_membership WHERE scope=?1 AND kind=?2 AND value=?3", + params![scope, update.kind, update.value], + ) + } + .map_err(|e| format!("update unread membership: {e}"))?; + } + for update in &request.markers { + match update.read_at { Some(read_at)=>{tx.execute("INSERT INTO read_markers(scope,context_id,read_at) VALUES(?1,?2,?3) ON CONFLICT(scope,context_id) DO UPDATE SET read_at=MAX(read_at,excluded.read_at)",params![scope,update.context_id,read_at])},None=>tx.execute("DELETE FROM read_markers WHERE scope=?1 AND context_id=?2",params![scope,update.context_id])}.map_err(|e| format!("update observed marker: {e}"))?; + } + prune(&tx, &scope)?; + let after = projections(&tx, &scope)?; + let after_ids: HashSet<_> = after + .iter() + .map(|projection| projection.channel_id.clone()) + .collect(); + let removed: Vec<_> = before_by_channel + .keys() + .filter(|channel_id| !after_ids.contains(*channel_id)) + .cloned() + .collect(); + let upserts: Vec<_> = after + .into_iter() + .filter(|projection| before_by_channel.get(&projection.channel_id) != Some(projection)) + .collect(); + let next_revision = revision + 1; + tx.execute( + "UPDATE scope_state SET revision=?2,last_sequence=?3 WHERE scope=?1", + params![scope, next_revision, request.sequence], + ) + .map_err(|e| format!("advance observed sequence: {e}"))?; + tx.commit() + .map_err(|e| format!("commit observed ingest: {e}"))?; + Ok(ObservedUnreadResponse::Delta { + scope: request.scope, + generation, + base_revision: revision, + revision: next_revision, + acked_sequence: request.sequence, + upserts, + removed, + }) +} + +pub(crate) fn load_membership( + app: &AppHandle, + scope: &ObservedUnreadScope, +) -> Result>, String> { + let conn = open_db(&db_path(app)?)?; + let key = scope.key(); + let mut stmt = conn + .prepare("SELECT kind,value FROM unread_membership WHERE scope=?1") + .map_err(|e| format!("prepare unread membership: {e}"))?; + let rows = stmt + .query_map([key], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("query unread membership: {e}"))?; + let mut result: HashMap> = HashMap::new(); + for row in rows { + let (kind, value) = row.map_err(|e| format!("read unread membership: {e}"))?; + result.entry(kind).or_default().insert(value); + } + Ok(result) +} + +pub(crate) fn flush(app: &AppHandle) { + if let Ok(path) = db_path(app) { + if let Ok(conn) = open_db(&path) { + let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn scope() -> ObservedUnreadScope { + ObservedUnreadScope { + pubkey: "PK".into(), + relay_url: "wss://relay/".into(), + } + } + fn db() -> (tempfile::TempDir, Connection) { + let dir = tempfile::tempdir().unwrap(); + let conn = open_db(&dir.path().join("observed-unread.db")).unwrap(); + (dir, conn) + } + #[test] + fn ingest_replay_gap_prune_and_projection() { + let (_d, mut conn) = db(); + let tx = conn.transaction().unwrap(); + let key = scope().key(); + ensure_scope(&tx, &key).unwrap(); + upsert_event( + &tx, + &key, + &IngestEvent { + channel_id: "ch".into(), + id: "e".into(), + created_at: chrono::Utc::now().timestamp() as u64, + root_id: Some("root".into()), + high_priority: true, + counts_toward_badge: true, + counts_toward_app_badge: false, + }, + ) + .unwrap(); + tx.execute( + "INSERT INTO read_markers(scope,context_id,read_at) VALUES(?1,'thread:root',0)", + [&key], + ) + .unwrap(); + let p = projections(&tx, &key).unwrap(); + assert_eq!(p[0].count, 1); + assert_eq!(p[0].badge_count, 1); + tx.commit().unwrap(); + } + #[test] + fn latest_anchor_survives_without_a_notify_event_and_seed_is_one_shot() { + let (_d, mut conn) = db(); + let tx = conn.transaction().unwrap(); + let key = scope().key(); + ensure_scope(&tx, &key).unwrap(); + let first = MembershipSeed { + participated_root_ids: vec!["kept".into()], + ..Default::default() + }; + seed_membership(&tx, &key, &first).unwrap(); + let empty = MembershipSeed::default(); + let (_, _, _, _, seeded) = state(&tx, &key).unwrap(); + if !seeded { + seed_membership(&tx, &key, &empty).unwrap(); + } + tx.execute( + "INSERT INTO channel_latest(scope,channel_id,created_at) VALUES(?1,'ch',42)", + [&key], + ) + .unwrap(); + let membership: i64 = tx + .query_row( + "SELECT COUNT(*) FROM unread_membership WHERE scope=?1 AND value='kept'", + [&key], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(membership, 1); + let projected = projections(&tx, &key).unwrap(); + assert_eq!(projected[0].latest, 42); + assert_eq!(projected[0].count, 0); + } + #[test] + fn ingest_request_wire_accepts_channel_latest() { + let request: IngestRequest = serde_json::from_value(serde_json::json!({ + "scope":{"pubkey":"PK","relayUrl":"wss://relay/"}, + "sequence":1,"baseRevision":0,"events":[], + "channelLatest":[{"channelId":"ch","createdAt":42}], + "markers":[],"membership":[],"clearChannels":[],"clearAll":false + })) + .unwrap(); + assert_eq!(request.channel_latest[0].channel_id, "ch"); + assert_eq!(request.channel_latest[0].created_at, 42); + } + /// Both commands must stay `async`. A sync `#[tauri::command]` is + /// `ExecutionContext::Blocking` and runs its body inline in the IPC + /// handler — the main thread on macOS — which is the defect this fix + /// closes. The bound is the assertion: dropping `async` makes the return + /// type `Result`, which is not a `Future`, and this stops compiling. + /// + /// The companion half — `async` kept but the body called directly, skipping + /// `spawn_blocking` — is held by `blocking::OnBlockingThread`, which the + /// bodies require and only `blocking::run` can mint. This test survived that + /// mutant while it asserted the helper's own behavior; the token is what + /// killed it, so the invariant lives in the types, not here. + const _: () = { + fn returns_future(_: fn(A, B, C) -> F) {} + fn assert() { + returns_future( + observed_unread_open_scope + as fn(OpenScopeRequest, AppHandle, State<'static, ObservedUnreadStore>) -> _, + ); + returns_future( + observed_unread_ingest + as fn(IngestRequest, AppHandle, State<'static, ObservedUnreadStore>) -> _, + ); + } + let _ = assert; + }; + /// `blocking::run` must actually leave the caller's thread. This pins the + /// helper only; that the commands go *through* it is the token's job. + #[test] + fn blocking_run_leaves_the_calling_thread() { + let caller = std::thread::current().id(); + let observed = tauri::async_runtime::block_on(blocking::run(move |_proof| { + Ok::<_, String>(std::thread::current().id()) + })) + .unwrap(); + assert_ne!(observed, caller); + } + #[test] + fn second_seed_cannot_erase_discovered_membership() { + let (_d, mut conn) = db(); + let tx = conn.transaction().unwrap(); + let key = scope().key(); + ensure_scope(&tx, &key).unwrap(); + // First open seeds from the renderer. + let (_, _, _, _, seeded) = state(&tx, &key).unwrap(); + seed_membership_once( + &tx, + &key, + seeded, + Some(&MembershipSeed { + participated_root_ids: vec!["from-seed".into()], + ..Default::default() + }), + ) + .unwrap(); + // Native discovers a root incrementally (the ingest path). + tx.execute( + "INSERT INTO unread_membership(scope,kind,value) VALUES(?1,'participated','discovered')", + [&key], + ) + .unwrap(); + // Second open with an EMPTY seed must not erase it. + let (_, _, _, _, seeded) = state(&tx, &key).unwrap(); + seed_membership_once(&tx, &key, seeded, Some(&MembershipSeed::default())).unwrap(); + let kept: i64 = tx + .query_row( + "SELECT COUNT(*) FROM unread_membership WHERE scope=?1 AND value='discovered'", + [&key], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(kept, 1, "an empty second seed erased discovered membership"); + } + + #[test] + fn channel_latest_anchor_never_moves_backward() { + let (_d, mut conn) = db(); + let tx = conn.transaction().unwrap(); + let key = scope().key(); + ensure_scope(&tx, &key).unwrap(); + let advance = |created_at: u64| { + advance_channel_latest(&tx, &key, "ch", created_at).unwrap(); + }; + advance(500); + advance(100); // an older catch-up trigger arriving late + let anchor: u64 = tx + .query_row( + "SELECT created_at FROM channel_latest WHERE scope=?1 AND channel_id='ch'", + [&key], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + anchor, 500, + "a late older trigger rewound the latest anchor" + ); + } + + #[test] + fn serialized_response_matches_typescript_contract() { + let actual = serde_json::to_value(ObservedUnreadResponse::Delta { + scope: scope(), + generation: "gen".into(), + base_revision: 4, + revision: 5, + acked_sequence: 7, + upserts: vec![ChannelProjection { + channel_id: "ch".into(), + latest: 42, + count: 2, + badge_count: 1, + app_badge_count: 1, + top_level_unread: true, + high_priority_unread: false, + }], + removed: vec!["old".into()], + }) + .unwrap(); + let expected = serde_json::json!({"kind":"delta","scope":{"pubkey":"PK","relayUrl":"wss://relay/"},"generation":"gen","baseRevision":4,"revision":5,"ackedSequence":7,"upserts":[{"channelId":"ch","latest":42,"count":2,"badgeCount":1,"appBadgeCount":1,"topLevelUnread":true,"highPriorityUnread":false}],"removed":["old"]}); + assert_eq!(actual, expected); + } +} diff --git a/desktop/src-tauri/src/persona_catalog.rs b/desktop/src-tauri/src/persona_catalog.rs new file mode 100644 index 0000000000..5d1717d67c --- /dev/null +++ b/desktop/src-tauri/src/persona_catalog.rs @@ -0,0 +1,296 @@ +//! Native persona-catalog fetch and trust-boundary projection. +//! +//! The renderer owns presentation/linkage to local personas. Relay paging, +//! signature verification, NIP-33 head selection, and untrusted-content parsing +//! stay here so a catalog refresh crosses IPC once instead of once per page and +//! never performs Schnorr verification on the webview thread. + +use std::{collections::HashMap, time::Duration}; + +use buzz_core_pkg::kind::KIND_PERSONA; +use nostr::Event; +use regex::Regex; +use serde::Serialize; +use serde_json::Value; +use std::sync::LazyLock; +use tauri::State; + +use crate::{ + app_state::AppState, managed_agents::validate_agent_definition_text, + native_relay_client::NativeRelayClient, +}; + +const CATALOG_PAGE_SIZE: usize = 500; +const MAX_CATALOG_PAGES: usize = 40; +const PAGE_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_HTTP_AVATAR_LENGTH: usize = 2_048; +const INLINE_SVG_AVATAR_PREFIX: &str = "data:image/svg+xml,"; +const MAX_INLINE_SVG_AVATAR_LENGTH: usize = 8_192; +const MAX_INLINE_RASTER_AVATAR_LENGTH: usize = 256 * 1_024; + +static INLINE_RASTER_AVATAR: LazyLock> = LazyLock::new(|| { + Regex::new(r"^data:image/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$").ok() +}); + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PersonaCatalogPublication { + event_id: String, + owner_pubkey: String, + source_persona_id: String, + created_at: u64, + agent: CatalogAgentProjection, +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct CatalogAgentProjection { + display_name: String, + avatar_url: Option, + system_prompt: String, + runtime: Option, + model: Option, + provider: Option, + name_pool: Vec, + respond_to: Option, + parallelism: Option, +} + +/// Fetches the active community's relay-confirmed persona catalog. +/// +/// The command accepts no relay or identity input: both are snapshotted from +/// `AppState`, then checked again before return so an in-flight old-community +/// response cannot populate the new community's query cache. +#[tauri::command] +pub(crate) async fn fetch_persona_catalog( + state: State<'_, AppState>, + relay_client: State<'_, NativeRelayClient>, +) -> Result, String> { + let keys = state.signing_keys()?; + let owner = keys.public_key().to_hex(); + let relay_url = crate::relay::relay_ws_url_with_override(&state); + let session = relay_client.session(relay_url.clone(), keys).await; + let mut by_id = HashMap::new(); + let mut until = None; + + for _ in 0..MAX_CATALOG_PAGES { + let mut filter = serde_json::json!({ + "kinds": [KIND_PERSONA], + "limit": CATALOG_PAGE_SIZE, + }); + if let Some(until) = until { + filter["until"] = serde_json::json!(until); + } + let page = session.fetch_events(filter, PAGE_TIMEOUT).await?; + let page_len = page.len(); + // Schnorr verification is CPU-bound. Keep the complete page off the + // async executor (and therefore off Tauri command scheduling). + let verified = tauri::async_runtime::spawn_blocking(move || { + page.into_iter() + .filter(|event| event.verify().is_ok()) + .collect::>() + }) + .await + .map_err(|error| format!("catalog signature verification failed: {error}"))?; + + let progress = merge_verified_page(&mut by_id, page_len, verified); + match progress { + PageProgress::Done => break, + PageProgress::Next(next_until) => until = Some(next_until), + } + } + + let current_keys = state.signing_keys()?; + if current_keys.public_key().to_hex() != owner + || crate::relay::relay_ws_url_with_override(&state) != relay_url + { + return Err("persona catalog scope changed while fetching".to_string()); + } + + Ok(publications_from_verified_events( + by_id.into_values().collect(), + )) +} + +#[derive(Debug, PartialEq)] +enum PageProgress { + Done, + Next(u64), +} + +fn merge_verified_page( + by_id: &mut HashMap, + wire_page_len: usize, + verified: Vec, +) -> PageProgress { + let size_before = by_id.len(); + let oldest = verified + .iter() + .map(|event| event.created_at.as_secs()) + .min(); + for event in verified { + by_id.insert(event.id.to_hex(), event); + } + + // A short page is the end of the catalog; a page of only repeats means the + // inclusive `until` cursor cannot advance past tied timestamps. + if wire_page_len < CATALOG_PAGE_SIZE || by_id.len() == size_before { + return PageProgress::Done; + } + // A full page of invalid signatures cannot supply a trusted cursor. + oldest.map_or(PageProgress::Done, PageProgress::Next) +} + +fn publications_from_verified_events(mut events: Vec) -> Vec { + events.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + let mut claimed = std::collections::HashSet::new(); + let mut publications = Vec::new(); + + for event in events { + if event.kind.as_u16() as u32 != KIND_PERSONA { + continue; + } + let Some(source_persona_id) = coordinate_tag(&event, "d") else { + continue; + }; + if source_persona_id.is_empty() { + continue; + } + let owner_pubkey = event.pubkey.to_hex().to_ascii_lowercase(); + let coordinate = (owner_pubkey.clone(), source_persona_id.clone()); + if !claimed.insert(coordinate) { + continue; + } + + // Claim happens before visibility or parsing. A valid newest unshared + // or malformed head is still the NIP-33 head and must not resurrect an + // older shared definition. + if exact_tag(&event, "shared").as_deref() != Some("true") { + continue; + } + let Some(agent) = parse_agent(&event.content) else { + continue; + }; + publications.push(PersonaCatalogPublication { + event_id: event.id.to_hex(), + owner_pubkey, + source_persona_id, + created_at: event.created_at.as_secs(), + agent, + }); + } + publications +} + +fn coordinate_tag(event: &Event, name: &str) -> Option { + let matches = event + .tags + .iter() + .filter_map(|tag| { + let values = tag.as_slice(); + (values.len() >= 2 && values.first().is_some_and(|value| value == name)) + .then(|| values[1].clone()) + }) + .collect::>(); + (matches.len() == 1).then(|| matches[0].clone()) +} + +fn exact_tag(event: &Event, name: &str) -> Option { + let matches = event + .tags + .iter() + .filter_map(|tag| { + let values = tag.as_slice(); + (values.len() == 2 && values.first().is_some_and(|value| value == name)) + .then(|| values[1].clone()) + }) + .collect::>(); + (matches.len() == 1).then(|| matches[0].clone()) +} + +fn parse_agent(content: &str) -> Option { + let value: Value = serde_json::from_str(content).ok()?; + let object = value.as_object()?; + let display_name = object.get("display_name")?.as_str()?.to_string(); + let system_prompt = object + .get("system_prompt") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + validate_agent_definition_text(&display_name, &system_prompt).ok()?; + + let respond_to = match object.get("respond_to").and_then(Value::as_str) { + Some("allowlist") => Some("owner-only".to_string()), + Some(value @ ("owner-only" | "anyone")) => Some(value.to_string()), + _ => None, + }; + let parallelism = object + .get("parallelism") + .and_then(Value::as_u64) + .filter(|value| (1..=32).contains(value)); + let name_pool = object + .get("name_pool") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default(); + + Some(CatalogAgentProjection { + display_name, + avatar_url: object + .get("avatar_url") + .and_then(Value::as_str) + .filter(|value| safe_avatar(value)) + .map(ToOwned::to_owned), + system_prompt, + runtime: optional_string(object.get("runtime")), + model: optional_string(object.get("model")), + provider: optional_string(object.get("provider")), + name_pool, + respond_to, + parallelism, + }) +} + +fn optional_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) +} + +fn safe_avatar(value: &str) -> bool { + if value.starts_with(INLINE_SVG_AVATAR_PREFIX) { + return value.len() <= MAX_INLINE_SVG_AVATAR_LENGTH; + } + if value.len() <= MAX_INLINE_RASTER_AVATAR_LENGTH { + if let Some(captures) = INLINE_RASTER_AVATAR + .as_ref() + .and_then(|pattern| pattern.captures(value)) + { + return captures + .get(1) + .is_some_and(|payload| payload.as_str().len() % 4 == 0); + } + } + value.len() <= MAX_HTTP_AVATAR_LENGTH + && !value.chars().any(char::is_whitespace) + && !value.contains(['(', ')']) + && url::Url::parse(value) + .ok() + .is_some_and(|url| matches!(url.scheme(), "http" | "https")) +} + +#[cfg(test)] +#[path = "persona_catalog_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/persona_catalog_tests.rs b/desktop/src-tauri/src/persona_catalog_tests.rs new file mode 100644 index 0000000000..d3175ef980 --- /dev/null +++ b/desktop/src-tauri/src/persona_catalog_tests.rs @@ -0,0 +1,235 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde_json::json; + +fn event(keys: &Keys, created_at: u64, source: &str, shared: bool, content: Value) -> Event { + let mut tags = vec![Tag::parse(["d", source]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), content.to_string()) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + +fn valid_content(name: &str) -> Value { + json!({ + "display_name": name, + "system_prompt": "Review changes.", + "avatar_url": "https://relay.example/avatar.png", + "runtime": " goose ", + "model": "claude", + "provider": null, + "name_pool": ["Reviewer", 7], + "respond_to": "allowlist", + "parallelism": 4 + }) +} + +#[test] +fn paging_uses_oldest_verified_cursor_and_stops_on_ties_or_short_pages() { + let keys = Keys::generate(); + let newest = event(&keys, 9, "newest", true, valid_content("Newest")); + let oldest = event(&keys, 4, "oldest", true, valid_content("Oldest")); + let mut by_id = HashMap::new(); + + assert_eq!( + merge_verified_page( + &mut by_id, + CATALOG_PAGE_SIZE, + vec![newest.clone(), oldest.clone()] + ), + PageProgress::Next(4) + ); + assert_eq!( + merge_verified_page(&mut by_id, CATALOG_PAGE_SIZE, vec![newest, oldest]), + PageProgress::Done + ); + + let short = event(&keys, 1, "short", true, valid_content("Short")); + assert_eq!( + merge_verified_page(&mut by_id, CATALOG_PAGE_SIZE - 1, vec![short]), + PageProgress::Done + ); + assert_eq!( + merge_verified_page(&mut HashMap::new(), CATALOG_PAGE_SIZE, Vec::new()), + PageProgress::Done + ); +} + +#[test] +fn forged_newest_head_is_dropped_before_it_can_claim_the_coordinate() { + let keys = Keys::generate(); + let older = event(&keys, 1, "reviewer", true, valid_content("Older")); + let mut forged = event(&keys, 2, "reviewer", true, valid_content("Forged")); + forged.content = valid_content("Tampered").to_string(); + + let verified = [older.clone(), forged] + .into_iter() + .filter(|candidate| candidate.verify().is_ok()) + .collect(); + let publications = publications_from_verified_events(verified); + assert_eq!(publications.len(), 1); + assert_eq!(publications[0].event_id, older.id.to_hex()); +} + +#[test] +fn valid_newest_head_claims_before_visibility_and_content_parsing() { + let keys = Keys::generate(); + for newest in [ + event(&keys, 2, "reviewer", false, valid_content("Unshared")), + event(&keys, 2, "reviewer", true, json!({})), + ] { + let older = event(&keys, 1, "reviewer", true, valid_content("Older")); + assert!(publications_from_verified_events(vec![older, newest]).is_empty()); + } +} + +#[test] +fn equal_second_heads_use_lowest_event_id_and_authors_are_independent() { + let alice = Keys::generate(); + let bob = Keys::generate(); + let shared = event(&alice, 1, "reviewer", true, valid_content("Shared")); + let unshared = event(&alice, 1, "reviewer", false, valid_content("Hidden")); + let bob_head = event(&bob, 1, "reviewer", true, valid_content("Bob")); + let expected_alice = if shared.id < unshared.id { 1 } else { 0 }; + + let publications = publications_from_verified_events(vec![shared, unshared, bob_head]); + assert_eq!(publications.len(), expected_alice + 1); +} + +#[test] +fn parser_projects_types_and_foreign_allowlists_exactly() { + let projection = parse_agent(&valid_content("Reviewer").to_string()).unwrap(); + assert_eq!(projection.display_name, "Reviewer"); + assert_eq!(projection.runtime.as_deref(), Some(" goose ")); + assert_eq!(projection.provider, None); + assert_eq!(projection.name_pool, vec!["Reviewer"]); + assert_eq!(projection.respond_to.as_deref(), Some("owner-only")); + assert_eq!(projection.parallelism, Some(4)); + + for bad in [0, 33] { + let mut content = valid_content("Reviewer"); + content["parallelism"] = json!(bad); + assert_eq!(parse_agent(&content.to_string()).unwrap().parallelism, None); + } +} + +#[test] +fn parser_rejects_malformed_and_invisible_definition_text() { + for content in [ + "not-json".to_string(), + "[]".to_string(), + json!({"display_name": 7}).to_string(), + valid_content("Review\u{202e}er").to_string(), + ] { + assert!(parse_agent(&content).is_none()); + } + let visible = parse_agent( + &json!({ + "display_name": "Reviewer 🐝", + "system_prompt": "Review.\n\t||literal markdown||" + }) + .to_string(), + ) + .unwrap(); + assert_eq!(visible.display_name, "Reviewer 🐝"); +} + +#[test] +fn avatar_allowlist_and_bounds_match_the_renderer_contract() { + assert!(safe_avatar("https://relay.example/avatar.png")); + assert!(!safe_avatar("javascript:alert(1)")); + assert!(safe_avatar("data:image/svg+xml,")); + assert!(!safe_avatar(&format!( + "data:image/svg+xml,{}", + "a".repeat(MAX_INLINE_SVG_AVATAR_LENGTH) + ))); + for mime in ["png", "jpeg", "gif", "webp"] { + assert!(safe_avatar(&format!( + "data:image/{mime};base64,iVBORw0KGgo=" + ))); + } + assert!(!safe_avatar("data:image/bmp;base64,aA==")); + assert!(!safe_avatar("data:image/png;base64,not base64")); +} + +#[test] +fn exact_tags_reject_duplicates_and_extra_fields() { + let keys = Keys::generate(); + let base = event(&keys, 1, "reviewer", true, valid_content("Reviewer")); + assert_eq!(exact_tag(&base, "shared").as_deref(), Some("true")); + + let duplicate = EventBuilder::new( + Kind::Custom(KIND_PERSONA as u16), + valid_content("x").to_string(), + ) + .tags([ + Tag::parse(["d", "reviewer"]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(exact_tag(&duplicate, "shared"), None); + + // The old renderer accepts an extended d tag (it reads tag[1]) but shared + // is opt-in only for the exact two-field shape. + let extended = EventBuilder::new( + Kind::Custom(KIND_PERSONA as u16), + valid_content("x").to_string(), + ) + .tags([ + Tag::parse(["d", "reviewer", "relay hint"]).unwrap(), + Tag::parse(["shared", "true", "extra"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(coordinate_tag(&extended, "d").as_deref(), Some("reviewer")); + assert_eq!(exact_tag(&extended, "shared"), None); +} + +/// Pins the serialized DTO output against the renderer's catalog contract. +/// The Tauri generic is only a TypeScript assertion; serde's bytes are the +/// actual boundary, so populate every optional field and compare the value. +#[test] +fn serialized_catalog_matches_the_typescript_contract() { + let publication = PersonaCatalogPublication { + event_id: "ev1".into(), + owner_pubkey: "owner".into(), + source_persona_id: "persona-1".into(), + created_at: 42, + agent: CatalogAgentProjection { + display_name: "Ada".into(), + avatar_url: Some("https://example.com/a.png".into()), + system_prompt: "be kind".into(), + runtime: Some("acp".into()), + model: Some("m1".into()), + provider: Some("p1".into()), + name_pool: vec!["Ada".into(), "Lin".into()], + respond_to: Some("mentions".into()), + parallelism: Some(2), + }, + }; + let actual = serde_json::to_value(vec![publication]).unwrap(); + let expected = serde_json::json!([{ + "eventId": "ev1", + "ownerPubkey": "owner", + "sourcePersonaId": "persona-1", + "createdAt": 42, + "agent": { + "displayName": "Ada", + "avatarUrl": "https://example.com/a.png", + "systemPrompt": "be kind", + "runtime": "acp", + "model": "m1", + "provider": "p1", + "namePool": ["Ada", "Lin"], + "respondTo": "mentions", + "parallelism": 2, + }, + }]); + assert_eq!(actual, expected); +} diff --git a/desktop/src-tauri/src/ptt_shortcut.rs b/desktop/src-tauri/src/ptt_shortcut.rs index a80af67a4d..7a85140f5a 100644 --- a/desktop/src-tauri/src/ptt_shortcut.rs +++ b/desktop/src-tauri/src/ptt_shortcut.rs @@ -8,6 +8,111 @@ use crate::huddle::HuddleState; #[cfg(not(test))] use crate::huddle::{HuddlePhase, VoiceInputMode}; +use tauri::{Builder, Runtime}; + +/// Install the global-shortcut plugin and its push-to-talk key handler. +/// +/// No-op in test builds: linking the plugin into the lib-test binary makes it +/// fail to load on Windows (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs. +/// `sync_registration` is stubbed out under the same cfg for the same reason. +#[cfg(test)] +pub fn install(builder: Builder) -> Builder { + builder +} + +/// Install the global-shortcut plugin and its push-to-talk key handler. +/// +/// Registration itself is driven by huddle state through [`sync_registration`]; +/// this only installs the plugin the handler runs on. +#[cfg(not(test))] +pub fn install(builder: Builder) -> Builder { + use crate::app_state::AppState; + use std::sync::Arc; + use tauri::{Emitter, Manager}; + use tauri_plugin_global_shortcut::ShortcutState; + + // Generation counter for the release delay task. Incremented on + // every press — a delayed release only fires if the generation + // hasn't changed (i.e. no new press happened during the delay). + // This prevents press→release→press within 200 ms from having + // the first release clobber the second press. + let ptt_press_gen = Arc::new(std::sync::atomic::AtomicU64::new(0)); + + builder.plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(move |app, _shortcut, event| { + let state = match app.try_state::() { + Some(s) => s, + None => return, + }; + + // Only act if a huddle is active and mode is PTT. + let (is_ptt_mode, is_active) = match state.huddle_state.lock() { + Ok(hs) => ( + hs.voice_input_mode == VoiceInputMode::PushToTalk, + matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), + ), + Err(_) => return, + }; + + if !is_ptt_mode || !is_active { + return; + } + + match event.state { + ShortcutState::Pressed => { + // Bump generation — invalidates any pending release delay. + ptt_press_gen.fetch_add(1, std::sync::atomic::Ordering::Release); + + if let Ok(hs) = state.huddle_state.lock() { + hs.ptt_active + .store(true, std::sync::atomic::Ordering::Release); + // Only cancel TTS if it's actually playing — avoids + // a stale cancel flag that drops the next queued message. + if hs.tts_active.load(std::sync::atomic::Ordering::Acquire) { + hs.tts_cancel + .store(true, std::sync::atomic::Ordering::Release); + } + } + // Emit ptt-state=true to the frontend. + // The React side plays the press audio cue on this event + // (Web Audio API via HuddleContext). Rust-side rodio audio + // was considered but rejected: the rodio OutputStream must + // outlive the handler and sharing it across the shortcut + // closure adds lifecycle complexity for marginal gain. + // The React implementation is sufficient and simpler. + let _ = app.emit("ptt-state", true); + } + ShortcutState::Released => { + // Capture generation at release time. + let gen_at_release = + ptt_press_gen.load(std::sync::atomic::Ordering::Acquire); + let gen_arc = Arc::clone(&ptt_press_gen); + let app_handle = app.clone(); + // 200 ms release delay — captures the tail of the utterance. + // Only applies if no new press happened during the delay. + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + // Check generation — if it changed, a new press arrived. + if gen_arc.load(std::sync::atomic::Ordering::Acquire) != gen_at_release + { + return; // Superseded by a new press. + } + if let Some(state) = app_handle.try_state::() { + if let Ok(hs) = state.huddle_state.lock() { + hs.ptt_active + .store(false, std::sync::atomic::Ordering::Release); + } + } + // Emit ptt-state=false — React plays the release audio cue. + let _ = app_handle.emit("ptt-state", false); + }); + } + } + }) + .build(), + ) +} /// Whether the PTT shortcut should currently be reserved with the OS. #[cfg(not(test))] diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 685f83b799..bd3fefb125 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -31,7 +31,7 @@ pub fn relay_ws_url() -> String { /// Read the workspace relay URL override, if set. Returns `None` when no /// override is active or when the mutex is poisoned (best-effort). -fn workspace_relay_override(state: &AppState) -> Option { +pub(crate) fn workspace_relay_override(state: &AppState) -> Option { state .relay_url_override .lock() @@ -84,6 +84,12 @@ pub fn relay_http_base_url(relay_url: &str) -> String { trimmed.to_string() } +mod scope; +pub use scope::{ + assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope, + bind_expected_signer, ScopedWorkspaceRelay, +}; + pub fn relay_api_base_url() -> String { if let Some(base) = configured_env_var("BUZZ_RELAY_HTTP") { return base.trim_end_matches('/').to_string(); @@ -537,7 +543,8 @@ pub use get::get_relay_json; mod submit; pub use submit::{ - submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse, + submit_event, submit_event_at_created_at, submit_event_at_with_keys, + submit_event_with_keys_created_at, submit_signed_event_at_with_keys, SubmitEventResponse, }; /// Sign an event with explicit keys and POST it to `/events` with NIP-98 auth. diff --git a/desktop/src-tauri/src/relay/scope.rs b/desktop/src-tauri/src/relay/scope.rs new file mode 100644 index 0000000000..b9c73328af --- /dev/null +++ b/desktop/src-tauri/src/relay/scope.rs @@ -0,0 +1,239 @@ +use super::relay_http_base_url; + +/// Fail closed when a caller-captured relay scope no longer matches the +/// relay a command actually resolved. +/// +/// Long-lived UI callbacks (e.g. the Projects agent submit flow) capture the +/// community relay before their first await; a workspace switch during that +/// await would otherwise retarget the eventual publication to the new +/// tenant's relay. Callers pass the captured scope as a ws(s) URL; it is +/// normalized through [`relay_http_base_url`] and compared against the base +/// the command resolved once and uses for every side effect. `None` preserves +/// the unscoped behavior for callers without a tenant boundary. +pub fn assert_expected_relay_scope( + expected_relay_url: Option<&str>, + resolved_api_base_url: &str, +) -> Result<(), String> { + let Some(expected) = expected_relay_url.map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(()); + }; + let expected_base = relay_http_base_url(expected); + if expected_base != resolved_api_base_url.trim().trim_end_matches('/') { + return Err( + "active community changed before the message was submitted; not sent".to_string(), + ); + } + Ok(()) +} + +/// A workspace-relay read that has passed the caller-captured scope check. +/// +/// The only constructor is [`bind_expected_relay_scope`], so any side effect +/// that takes this type is proven — by construction — to consume the exact +/// value the check passed on, never a re-read of the mutable override. This +/// closes the check/use gap where a workspace switch landing between a scope +/// assertion and the side effect retargets it to a tenant the caller never +/// validated. +#[derive(Debug)] +pub struct ScopedWorkspaceRelay(String); + +impl ScopedWorkspaceRelay { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Validate a caller-captured relay scope against one workspace-relay read +/// and bind that exact read for the side effect to consume. +/// +/// `None` preserves the unscoped behavior for callers without a tenant +/// boundary — the read is still bound so the side effect stays single-read. +pub fn bind_expected_relay_scope( + expected_relay_url: Option<&str>, + workspace_relay_url: String, +) -> Result { + assert_expected_relay_scope( + expected_relay_url, + &relay_http_base_url(&workspace_relay_url), + )?; + Ok(ScopedWorkspaceRelay(workspace_relay_url)) +} + +/// Fail closed when a caller-captured signer identity no longer matches the +/// identity a command actually read. +/// +/// The relay URL and the signing keys live under separate locks and a +/// workspace switch mutates them in sequence, so a caller that only pins the +/// relay can still have its event signed — and its NIP-98 auth minted — by +/// the *new* tenant's identity if the switch lands between the URL check and +/// the key read. Callers capture the expected owner pubkey together with the +/// relay scope; commands read one identity snapshot, assert it here, and use +/// that exact snapshot for every signature. `None` preserves the unscoped +/// behavior for callers without a tenant boundary. +pub fn assert_expected_signer( + expected_signer_pubkey: Option<&str>, + actual_signer_hex: &str, +) -> Result<(), String> { + let Some(expected) = expected_signer_pubkey + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + return Ok(()); + }; + if !expected.eq_ignore_ascii_case(actual_signer_hex) { + return Err( + "active identity changed before the message was submitted; not sent".to_string(), + ); + } + Ok(()) +} + +/// A workspace-signer read that has passed the caller-captured identity check. +/// +/// The only constructor is [`bind_expected_signer`], so side effects consume +/// the exact owner read that was validated rather than a stale pre-await value. +#[derive(Debug)] +pub struct ScopedWorkspaceSigner(String); + +impl ScopedWorkspaceSigner { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Validate a caller-captured signer against one active-owner read and bind +/// that exact read for the side effect to consume. `None` preserves unscoped +/// callers while still making the owner input single-read. +pub fn bind_expected_signer( + expected_signer_pubkey: Option<&str>, + actual_signer_hex: String, +) -> Result { + assert_expected_signer(expected_signer_pubkey, &actual_signer_hex)?; + Ok(ScopedWorkspaceSigner(actual_signer_hex)) +} + +#[cfg(test)] +mod tests { + use super::{ + assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope, + bind_expected_signer, + }; + + #[test] + fn matching_scope_passes_across_ws_http_normalization() { + assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-a.example") + .unwrap(); + assert_expected_relay_scope(Some("ws://localhost:3000"), "http://localhost:3000").unwrap(); + // Trailing-slash and whitespace tolerance mirrors relay_http_base_url. + assert_expected_relay_scope( + Some(" wss://tenant-a.example/ "), + "https://tenant-a.example/", + ) + .unwrap(); + } + + #[test] + fn changed_scope_fails_closed() { + let error = + assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-b.example") + .unwrap_err(); + assert!(error.contains("active community changed"), "{error}"); + } + + #[test] + fn absent_scope_preserves_unscoped_sends() { + assert_expected_relay_scope(None, "https://anything.example").unwrap(); + assert_expected_relay_scope(Some(""), "https://anything.example").unwrap(); + assert_expected_relay_scope(Some(" "), "https://anything.example").unwrap(); + } + + #[test] + fn bound_scope_is_immune_to_a_switch_landing_after_the_bind() { + // Models the round-7 startup race: the caller captured tenant A, the + // post-preflight bind reads the workspace relay while it is still A, + // and THEN the switch to B lands — after the check, before the spawn. + // The spawn consumes the BOUND value, not a re-read, so the pair can + // only ever be keyed to the tenant the caller validated; the switch + // mutates state the spawn no longer consults. + let mut workspace = "wss://tenant-a.example".to_string(); + let bound = + bind_expected_relay_scope(Some("wss://tenant-a.example"), workspace.clone()).unwrap(); + workspace = "wss://tenant-b.example".to_string(); // the switch lands post-check + assert_eq!(bound.as_str(), "wss://tenant-a.example"); + assert_ne!( + bound.as_str(), + workspace, + "spawn input must be the checked value" + ); + } + + #[test] + fn bind_fails_closed_when_the_switch_lands_before_the_read() { + // The switch landed during the preflight await, so the one workspace + // read already sees tenant B: no relay may be released to the spawn. + let error = bind_expected_relay_scope( + Some("wss://tenant-a.example"), + "wss://tenant-b.example".to_string(), + ) + .unwrap_err(); + assert!(error.contains("active community changed"), "{error}"); + } + + #[test] + fn bind_returns_the_exact_read_for_unscoped_callers() { + let bound = bind_expected_relay_scope(None, "wss://anything.example".to_string()).unwrap(); + assert_eq!(bound.as_str(), "wss://anything.example"); + } + + // The round-7 pair-key regression moved to + // `managed_agents::runtime::tests::production_spawn_key_derives_from_the_bound_relay_not_the_post_switch_workspace`, + // which exercises `bound_runtime_key` — the seam production spawn keys on — + // instead of reconstructing the derivation by hand here. + + #[test] + fn matching_signer_passes_case_insensitively() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + assert_expected_signer(Some(&hex), &hex).unwrap(); + assert_expected_signer(Some(&hex.to_ascii_uppercase()), &hex).unwrap(); + assert_expected_signer(Some(&format!(" {hex} ")), &hex).unwrap(); + } + + #[test] + fn changed_signer_fails_closed() { + // Models the workspace-switch race: the caller captured tenant A's + // owner identity, but the switch landed before the command read the + // keys, so the snapshot now holds tenant B's identity. + let captured = nostr::Keys::generate().public_key().to_hex(); + let switched = nostr::Keys::generate().public_key().to_hex(); + let error = assert_expected_signer(Some(&captured), &switched).unwrap_err(); + assert!(error.contains("active identity changed"), "{error}"); + } + + #[test] + fn signer_bind_fails_closed_after_same_relay_identity_switch() { + let captured = nostr::Keys::generate().public_key().to_hex(); + let switched = nostr::Keys::generate().public_key().to_hex(); + let error = bind_expected_signer(Some(&captured), switched).unwrap_err(); + assert!(error.contains("active identity changed"), "{error}"); + } + + #[test] + fn signer_bind_returns_exact_read_for_scoped_and_unscoped_callers() { + let actual = nostr::Keys::generate().public_key().to_hex(); + let scoped = bind_expected_signer(Some(&actual), actual.clone()).unwrap(); + assert_eq!(scoped.as_str(), actual); + + let unscoped_actual = nostr::Keys::generate().public_key().to_hex(); + let unscoped = bind_expected_signer(None, unscoped_actual.clone()).unwrap(); + assert_eq!(unscoped.as_str(), unscoped_actual); + } + + #[test] + fn absent_signer_preserves_unscoped_sends() { + let hex = nostr::Keys::generate().public_key().to_hex(); + assert_expected_signer(None, &hex).unwrap(); + assert_expected_signer(Some(""), &hex).unwrap(); + assert_expected_signer(Some(" "), &hex).unwrap(); + } +} diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index eaad29d3b1..b6a5703fd9 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -76,3 +76,50 @@ pub async fn submit_event( let keys = state.signing_keys()?; submit_event_at_with_keys(builder, state, &api_base_url, &keys).await } + +/// Sign with an explicit identity, submit to an explicit HTTP API base URL, +/// and also return the signed event's `created_at`. +/// +/// Callers that persist a timestamp as an event cursor (e.g. the Projects +/// conversation opener) need the signed event's own second — a +/// post-publication clock read can land a second later and permanently +/// exclude other events stamped in the event's real second. +/// +/// The explicit base (rather than a re-read of the workspace override at +/// submit time) matters for the same callers: they validated a tenant scope +/// against the resolved base earlier in the same command, and re-resolving +/// here would reopen the window where a workspace switch retargets the event +/// after the check passed. The explicit `keys` close the sibling window: the +/// relay URL and the signing keys mutate under separate locks during a +/// workspace switch, so re-reading the keys here could sign — and NIP-98 +/// authenticate — the event as the *new* tenant's identity after the caller +/// validated the old one. The caller passes the exact snapshot it asserted. +pub async fn submit_event_at_created_at( + builder: nostr::EventBuilder, + state: &AppState, + api_base_url: &str, + keys: &nostr::Keys, +) -> Result<(SubmitEventResponse, i64), String> { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + let created_at = event.created_at.as_secs() as i64; + let result = submit_signed_event_at_with_keys(&event, state, api_base_url, keys).await?; + Ok((result, created_at)) +} + +/// Like `submit_event_with_keys`, but also returns the signed event's +/// `created_at` — same cursor rationale as [`submit_event_at_created_at`]. +pub async fn submit_event_with_keys_created_at( + builder: nostr::EventBuilder, + state: &AppState, + keys: &nostr::Keys, + auth_tag: Option<&str>, +) -> Result<(SubmitEventResponse, i64), String> { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + let created_at = event.created_at.as_secs() as i64; + let result = super::submit_signed_event_with_keys(&event, state, keys, auth_tag).await?; + Ok((result, created_at)) +} diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index efd88f3cac..17ca7a7bb3 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -19,6 +19,7 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a .store(true, Ordering::SeqCst); if !shutdown_done.swap(true, Ordering::SeqCst) { prevent_sleep::release(&app.state::().prevent_sleep); + crate::observed_unread::flush(app); app.state::() .shutdown_all(); if let Err(error) = shutdown_managed_agents(app) { diff --git a/desktop/src-tauri/src/unread_catch_up.rs b/desktop/src-tauri/src/unread_catch_up.rs new file mode 100644 index 0000000000..f8609ef1f6 --- /dev/null +++ b/desktop/src-tauri/src/unread_catch_up.rs @@ -0,0 +1,668 @@ +//! Batched native unread catch-up. +//! +//! Native unread catch-up consumes notification membership from the observed- +//! unread SQLite store rather than serializing renderer-owned sets on every +//! request. Rust performs every channel REQ over the shared authenticated +//! session, then classifies the complete successful batch in two passes so a +//! root learned anywhere in pass one is visible everywhere in pass two. + +use std::{collections::HashSet, time::Duration}; + +use buzz_core_pkg::kind::{ + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_HUDDLE_STARTED, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, +}; +use nostr::Event; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; +use tokio::{sync::Semaphore, task::JoinSet}; + +use crate::{app_state::AppState, native_relay_client::NativeRelayClient}; + +const CATCH_UP_LIMIT: usize = 1_000; +const ACTIVITY_LIMIT: usize = 100; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UnreadCatchUpRequest { + channels: Vec, + self_pubkey: String, + muted_channel_ids: HashSet, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CatchUpChannel { + id: String, + #[serde(rename = "type")] + channel_type: String, + name: String, + read_at: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UnreadCatchUpResponse { + channels: Vec, +} + +#[derive(Serialize)] +#[serde( + tag = "status", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +enum ChannelResult { + Success { + channel_id: String, + observed_events: Vec, + max_trigger: u64, + activity_rows: Vec, + discovered: DiscoveredRoots, + }, + Error { + channel_id: String, + error: String, + }, +} + +#[derive(Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ObservedUnreadEvent { + id: String, + created_at: u64, + root_id: Option, + high_priority: bool, + counts_toward_badge: bool, + counts_toward_app_badge: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ActivityRow { + id: String, + kind: u16, + pubkey: String, + content: String, + created_at: u64, + channel_id: String, + channel_name: String, + tags: Vec>, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct DiscoveredRoots { + participated: Vec, + authored: Vec, + mentioned: Vec, +} + +struct FetchedChannel { + order: usize, + channel: CatchUpChannel, + events: Vec, +} + +#[derive(Clone)] +struct EventView { + id: String, + kind: u16, + pubkey: String, + content: String, + created_at: u64, + tags: Vec>, +} + +impl From for EventView { + fn from(event: Event) -> Self { + Self { + id: event.id.to_hex(), + kind: event.kind.as_u16(), + pubkey: event.pubkey.to_hex(), + content: event.content, + created_at: event.created_at.as_secs(), + tags: event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(), + } + } +} + +#[tauri::command] +pub(crate) async fn unread_catch_up( + request: UnreadCatchUpRequest, + state: State<'_, AppState>, + relay_client: State<'_, NativeRelayClient>, + app: AppHandle, +) -> Result { + let keys = state.signing_keys()?; + let owner = keys.public_key().to_hex(); + if !owner.eq_ignore_ascii_case(&request.self_pubkey) { + return Err("unread catch-up identity does not match active scope".to_string()); + } + let relay_url = crate::relay::relay_ws_url_with_override(&state); + // The lease must outlive every task below: when the leased session is + // private (a scope switch landed mid-command), dropping the lease shuts + // that session down, and a `handle()` clone still held by a running fetch + // would then be reading a cancelled socket. The `join_next` drain ends + // before this binding does, so that holds today — keep it that way, and in + // particular do not move the lease into a task or narrow its scope. + let session = relay_client.session(relay_url.clone(), keys).await; + + let concurrency = std::sync::Arc::new(Semaphore::new(8)); + let mut pending = JoinSet::new(); + // One command replaces N renderer invokes while the shared session still + // multiplexes bounded finite REQs on one authenticated socket. + for (order, channel) in request.channels.iter().cloned().enumerate() { + let permit = concurrency + .clone() + .acquire_owned() + .await + .map_err(|error| error.to_string())?; + let session = session.handle(); + pending.spawn(async move { + let _permit = permit; + let kinds: &[u32] = if channel.channel_type == "dm" { + &[ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + KIND_HUDDLE_STARTED, + ] + } else { + &[ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + ] + }; + let filter = serde_json::json!({ + "kinds": kinds, + "#h": [channel.id], + "since": channel.read_at.map_or(0, |value| value.saturating_add(1)), + "limit": CATCH_UP_LIMIT, + }); + let result = session.fetch_events(filter, REQUEST_TIMEOUT).await; + (order, channel, result) + }); + } + + let mut fetched = Vec::new(); + let mut failures = Vec::new(); + while let Some(joined) = pending.join_next().await { + let (order, channel, result) = + joined.map_err(|error| format!("unread catch-up task failed: {error}"))?; + match result { + Ok(events) => fetched.push(FetchedChannel { + order, + channel, + events: events + .into_iter() + .take(CATCH_UP_LIMIT) + .map(EventView::from) + .collect(), + }), + Err(error) => failures.push(ChannelResult::Error { + channel_id: channel.id, + error, + }), + } + } + + fetched.sort_by_key(|item| item.order); + + let current_keys = state.signing_keys()?; + if current_keys.public_key().to_hex() != owner + || crate::relay::relay_ws_url_with_override(&state) != relay_url + { + return Err("unread catch-up scope changed while fetching".to_string()); + } + + let membership = crate::observed_unread::load_membership( + &app, + &crate::observed_unread::ObservedUnreadScope { + pubkey: owner, + relay_url, + }, + )?; + let mut channels = classify_batch(&request, fetched, &membership); + channels.extend(failures); + Ok(UnreadCatchUpResponse { channels }) +} + +fn classify_batch( + request: &UnreadCatchUpRequest, + fetched: Vec, + membership: &std::collections::HashMap>, +) -> Vec { + let self_pubkey = request.self_pubkey.to_lowercase(); + let mut participated = membership.get("participated").cloned().unwrap_or_default(); + let mut authored = membership.get("authored").cloned().unwrap_or_default(); + let mut mentioned = membership.get("mentioned").cloned().unwrap_or_default(); + + // Pass one is deliberately global, not per-channel: notification validity + // depends on roots learned from history, while the command observes a batch. + // Deltas remain attributed to the channel that first discovered each root. + let mut discoveries = Vec::with_capacity(fetched.len()); + for item in &fetched { + let mut discovered = DiscoveredRoots::default(); + for event in &item.events { + if event.pubkey.eq_ignore_ascii_case(&self_pubkey) { + let reference = thread_reference(&event.tags); + if let Some(root_id) = reference.root_id { + if participated.insert(root_id.clone()) { + discovered.participated.push(root_id); + } + } else if authored.insert(event.id.clone()) { + discovered.authored.push(event.id.clone()); + } + } else if has_tag_value(&event.tags, "p", &self_pubkey) { + if let Some(root_id) = thread_reference(&event.tags).root_id { + if mentioned.insert(root_id.clone()) { + discovered.mentioned.push(root_id); + } + } + } + } + discoveries.push(discovered); + } + + let mut outputs = Vec::new(); + let mut all_activity = Vec::new(); + for (item, discovered) in fetched.into_iter().zip(discoveries) { + let mut observed_events = Vec::new(); + let mut activity_rows = Vec::new(); + let mut max_trigger = 0; + for event in item.events { + if event.pubkey.eq_ignore_ascii_case(&self_pubkey) + || item + .channel + .read_at + .is_some_and(|read_at| event.created_at <= read_at) + || !should_notify( + &event, + &self_pubkey, + request, + membership, + &participated, + &authored, + ) + { + continue; + } + let reference = thread_reference(&event.tags); + let broadcast = has_exact_tag(&event.tags, "broadcast", "1"); + let threaded = reference.parent_id.is_some() && !broadcast; + let high_priority = item.channel.channel_type == "dm" + || broadcast + || has_tag_value(&event.tags, "p", &self_pubkey); + max_trigger = max_trigger.max(event.created_at); + observed_events.push(ObservedUnreadEvent { + id: event.id.clone(), + created_at: event.created_at, + root_id: if broadcast { + None + } else { + reference.root_id.clone() + }, + high_priority, + counts_toward_badge: item.channel.channel_type == "dm" || threaded || high_priority, + counts_toward_app_badge: item.channel.channel_type == "dm" + || (!threaded && high_priority), + }); + if threaded { + activity_rows.push(ActivityRow { + id: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + created_at: event.created_at, + channel_id: item.channel.id.clone(), + channel_name: item.channel.name.clone(), + tags: event.tags, + }); + } + } + all_activity.extend(activity_rows.iter().cloned()); + outputs.push(( + item.channel.id, + observed_events, + max_trigger, + activity_rows, + discovered, + )); + } + + all_activity.sort_by_key(|row| row.created_at); + let mut seen = HashSet::new(); + all_activity.retain(|row| seen.insert(row.id.clone())); + if all_activity.len() > ACTIVITY_LIMIT { + all_activity.drain(..all_activity.len() - ACTIVITY_LIMIT); + } + let allowed: HashSet<_> = all_activity.into_iter().map(|row| row.id).collect(); + + outputs + .into_iter() + .map( + |(channel_id, observed_events, max_trigger, mut activity_rows, discovered)| { + activity_rows.retain(|row| allowed.contains(&row.id)); + ChannelResult::Success { + channel_id, + observed_events, + max_trigger, + activity_rows, + discovered, + } + }, + ) + .collect() +} + +struct ThreadReference { + parent_id: Option, + root_id: Option, +} + +fn thread_reference(tags: &[Vec]) -> ThreadReference { + let event_tags: Vec<_> = tags + .iter() + .filter(|tag| tag.first().is_some_and(|v| v == "e") && tag.get(1).is_some()) + .collect(); + let root = event_tags + .iter() + .find(|tag| tag.get(3).is_some_and(|v| v == "root")); + let reply = event_tags + .iter() + .rev() + .find(|tag| tag.get(3).is_some_and(|v| v == "reply")); + let Some(reply) = reply else { + return ThreadReference { + parent_id: None, + root_id: None, + }; + }; + let parent_id = reply.get(1).cloned(); + ThreadReference { + root_id: root + .and_then(|tag| tag.get(1).cloned()) + .or_else(|| parent_id.clone()), + parent_id, + } +} + +fn should_notify( + event: &EventView, + self_pubkey: &str, + request: &UnreadCatchUpRequest, + membership: &std::collections::HashMap>, + participated: &HashSet, + authored: &HashSet, +) -> bool { + if has_exact_tag(&event.tags, "broadcast", "1") || has_tag_value(&event.tags, "p", self_pubkey) + { + return true; + } + let event_channel_id = event + .tags + .iter() + .find(|tag| tag.first().is_some_and(|part| part == "h")) + .and_then(|tag| tag.get(1)); + if event_channel_id.is_some_and(|id| request.muted_channel_ids.contains(id)) { + return false; + } + let reference = thread_reference(&event.tags); + if reference.parent_id.is_none() { + return true; + } + let Some(root_id) = reference.root_id else { + return false; + }; + if membership + .get("muted_root") + .is_some_and(|set| set.contains(&root_id)) + { + return false; + } + participated.contains(&root_id) + || membership + .get("followed") + .is_some_and(|set| set.contains(&root_id)) + || authored.contains(&root_id) +} + +fn has_exact_tag(tags: &[Vec], name: &str, value: &str) -> bool { + tags.iter().any(|tag| { + tag.first().is_some_and(|part| part == name) && tag.get(1).is_some_and(|part| part == value) + }) +} + +fn has_tag_value(tags: &[Vec], name: &str, value: &str) -> bool { + tags.iter().any(|tag| { + tag.first().is_some_and(|part| part == name) + && tag + .get(1) + .is_some_and(|part| part.eq_ignore_ascii_case(value)) + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn event(id: &str, pubkey: &str, created_at: u64, tags: &[&[&str]]) -> EventView { + EventView { + id: id.into(), + kind: 9, + pubkey: pubkey.into(), + content: id.into(), + created_at, + tags: tags + .iter() + .map(|tag| tag.iter().map(|part| (*part).to_string()).collect()) + .collect(), + } + } + + fn request() -> UnreadCatchUpRequest { + UnreadCatchUpRequest { + channels: vec![], + self_pubkey: "self".into(), + muted_channel_ids: HashSet::new(), + } + } + + #[test] + fn pass_one_history_changes_later_classification() { + let req = request(); + let channel = CatchUpChannel { + id: "ch".into(), + channel_type: "stream".into(), + name: "Ch".into(), + read_at: Some(9), + }; + let fetched = vec![FetchedChannel { + order: 0, + channel, + events: vec![ + event( + "self-reply", + "self", + 10, + &[&["e", "root", "", "reply"], &["h", "ch"]], + ), + event( + "external-reply", + "other", + 11, + &[&["e", "root", "", "reply"], &["h", "ch"]], + ), + ], + }]; + let result = classify_batch(&req, fetched, &HashMap::new()); + let ChannelResult::Success { + observed_events, + discovered, + .. + } = &result[0] + else { + panic!("expected success") + }; + assert_eq!( + observed_events + .iter() + .map(|event| event.id.as_str()) + .collect::>(), + ["external-reply"] + ); + assert_eq!(discovered.participated, ["root"]); + } + + #[test] + fn same_second_marker_and_mutes_match_renderer_rules() { + let req = request(); + let mut membership = HashMap::new(); + membership.insert("muted_root".into(), HashSet::from(["muted".into()])); + let channel = CatchUpChannel { + id: "ch".into(), + channel_type: "stream".into(), + name: "Ch".into(), + read_at: Some(10), + }; + let fetched = vec![FetchedChannel { + order: 0, + channel, + events: vec![ + event("boundary", "other", 10, &[&["h", "ch"]]), + event( + "muted", + "other", + 11, + &[&["e", "muted", "", "reply"], &["h", "ch"]], + ), + event( + "broadcast", + "other", + 12, + &[&["broadcast", "1"], &["h", "ch"]], + ), + ], + }]; + let result = classify_batch(&req, fetched, &membership); + let ChannelResult::Success { + observed_events, + max_trigger, + .. + } = &result[0] + else { + panic!("expected success") + }; + assert_eq!( + observed_events + .iter() + .map(|event| event.id.as_str()) + .collect::>(), + ["broadcast"] + ); + assert_eq!(*max_trigger, 12); + } + + /// Pins the SERIALIZED wire contract against `tauriUnreadCatchUp.ts`. + /// + /// Asserts on serde's OUTPUT, not on `ChannelResult`: the renderer never + /// sees the Rust type, it sees bytes, through an `invokeTauri` cast + /// that validates nothing. Every other test here inspects the enum before + /// serialization and the e2e bridge hand-writes the intended shape, so + /// without this nothing compares what Rust emits to what TypeScript + /// declares. + /// + /// Whole-value rather than a key list, deliberately: a key-set assertion + /// passes a mutant that drops the variant rename and emits `"Success"`, + /// which the renderer's `status === "error"` branch silently misreads. + /// Failure here means the merge loop throws on the first success row and + /// catch-up yields nothing, silently. + #[test] + fn serialized_response_matches_the_typescript_contract() { + let channels = vec![ + ChannelResult::Success { + channel_id: "ch".into(), + observed_events: vec![ObservedUnreadEvent { + id: "evt".into(), + created_at: 11, + root_id: Some("root".into()), + high_priority: true, + counts_toward_badge: true, + counts_toward_app_badge: false, + }], + max_trigger: 11, + activity_rows: vec![ActivityRow { + id: "evt".into(), + kind: 9, + pubkey: "other".into(), + content: "hi".into(), + created_at: 11, + channel_id: "ch".into(), + channel_name: "Ch".into(), + tags: vec![vec!["h".into(), "ch".into()]], + }], + discovered: DiscoveredRoots { + participated: vec!["root".into()], + authored: Vec::new(), + mentioned: Vec::new(), + }, + }, + ChannelResult::Error { + channel_id: "ch-2".into(), + error: "relay request timed out".into(), + }, + ]; + + let actual = serde_json::to_value(UnreadCatchUpResponse { channels }).unwrap(); + let expected = serde_json::json!({ + "channels": [ + { + "status": "success", + "channelId": "ch", + "observedEvents": [{ + "id": "evt", + "createdAt": 11, + "rootId": "root", + "highPriority": true, + "countsTowardBadge": true, + "countsTowardAppBadge": false, + }], + "maxTrigger": 11, + "activityRows": [{ + "id": "evt", + "kind": 9, + "pubkey": "other", + "content": "hi", + "createdAt": 11, + "channelId": "ch", + "channelName": "Ch", + "tags": [["h", "ch"]], + }], + "discovered": { + "participated": ["root"], + "authored": [], + "mentioned": [], + }, + }, + { + "status": "error", + "channelId": "ch-2", + "error": "relay request timed out", + }, + ] + }); + + assert_eq!(actual, expected); + } +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a65..bfaf2ba200 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useLayoutEffect, + useReducer, useRef, useState, } from "react"; @@ -20,6 +21,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { CommunityThemeController } from "@/shared/theme/CommunityThemeController"; import { useReloadShortcut } from "@/app/useReloadShortcut"; +import { useCloseWindowShortcut } from "@/app/useCloseWindowShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; @@ -61,6 +63,7 @@ import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChang import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; import { @@ -237,6 +240,39 @@ function CommunityQueryProvider({ children }: { children: ReactNode }) { ); } +/** + * Watches the community-scoped identity query and fires once the active + * pubkey changes after mount — i.e. an in-app key import through the + * relay-scoped onboarding flow, which writes the new identity to the + * community query client only. The parent uses the signal to rebuild the + * entire community boundary (query client, AppReady subtree, module + * singletons via useCommunityInit) so a replacement identity never inherits + * the previous identity's cached queries or draft-store bucket. + */ +function CommunityIdentityReplacementSentinel({ + onIdentityReplaced, +}: { + onIdentityReplaced: () => void; +}) { + const identityQuery = useIdentityQuery(); + const pubkey = identityQuery.data?.pubkey ?? null; + const baselinePubkeyRef = useRef(null); + + useEffect(() => { + if (!pubkey) return; + if (baselinePubkeyRef.current === null) { + baselinePubkeyRef.current = pubkey; + return; + } + if (baselinePubkeyRef.current !== pubkey) { + baselinePubkeyRef.current = pubkey; + onIdentityReplaced(); + } + }, [pubkey, onIdentityReplaced]); + + return null; +} + function AppReady({ isSharedIdentity, isCommunitySwitch, @@ -329,9 +365,21 @@ function CommunityApp({ // ahead of the first apply_workspace call. useNestNotifications(); - // Composite key: changes when community ID changes OR when - // the active community's config is updated (relayUrl/token). - const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}`; + // Increments when the community-scoped identity is replaced in-app (key + // import through the relay onboarding flow). Machine-level identity changes + // already reach this component through the currentPubkey prop; this covers + // imports that only the community query client observes. + const [signerEpoch, bumpSignerEpoch] = useReducer( + (epoch: number) => epoch + 1, + 0, + ); + + // Composite key: changes when the community ID changes, when the active + // community's config is updated (relayUrl/token), or when the signing + // identity is replaced. Keying CommunityQueryProvider and AppReady on the + // signer guarantees a replacement identity never sees the previous + // identity's query cache, React state, or draft-store bucket. + const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}-${currentPubkey ?? "anonymous"}-${signerEpoch}`; // Latch once the community key deviates from its cold-boot value: from then // on, loading phases are in-app switches and get the quiet gate instead of @@ -554,6 +602,9 @@ function CommunityApp({ if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( + (null); const [queryClient] = useState(createBuzzQueryClient); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b72..e111f93ca0 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -8,6 +8,10 @@ import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays"; import { AppShellChannelSurface } from "@/app/AppShellChannelSurface"; import { AppHuddleShell } from "@/app/AppHuddleShell"; import { AppTopChrome } from "@/app/AppTopChrome"; +import { + type TerminalContextOverride, + TerminalContextOverrideProvider, +} from "@/app/TerminalContextOverrideContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; @@ -57,7 +61,8 @@ import { useUserStatusSubscription, } from "@/features/user-status/hooks"; import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; -import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; +import { useArchiveSync } from "@/features/local-archive/useArchiveSync"; +import { useArchiveAgentMetricsBridge } from "@/features/local-archive/useArchiveAgentMetricsBridge"; import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed"; import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; import { useProfileQuery } from "@/features/profile/hooks"; @@ -96,6 +101,7 @@ import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; +import { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlayProvider"; import { LazySettingsScreen } from "@/app/LazySettingsScreen"; const EMPTY_CHANNELS: Channel[] = []; export function AppShell() { @@ -205,6 +211,9 @@ export function AppShell() { // useArchiveSync must wait for reconciliation, or listeners could open // before kind 24200 is guaranteed present in the subscription. useArchiveSync(observerReconciled); + // The archive batch now persists in Rust, so the agent-metrics invalidation + // signal arrives as a Tauri event rather than an in-process call. + useArchiveAgentMetricsBridge(); // Kind 44200 is relay-persisted (durable) and stays deferred: missed // startup frames can be replayed, so there's no ordering constraint. const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; @@ -312,6 +321,8 @@ export function AppShell() { selectedView, sidebarChannels, ]); + const [terminalContextOverride, setTerminalContextOverride] = + React.useState(null); const { activeChannel, terminalContext } = useTerminalContext({ channelId: selectedChannelId, channels, @@ -319,6 +330,14 @@ export function AppShell() { pubkey: identityQuery.data?.pubkey, relayUrl: communitiesHook.activeCommunity?.relayUrl, }); + const effectiveTerminalContext = terminalContextOverride + ? { + ...terminalContext, + channelId: terminalContextOverride.channelId, + channelName: terminalContextOverride.channelName, + threadId: null, + } + : terminalContext; const managedChannel = React.useMemo(() => { const targetChannelId = managedChannelId ?? selectedChannelId; return targetChannelId @@ -637,7 +656,7 @@ export function AppShell() { useAppShellLifecycleEffects({ desktopBadgeEnabled: !isHuddleRoom, homeBadgeCountExcludingHighPriority, - unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelNotificationCount, }); // Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route. @@ -746,206 +765,222 @@ export function AppShell() { data-testid="app-sidebar-layer" > - {!settingsOpen && !isHuddleRoom ? ( - - ) : null} - {settingsOpen ? ( -
- - - -
- ) : ( -
- {!isHuddleRoom ? ( - { - const id = communitiesHook.addCommunity({ - ...community, - pubkey: - community.pubkey ?? identityQuery.data?.pubkey, - }); - handleSwitchCommunity(id); - }} - onAddCommunityOpenChange={ - addCommunityDialog.onOpenChange - } - onNewMessage={goNewMessage} - onBackgroundClick={requestFocusedThreadClose} - onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={addCommunityDialog.openDialog} - onSendFeedback={() => setIsSendFeedbackOpen(true)} - onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={handleRemoveCommunity} - onSwitchCommunity={handleSwitchCommunity} - onCreateAgent={() => requestOpenCreateAgent()} - selfPresenceStatus={presenceSession.currentStatus} - communities={communitiesHook.communities} - onCreateChannel={handleCreateChannel} - onCreateForum={handleCreateForum} - onHideDm={handleHideDm} - onHuddleEnded={handleHuddleEnded} - onMarkAllChannelsRead={markAllChannelsRead} - onMarkChannelRead={markChannelRead} - onMarkChannelUnread={markChannelUnread} - onBrowseChannels={handleOpenBrowseChannels} - onOpenDm={async ({ pubkeys }) => { - const directMessage = - await openDmMutation.mutateAsync({ - pubkeys, + + {!settingsOpen && !isHuddleRoom ? ( + + ) : null} + {settingsOpen ? ( +
+ + + +
+ ) : ( +
+ {!isHuddleRoom ? ( + { + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? identityQuery.data?.pubkey, }); - await goChannel(directMessage.id); - }} - onSelectAgents={() => void goAgents()} - onSelectChannel={handleSidebarChannelSelect} - onOpenSearchResult={handleOpenSearchResult} - searchChannels={channels} - searchFocusRequests={[ - searchFocusRequest, - scopeSearchFocusRequest, - ]} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } - onClearUserStatus={() => - setUserStatusMutation.mutate({ - text: "", - emoji: "", - }) - } - profile={profileQuery.data} - selfUserStatus={ - deferredPubkey - ? (selfStatusQuery.data?.[ - deferredPubkey.toLowerCase() - ] ?? undefined) - : undefined - } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - previewActivityChannelIds={unreadThreadChannelIds} - unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} - starredChannelIds={starredChannelIds} - onStarChannel={starChannel} - onUnstarChannel={unstarChannel} - /> - ) : null} - } - > - - - {!isHuddleRoom ? ( - - ) : null} -
- )} - - - { - setIsChannelManagementOpen(open); - if (!open) { - setManagedChannelId(null); + handleSwitchCommunity(id); + }} + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange + } + onNewMessage={goNewMessage} + onBackgroundClick={requestFocusedThreadClose} + onCreateChannelOpenChange={setIsCreateChannelOpen} + onOpenAddCommunity={addCommunityDialog.openDialog} + onSendFeedback={() => setIsSendFeedbackOpen(true)} + onUpdateCommunity={communitiesHook.updateCommunity} + onRemoveCommunity={handleRemoveCommunity} + onSwitchCommunity={handleSwitchCommunity} + onCreateAgent={() => requestOpenCreateAgent()} + selfPresenceStatus={presenceSession.currentStatus} + communities={communitiesHook.communities} + onCreateChannel={handleCreateChannel} + onCreateForum={handleCreateForum} + onHideDm={handleHideDm} + onHuddleEnded={handleHuddleEnded} + onMarkAllChannelsRead={markAllChannelsRead} + onMarkChannelRead={markChannelRead} + onMarkChannelUnread={markChannelUnread} + onBrowseChannels={handleOpenBrowseChannels} + onOpenDm={async ({ pubkeys }) => { + const directMessage = + await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onSelectAgents={() => void goAgents()} + onSelectChannel={handleSidebarChannelSelect} + onOpenSearchResult={handleOpenSearchResult} + searchChannels={channels} + searchFocusRequests={[ + searchFocusRequest, + scopeSearchFocusRequest, + ]} + onSelectHome={() => void goHome()} + onSelectProjects={() => void goProjects()} + onSelectPulse={() => void goPulse()} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => void goWorkflows()} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) + } + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) + } + onClearUserStatus={() => + setUserStatusMutation.mutate({ + text: "", + emoji: "", + }) + } + profile={profileQuery.data} + projectsOverviewActive={ + location.pathname === "/projects" + } + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[ + deferredPubkey.toLowerCase() + ] ?? undefined) + : undefined + } + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + previewActivityChannelIds={unreadThreadChannelIds} + unreadChannelCounts={unreadChannelCounts} + mutedChannelIds={mutedChannelIds} + onMuteChannel={muteChannel} + onUnmuteChannel={unmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={starChannel} + onUnstarChannel={unstarChannel} + /> + ) : null} + + + } + > + + + + {!isHuddleRoom ? ( + + ) : null} +
+ )} + + + { - setIsChannelManagementOpen(false); - setManagedChannelId(null); - void goHome({ replace: true }); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - relayUrl={communitiesHook.activeCommunity?.relayUrl} - /> - + onBrowseChannelJoin={handleBrowseChannelJoin} + onBrowseChannelCreate={handleBrowseChannelCreate} + onBrowseDialogOpenChange={handleBrowseDialogOpenChange} + onChannelManagementOpenChange={(open) => { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); + } + }} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); + setManagedChannelId(null); + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + relayUrl={communitiesHook.activeCommunity?.relayUrl} + /> + +
diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx index 37be3448ee..da6907a902 100644 --- a/desktop/src/app/AppShellChannelSurface.tsx +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -4,10 +4,11 @@ import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle"; import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; -import { SidebarInset } from "@/shared/ui/sidebar"; +import { SidebarInset, useSidebar } from "@/shared/ui/sidebar"; type AppShellChannelSurfaceProps = { children: React.ReactNode; + hasCommunityRail: boolean; isHuddleRoom: boolean; isHuddleRoomStarting: boolean; mainInsetRef: React.RefObject; @@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = { export function AppShellChannelSurface({ children, + hasCommunityRail, isHuddleRoom, isHuddleRoomStarting, mainInsetRef, terminal, }: AppShellChannelSurfaceProps) { + const { isMobile, openMobile, state: sidebarState } = useSidebar(); + const hasCollapsedSidebarGutter = + !isHuddleRoom && + !hasCommunityRail && + (isMobile ? !openMobile : sidebarState === "collapsed"); + return ( + {hasCollapsedSidebarGutter ? ( +
+ ) : null} {isHuddleRoom && !isHuddleRoomStarting ? : null} {isHuddleRoomStarting ? : children} diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 35edc84f78..624cf3b1e4 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -4,6 +4,10 @@ import * as React from "react"; import type { Channel } from "@/shared/api/types"; import type { CreateChannelInput } from "@/features/sidebar/lib/useCreateChannelForm"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; +import { + mergeOpenChannelDirectory, + useOpenChannelDirectoryQuery, +} from "@/features/channels/openChannelDirectory"; const ChannelBrowserDialog = React.lazy(async () => { const module = await import("@/features/channels/ui/ChannelBrowserDialog"); @@ -76,12 +80,24 @@ export function AppShellOverlays({ const renderedBrowseDialogType = visibleBrowseDialogType ?? browseDialogType; + // The channel browser is the only overlay that shows non-member open + // channels, so it — not the 60s poll — pays for the all-open directory scan, + // and only while it is open. Merge the superset over the member list so a + // just-joined or optimistic channel keeps its live state. + const openDirectoryQuery = useOpenChannelDirectoryQuery({ + enabled: browseDialogType !== null, + }); + const browserChannels = React.useMemo( + () => mergeOpenChannelDirectory(channels, openDirectoryQuery.data), + [channels, openDirectoryQuery.data], + ); + return ( <> {browseDialogType !== null ? ( - {sidebar?.open ? : } + Toggle Sidebar ); @@ -82,6 +78,29 @@ export function AppTopChrome({ : "pl-3"; const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null; + React.useLayoutEffect(() => { + const topChrome = topChromeRef.current; + const portalTarget = topChrome?.querySelector( + "#app-top-chrome-content", + ); + if (!topChrome || !portalTarget) return; + + const updateCenterOffset = () => { + const portalBounds = portalTarget.getBoundingClientRect(); + const portalCenter = portalBounds.left + portalBounds.width / 2; + topChrome.style.setProperty( + "--app-top-chrome-center-offset", + `${window.innerWidth / 2 - portalCenter}px`, + ); + }; + + updateCenterOffset(); + const observer = new ResizeObserver(updateCenterOffset); + observer.observe(topChrome); + observer.observe(portalTarget); + return () => observer.disconnect(); + }, []); + React.useEffect(() => { const topChrome = topChromeRef.current; if (!topChrome) { @@ -105,6 +124,11 @@ export function AppTopChrome({ )} data-tauri-drag-region data-testid="app-top-chrome" + style={ + { + "--app-top-chrome-center-offset": "0px", + } as React.CSSProperties + } >
@@ -131,6 +155,11 @@ export function AppTopChrome({
+
); } diff --git a/desktop/src/app/AppTopChromePortal.tsx b/desktop/src/app/AppTopChromePortal.tsx new file mode 100644 index 0000000000..1bda5741f3 --- /dev/null +++ b/desktop/src/app/AppTopChromePortal.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; + +const APP_TOP_CHROME_CONTENT_ID = "app-top-chrome-content"; + +export function AppTopChromePortal({ + children, +}: { + children: React.ReactNode; +}) { + const [target, setTarget] = React.useState(null); + + React.useEffect(() => { + setTarget(document.getElementById(APP_TOP_CHROME_CONTENT_ID)); + }, []); + + return target ? createPortal(children, target) : null; +} diff --git a/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx b/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx new file mode 100644 index 0000000000..8d53d20bad --- /dev/null +++ b/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx @@ -0,0 +1,174 @@ +import * as React from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useLocation } from "@tanstack/react-router"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog"; +import { + WorkflowEditorHost, + type WorkflowEditorTarget, +} from "@/features/workflows/ui/WorkflowEditorHost"; +import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane"; +import { deleteWorkflow, triggerWorkflow } from "@/shared/api/tauriWorkflows"; +import type { Workflow } from "@/shared/api/types"; +import { WorkflowEditorOverlayProvider } from "@/shared/context/WorkflowEditorOverlayContext"; + +const INITIAL_PANE: WorkflowEditorPane = { type: "trigger" }; + +/** Rebuilds a target with a new pane without widening its discriminant. */ +function withPane( + target: WorkflowEditorTarget, + pane: WorkflowEditorPane, +): WorkflowEditorTarget { + return target.mode === "create" + ? { initialChannelId: target.initialChannelId, mode: "create", pane } + : { mode: target.mode, pane, workflowId: target.workflowId }; +} + +/** + * Hosts the shared workflow editor as an overlay owned by the app shell, so + * surfaces like channel settings can open a workflow without navigating away + * from the channel. The Workflows route keeps its own URL-addressable host — + * both render the same editor. + */ +export function AppWorkflowEditorOverlayProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const queryClient = useQueryClient(); + const channelsQuery = useChannelsQuery(); + const memberChannels = React.useMemo( + () => (channelsQuery.data ?? []).filter((channel) => channel.isMember), + [channelsQuery.data], + ); + + const [editor, setEditor] = React.useState(null); + const [workflowHint, setWorkflowHint] = React.useState( + undefined, + ); + const [deleteTarget, setDeleteTarget] = React.useState(null); + + const handleOpenWorkflow = React.useCallback( + (workflowId: string, workflow?: Workflow) => { + setWorkflowHint(workflow); + setEditor({ mode: "detail", pane: INITIAL_PANE, workflowId }); + }, + [], + ); + + const handleOpenNewWorkflow = React.useCallback((channelId?: string) => { + setWorkflowHint(undefined); + setEditor({ + initialChannelId: channelId, + mode: "create", + pane: INITIAL_PANE, + }); + }, []); + + const closeEditor = React.useCallback(() => { + setEditor(null); + setWorkflowHint(undefined); + }, []); + + // This editor belongs to the surface that opened it. If the route leaves that + // surface anyway, drop it rather than trailing the modal onto the next screen. + // The editor's own dirty-exit guard runs first, so unsaved work still prompts. + const { pathname } = useLocation(); + const lastPathnameRef = React.useRef(pathname); + React.useEffect(() => { + if (lastPathnameRef.current === pathname) return; + lastPathnameRef.current = pathname; + closeEditor(); + }, [closeEditor, pathname]); + + const handleEditorPaneChange = React.useCallback( + (pane: WorkflowEditorPane) => { + setEditor((current) => (current ? withPane(current, pane) : current)); + }, + [], + ); + + const handleEditWorkflow = React.useCallback((workflowId: string) => { + setEditor({ mode: "edit", pane: INITIAL_PANE, workflowId }); + }, []); + + const handleDuplicateWorkflow = React.useCallback((workflowId: string) => { + setEditor({ mode: "duplicate", pane: INITIAL_PANE, workflowId }); + }, []); + + const triggerMutation = useMutation({ + mutationFn: (workflowId: string) => triggerWorkflow(workflowId), + onSuccess: () => { + void queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === "workflow-runs", + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (workflowId: string) => deleteWorkflow(workflowId), + onSuccess: () => { + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "workflows" || + query.queryKey[0] === "workflows-all", + }); + }, + }); + + const triggerOne = triggerMutation.mutate; + const handleTriggerWorkflow = React.useCallback( + (workflowId: string) => triggerOne(workflowId), + [triggerOne], + ); + + const deleteOne = deleteMutation.mutateAsync; + const handleConfirmDelete = React.useCallback( + async (workflow: Workflow) => { + try { + await deleteOne(workflow.id); + setDeleteTarget(null); + closeEditor(); + } catch { + // React Query stores the error; keep the confirmation and editor open. + } + }, + [closeEditor, deleteOne], + ); + + return ( + + {children} + + { + if (!open) { + deleteMutation.reset(); + setDeleteTarget(null); + } + }} + open={deleteTarget !== null} + workflow={deleteTarget} + /> + + ); +} diff --git a/desktop/src/app/TerminalContextOverrideContext.tsx b/desktop/src/app/TerminalContextOverrideContext.tsx new file mode 100644 index 0000000000..85a58c37a0 --- /dev/null +++ b/desktop/src/app/TerminalContextOverrideContext.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; + +export type TerminalContextOverride = { + channelId: string; + channelName: string; +}; + +const TerminalContextOverrideContext = React.createContext +> | null>(null); + +export function TerminalContextOverrideProvider({ + children, + onChange, +}: { + children: React.ReactNode; + onChange: React.Dispatch< + React.SetStateAction + >; +}) { + return ( + + {children} + + ); +} + +export function useTerminalContextOverride( + context: TerminalContextOverride | null, +) { + const setOverride = React.useContext(TerminalContextOverrideContext); + + React.useEffect(() => { + if (!setOverride) return; + setOverride(context); + return () => setOverride(null); + }, [context, setOverride]); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a6..53db19d378 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -169,6 +169,66 @@ export function useAppNavigation() { params: { workflowId, }, + search: { pane: "trigger" }, + state: { workflowEditorHasOrigin: true }, + }, + behavior, + ), + [commitNavigation], + ); + + const goNewWorkflow = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows", + search: { pane: "trigger", view: "create" }, + state: { workflowEditorHasOrigin: true }, + }, + behavior, + ), + [commitNavigation], + ); + + const goNewWorkflowForChannel = React.useCallback( + (channelId: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows", + search: { + channel: channelId, + pane: "trigger", + view: "create", + }, + state: { workflowEditorHasOrigin: true }, + }, + behavior, + ), + [commitNavigation], + ); + + const goEditWorkflow = React.useCallback( + (workflowId: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows/$workflowId", + params: { workflowId }, + search: { pane: "trigger", view: "edit" }, + state: { workflowEditorHasOrigin: true }, + }, + behavior, + ), + [commitNavigation], + ); + + const goDuplicateWorkflow = React.useCallback( + (workflowId: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows/$workflowId", + params: { workflowId }, + search: { pane: "trigger", view: "duplicate" }, + state: { workflowEditorHasOrigin: true }, }, behavior, ), @@ -330,9 +390,13 @@ export function useAppNavigation() { closeWorkflowDetail, goAgents, goChannel, + goDuplicateWorkflow, + goEditWorkflow, goForumPost, goHome, goNewMessage, + goNewWorkflow, + goNewWorkflowForChannel, goProject, goProjects, goPulse, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d626179ebb..d4626d2c6f 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { useOpenChannelDirectoryQuery } from "@/features/channels/openChannelDirectory"; import { ChannelScreen } from "@/features/channels/ui/ChannelScreen"; import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingView"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; @@ -110,8 +111,21 @@ export function ChannelRouteScreen({ const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const channels = channelsQuery.data ?? []; - const activeChannel = + const memberChannel = channels.find((channel) => channel.id === channelId) ?? null; + // A deep link to a non-member open channel resolves nothing in the + // member-only poll list. Fall back to the discovery directory — but only for + // that case, so a normal in-membership route never triggers the all-open + // scan. React Query dedups the shared directory key across surfaces. + const needsDirectoryFallback = + !memberChannel && channelsQuery.isSuccess && !isHuddleTranscript; + const openDirectoryQuery = useOpenChannelDirectoryQuery({ + enabled: needsDirectoryFallback, + }); + const activeChannel = + memberChannel ?? + openDirectoryQuery.data?.find((channel) => channel.id === channelId) ?? + null; const [targetMessageEvents, setTargetMessageEvents] = React.useState< RelayEvent[] >(() => { @@ -188,7 +202,11 @@ export function ChannelRouteScreen({ }; }, [selectedPostId, targetMessageId, targetThreadRootId]); - if (channelsQuery.isPending && !activeChannel) { + if ( + !activeChannel && + (channelsQuery.isPending || + (needsDirectoryFallback && openDirectoryQuery.isPending)) + ) { if (isHuddleTranscript) { return ; } diff --git a/desktop/src/app/routes/WorkflowsRouteScreen.tsx b/desktop/src/app/routes/WorkflowsRouteScreen.tsx index 0a0a4dfb36..193695f0cd 100644 --- a/desktop/src/app/routes/WorkflowsRouteScreen.tsx +++ b/desktop/src/app/routes/WorkflowsRouteScreen.tsx @@ -1,15 +1,36 @@ +import * as React from "react"; + import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; -import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen"; +import { + type WorkflowEditorRoute, + WorkflowsScreen, +} from "@/features/workflows/ui/WorkflowsScreen"; +import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane"; type WorkflowsRouteScreenProps = { - selectedWorkflowId: string | null; + editor?: WorkflowEditorRoute | null; + onEditorPaneChange: (pane: WorkflowEditorPane) => void; }; export function WorkflowsRouteScreen({ - selectedWorkflowId, + editor = null, + onEditorPaneChange, }: WorkflowsRouteScreenProps) { - const { closeWorkflowDetail, goWorkflow } = useAppNavigation(); + const { + goDuplicateWorkflow, + goEditWorkflow, + goNewWorkflow, + goWorkflow, + goWorkflows, + } = useAppNavigation(); + const closeEditor = React.useCallback(() => { + if (editor?.hasOrigin) { + window.history.back(); + return; + } + void goWorkflows({ replace: true }); + }, [editor?.hasOrigin, goWorkflows]); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; const memberChannels = channels.filter((channel) => channel.isMember); @@ -17,11 +38,21 @@ export function WorkflowsRouteScreen({ return ( { + editor={editor} + onCloseEditor={closeEditor} + onCreateWorkflow={() => { + void goNewWorkflow(); + }} + onDuplicateWorkflow={(workflowId) => { + void goDuplicateWorkflow(workflowId); + }} + onEditWorkflow={(workflowId) => { + void goEditWorkflow(workflowId); + }} + onViewWorkflow={(workflowId) => { void goWorkflow(workflowId); }} - selectedWorkflowId={selectedWorkflowId} + onEditorPaneChange={onEditorPaneChange} /> ); } diff --git a/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts b/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts new file mode 100644 index 0000000000..8def7e6502 --- /dev/null +++ b/desktop/src/app/routes/lazyWorkflowsRouteScreen.ts @@ -0,0 +1,6 @@ +import * as React from "react"; + +export const LazyWorkflowsRouteScreen = React.lazy(async () => { + const module = await import("./WorkflowsRouteScreen"); + return { default: module.WorkflowsRouteScreen }; +}); diff --git a/desktop/src/app/routes/workflows.$workflowId.tsx b/desktop/src/app/routes/workflows.$workflowId.tsx index f6a74aa15d..71e62c658f 100644 --- a/desktop/src/app/routes/workflows.$workflowId.tsx +++ b/desktop/src/app/routes/workflows.$workflowId.tsx @@ -1,25 +1,62 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { + parseWorkflowEditorPane, + serializeWorkflowEditorPane, +} from "@/features/workflows/ui/workflowEditorPane"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen"; export const Route = createFileRoute("/workflows/$workflowId")({ - component: WorkflowDetailRouteComponent, + component: WorkflowRouteComponent, + validateSearch: (search: Record) => ({ + pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)), + view: + search.view === "edit" || search.view === "duplicate" + ? search.view + : undefined, + }), }); -const WorkflowsRouteScreen = React.lazy(async () => { - const module = await import("./WorkflowsRouteScreen"); - return { default: module.WorkflowsRouteScreen }; -}); - -function WorkflowDetailRouteComponent() { +function WorkflowRouteComponent() { usePreviewFeatureWarning("workflows"); + const navigate = Route.useNavigate(); + const location = useLocation(); const { workflowId } = Route.useParams(); + const { pane, view } = Route.useSearch(); + const hasOrigin = + (location.state as { workflowEditorHasOrigin?: unknown } | undefined) + ?.workflowEditorHasOrigin === true; + const editor: import("@/features/workflows/ui/WorkflowsScreen").WorkflowEditorRoute = + { + hasOrigin, + mode: + view === "duplicate" + ? "duplicate" + : view === "edit" + ? "edit" + : "detail", + pane: parseWorkflowEditorPane(pane), + workflowId, + }; return ( }> - + { + void navigate({ + replace: true, + resetScroll: false, + search: { + pane: serializeWorkflowEditorPane(nextPane), + view, + }, + }); + }} + /> ); } diff --git a/desktop/src/app/routes/workflows.tsx b/desktop/src/app/routes/workflows.tsx index 7ab6461fd0..7b8d5ad0d0 100644 --- a/desktop/src/app/routes/workflows.tsx +++ b/desktop/src/app/routes/workflows.tsx @@ -1,23 +1,57 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { + parseWorkflowEditorPane, + serializeWorkflowEditorPane, +} from "@/features/workflows/ui/workflowEditorPane"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen"; export const Route = createFileRoute("/workflows")({ component: WorkflowsRouteComponent, -}); - -const WorkflowsRouteScreen = React.lazy(async () => { - const module = await import("./WorkflowsRouteScreen"); - return { default: module.WorkflowsRouteScreen }; + validateSearch: (search: Record) => ({ + channel: typeof search.channel === "string" ? search.channel : undefined, + pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)), + view: search.view === "create" ? search.view : undefined, + }), }); function WorkflowsRouteComponent() { usePreviewFeatureWarning("workflows"); + const navigate = Route.useNavigate(); + const location = useLocation(); + const { channel, pane, view } = Route.useSearch(); + const hasOrigin = + (location.state as { workflowEditorHasOrigin?: unknown } | undefined) + ?.workflowEditorHasOrigin === true; + return ( }> - + { + void navigate({ + replace: true, + resetScroll: false, + search: { + channel, + pane: serializeWorkflowEditorPane(nextPane), + view, + }, + }); + }} + /> ); } diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index ae62ae3e22..969bf67ca6 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -8,14 +8,14 @@ import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers"; type AppShellLifecycleEffectsOptions = { desktopBadgeEnabled: boolean; homeBadgeCountExcludingHighPriority: number; - unreadChannelIds: ReadonlySet; + topLevelUnreadChannelIds: ReadonlySet; unreadChannelNotificationCount: number; }; export function useAppShellLifecycleEffects({ desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, - unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelNotificationCount, }: AppShellLifecycleEffectsOptions) { // Event-driven reconnect: network online / focus / visibility short-circuit @@ -82,12 +82,12 @@ export function useAppShellLifecycleEffects({ void setDesktopAppBadge( count ? { kind: "count", count } - : { kind: unreadChannelIds.size ? "dot" : "none" }, + : { kind: topLevelUnreadChannelIds.size ? "dot" : "none" }, ); }, [ desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, - unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelNotificationCount, ]); } diff --git a/desktop/src/app/useCloseWindowShortcut.test.mjs b/desktop/src/app/useCloseWindowShortcut.test.mjs new file mode 100644 index 0000000000..2167af4c38 --- /dev/null +++ b/desktop/src/app/useCloseWindowShortcut.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isCloseWindowShortcut } from "./useCloseWindowShortcut.ts"; + +function chord(overrides = {}) { + return { + altKey: false, + code: "KeyW", + ctrlKey: false, + defaultPrevented: false, + isComposing: false, + metaKey: true, + repeat: false, + shiftKey: false, + ...overrides, + }; +} + +test("Cmd+W closes a window on macOS", () => { + assert.equal(isCloseWindowShortcut(chord(), true), true); +}); + +test("the close-window shortcut rejects other platforms and modified chords", () => { + assert.equal(isCloseWindowShortcut(chord(), false), false); + assert.equal(isCloseWindowShortcut(chord({ metaKey: false }), true), false); + assert.equal(isCloseWindowShortcut(chord({ ctrlKey: true }), true), false); + assert.equal(isCloseWindowShortcut(chord({ altKey: true }), true), false); + assert.equal(isCloseWindowShortcut(chord({ shiftKey: true }), true), false); + assert.equal(isCloseWindowShortcut(chord({ code: "KeyQ" }), true), false); +}); + +test("handled, composing, and repeated events are left alone", () => { + assert.equal( + isCloseWindowShortcut(chord({ defaultPrevented: true }), true), + false, + ); + assert.equal( + isCloseWindowShortcut(chord({ isComposing: true }), true), + false, + ); + assert.equal(isCloseWindowShortcut(chord({ repeat: true }), true), false); +}); diff --git a/desktop/src/app/useCloseWindowShortcut.ts b/desktop/src/app/useCloseWindowShortcut.ts new file mode 100644 index 0000000000..08f62544c3 --- /dev/null +++ b/desktop/src/app/useCloseWindowShortcut.ts @@ -0,0 +1,55 @@ +import * as React from "react"; +import { isTauri } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +import { isMacPlatform } from "@/shared/lib/platform"; + +type CloseWindowChord = Pick< + KeyboardEvent, + | "altKey" + | "code" + | "ctrlKey" + | "defaultPrevented" + | "isComposing" + | "metaKey" + | "repeat" + | "shiftKey" +>; + +export function isCloseWindowShortcut( + event: CloseWindowChord, + isMac: boolean, +): boolean { + return ( + isMac && + event.code === "KeyW" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + !event.defaultPrevented && + !event.isComposing && + !event.repeat + ); +} + +/** + * Restores the standard macOS Cmd+W behavior without reclaiming the native + * menu accelerator. Buzz Term handles the chord first in capture phase while + * it owns input; otherwise this bubble-phase listener closes the current + * window. The main window's Rust close handler turns that into hide-to-tray. + */ +export function useCloseWindowShortcut() { + React.useEffect(() => { + if (!isTauri()) return; + + function handleKeyDown(event: KeyboardEvent) { + if (!isCloseWindowShortcut(event, isMacPlatform())) return; + event.preventDefault(); + void getCurrentWindow().close(); + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); +} diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index cda6c0f2ed..e8b9320794 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,13 +1,13 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; const DEFAULT_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.75; const MAX_ZOOM_FACTOR = 1.5; const ZOOM_STEP = 0.1; -const BASE_FONT_SIZE_PX = 16; const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale"; type ZoomAction = "increase" | "decrease" | "reset"; @@ -76,13 +76,12 @@ function readStoredZoomFactor() { } function applyTextScale(zoomFactor: number) { + applyTextZoomFactor(zoomFactor); if (zoomFactor === DEFAULT_ZOOM_FACTOR) { - document.documentElement.style.fontSize = ""; window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); return; } - document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`; window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor)); } @@ -120,9 +119,21 @@ export function useWebviewZoomShortcuts() { applyTextScale(nextZoomFactor); } + function handleStorage(event: StorageEvent) { + if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) { + return; + } + + const storedZoomFactor = readStoredZoomFactor(); + zoomFactorRef.current = storedZoomFactor; + applyTextZoomFactor(storedZoomFactor); + } + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("storage", handleStorage); return () => { window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("storage", handleStorage); }; }, []); } diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index fa352284dd..7822211541 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -201,18 +201,54 @@ with a TypeScript lookup table or an id comparison in a component. fields, and profile-wide activity selection. Caller context may control the panel shell or return navigation, but must not filter or replace profile content. +14. **Thinking effort has two surfaces: a local-only WRITE control and a + read-only two-facts DISPLAY.** The write control is `EffortPickerField` + (`ui/EffortPickerField.tsx`), a self-contained section component mounted in + `AgentInstanceEditDialog` beside the Model block. It is direct-write, not + part of the frozen `UpdateManagedAgentInput` shape: each selection calls + `persistAgentEffortLevel` and invalidates the config-surface query, mirroring + the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and + option compute live in the pure helper `ui/effortPicker.ts` + (`effortPickerState`): the picker renders only when + `agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId` + has been discovered from the running session (absent pre-first-session and + for runtimes/models without effort support). Local-only is load-bearing, not + cosmetic — the Rust command rejects non-local backends because remote effort + is set at deploy time via `policy_env`. Because it reads its inputs from the + config surface the dialog already fetches (`useAgentConfigSurface`) and owns + its own mutation, it does **not** thread new props through the over-1000-line + dialog (see rule 11): keep effort state inside the section component, never + as dialog-level props. The read-only display is the `thinkingEffort` + normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which + already shows both facts — `field.value` (canonical, the effort the next + spawn will launch with) and, when a running ACP session differs, + `field.overriddenValue` struck through (the live session's current effort). + No component owns "configured vs current" logic; the reader's canonical tier + ordering feeds both facts. Do not add a second effort write path or restate + the two-facts logic in a component. -12. **Owner-only builds discover only verified same-owner remote agents.** - The native `list_relay_agents` boundary authenticates ownership through the - agent's NIP-OA profile, then retains only agents owned by the active user - when the compiled owner-only capability is present. Keep this as the - authoritative backstop: internal builds must never admit cross-owner remote - agents, while same-owner agents on another machine remain inside the - documented owner-only trust boundary. OSS builds retain the complete - policy-filtered relay directory and send-time fail-closed mention - revalidation. Local `agents-data-changed` events refresh only local - persona/team/managed-agent caches; they must never invalidate the remote - relay directory. + **Cut invariant — live mid-conversation effort machinery was deliberately + removed.** Effort is spawn-scoped only: the worker holds one `startup_effort` + read from `BUZZ_ACP_EFFORT_LEVEL` and applies it once at session creation + (`apply_startup_effort` in `buzz-acp/src/pool.rs`); there is no pool-level + effort authority, no live effort switching, and no effort-ack frame. Do not + reintroduce a live effort-switch RPC, a pool effort field, or a + 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.** + 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 + `respond_to` policy admits the viewer and relay membership includes the + target channel. Marked builds require that verified owner coordinate but do + not require it to equal the viewer; OSS builds retain compatibility with + self-authored legacy directory records. Keep native discovery and send-time + revalidation fail closed on invalid ownership or managed policy evidence, + and on missing membership or directory evidence; do not add a cross-owner + clamp to either mention path. Local `agents-data-changed` events + refresh only local persona/team/managed-agent caches; they must never + invalidate the remote relay directory. ## The tests that enforce this @@ -242,6 +278,11 @@ with a TypeScript lookup table or an id comparison in a component. every profile tab when opened from Agents and from the agent's DM. - `ui/AgentConfigPanelPresentation.test.mjs` — shared profile/agent config rows show only effective values, with an em dash for unknown values. +- `ui/effortPicker.test.mjs` — `effortPickerState` gating (local + discovered + `effortConfigId` renders; provider backend or missing configId hides) and + option/preselect compute, plus `effortSelectionToPersistedValue` sentinel → + null. This is where the v4 provider regression is pinned: the write control + must never render for a provider backend. - `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior acceptance coverage for readiness, failure states, defaults, session-draft restoration, zero-write Skip, Next save failure/retry, navigation, and diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 0913582cab..6d8ab4f6ea 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -549,7 +549,24 @@ export function useStartManagedAgentMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (pubkey: string) => startManagedAgent(pubkey), + // Accepts a bare pubkey, or an object carrying the tenant scope a + // long-lived callback captured before its first await (the backend + // fails closed on a mid-flight community/identity switch). + mutationFn: ( + input: + | string + | { + pubkey: string; + expectedRelayUrl?: string; + expectedSignerPubkey?: string; + }, + ) => + typeof input === "string" + ? startManagedAgent(input) + : startManagedAgent(input.pubkey, { + expectedRelayUrl: input.expectedRelayUrl, + expectedSignerPubkey: input.expectedSignerPubkey, + }), onSuccess: (updated) => { queryClient.setQueryData( managedAgentsQueryKey, diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index c2171e9d7d..21880eca2f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -278,7 +278,6 @@ test("isAgentIdentityInAllowedList: keeps people and only explicitly allowed age test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: false, isMember: false, pubkey: PUB_A, @@ -292,7 +291,6 @@ test("shouldHideAgentFromMentions: never hides non-agents", () => { test("shouldHideAgentFromMentions: shows invocable agents even when non-member", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: false, pubkey: PUB_A, @@ -306,7 +304,6 @@ test("shouldHideAgentFromMentions: shows invocable agents even when non-member", test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: false, pubkey: PUB_A, @@ -320,7 +317,6 @@ test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => test("shouldHideAgentFromMentions: hides member agents with an explicit not-invocable directory entry (Fizz)", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: true, pubkey: PUB_A, @@ -334,7 +330,6 @@ test("shouldHideAgentFromMentions: hides member agents with an explicit not-invo test("shouldHideAgentFromMentions: hides member agents without an affirmative directory grant", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: true, pubkey: PUB_A, @@ -348,7 +343,6 @@ test("shouldHideAgentFromMentions: hides member agents without an affirmative di test("shouldHideAgentFromMentions: hides unknown member agents while directories load", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: true, pubkey: PUB_A, @@ -363,7 +357,6 @@ test("shouldHideAgentFromMentions: hides unknown member agents while directories test("shouldHideAgentFromMentions: hides mentionable member agents while directories load", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: true, pubkey: PUB_A, @@ -378,7 +371,6 @@ test("shouldHideAgentFromMentions: hides mentionable member agents while directo test("shouldHideAgentFromMentions: shows non-agent members while directories load", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: false, isMember: true, pubkey: PUB_A, @@ -393,7 +385,6 @@ test("shouldHideAgentFromMentions: shows non-agent members while directories loa test("shouldHideAgentFromMentions: hides unknown member agents after empty directories settle", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: true, pubkey: PUB_A, @@ -405,16 +396,15 @@ test("shouldHideAgentFromMentions: hides unknown member agents after empty direc ); }); -test("shouldHideAgentFromMentions: hides agents while owner policy loads", () => { +test("shouldHideAgentFromMentions: shows authorized agents without managed-owner policy", () => { assert.equal( shouldHideAgentFromMentions({ isAgent: true, pubkey: PUB_A, mentionableAgentPubkeys: new Set([PUB_A]), directoryReady: true, - ownerOnly: undefined, }), - true, + false, ); }); @@ -424,7 +414,6 @@ test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => { assert.equal( shouldHideAgentFromMentions({ - ownerOnly: false, isAgent: true, isMember: true, pubkey: mixedCase, @@ -435,42 +424,31 @@ test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => { ); }); -test("getAgentMentionAdmission: owner-only requires current verified ownership", () => { +test("getAgentMentionAdmission: authorized relay agents are independent of owner", () => { const common = { isAgent: true, - isManagedAgent: false, pubkey: PUB_A, - currentPubkey: CURRENT_PUBKEY, mentionableAgentPubkeys: new Set([PUB_A]), directoryReady: true, - ownerOnly: true, }; + assert.equal(getAgentMentionAdmission(common), "allow"); assert.equal( - getAgentMentionAdmission({ ...common, ownerPubkey: CURRENT_PUBKEY }), - "allow", - ); - assert.equal( - getAgentMentionAdmission({ ...common, ownerPubkey: OTHER_OWNER_PUBKEY }), + getAgentMentionAdmission({ + ...common, + mentionableAgentPubkeys: new Set(), + }), "deny", ); - assert.equal( - getAgentMentionAdmission({ ...common, ownerPubkey: null }), - "unknown", - ); }); test("getAgentMentionAdmission: unresolved directory state stays unknown", () => { assert.equal( getAgentMentionAdmission({ isAgent: true, - isManagedAgent: false, pubkey: PUB_A, - currentPubkey: CURRENT_PUBKEY, - ownerPubkey: CURRENT_PUBKEY, mentionableAgentPubkeys: new Set([PUB_A]), directoryReady: false, - ownerOnly: false, }), "unknown", ); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 516520e2ca..4e1c787f92 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -110,65 +110,40 @@ export type AgentMentionAdmission = "allow" | "deny" | "unknown"; export function getAgentMentionAdmission({ isAgent, - isManagedAgent, pubkey, - ownerPubkey, - currentPubkey, mentionableAgentPubkeys, directoryReady, - ownerOnly, }: { isAgent: boolean; - isManagedAgent: boolean; pubkey: string; - ownerPubkey?: string | null; - currentPubkey?: string | null; mentionableAgentPubkeys: ReadonlySet; directoryReady: boolean; - ownerOnly: boolean | undefined; }): AgentMentionAdmission { if (!isAgent) return "allow"; - if (!directoryReady || ownerOnly === undefined) return "unknown"; + if (!directoryReady) return "unknown"; - const normalized = normalizePubkey(pubkey); - if (!mentionableAgentPubkeys.has(normalized)) return "deny"; - if (!ownerOnly || isManagedAgent) return "allow"; - if (!ownerPubkey || !currentPubkey) return "unknown"; - - return normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey) + return mentionableAgentPubkeys.has(normalizePubkey(pubkey)) ? "allow" : "deny"; } export function shouldHideAgentFromMentions({ isAgent, - isManagedAgent = false, pubkey, - ownerPubkey, - currentPubkey, mentionableAgentPubkeys, directoryReady = true, - ownerOnly, }: { isAgent: boolean; - isManagedAgent?: boolean; pubkey: string; - ownerPubkey?: string | null; - currentPubkey?: string | null; mentionableAgentPubkeys: ReadonlySet; directoryReady?: boolean; - ownerOnly: boolean | undefined; }) { return ( getAgentMentionAdmission({ isAgent, - isManagedAgent, pubkey, - ownerPubkey, - currentPubkey, mentionableAgentPubkeys, directoryReady, - ownerOnly, }) !== "allow" ); } diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs index 11d5f74d8f..4a1151c9ce 100644 --- a/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs +++ b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs @@ -60,12 +60,59 @@ test("resolveAgentCardModelLabel — non-inherited agent with a blank resolved m // Databricks registry integration import { formatAgentModelLabel } from "./formatAgentModelLabel.ts"; +test("formatAgentModelLabel — Databricks aliases reuse canonical labels", () => { + assert.equal( + formatAgentModelLabel("goose-gpt-5-6-sol", "databricks_v2"), + "GPT-5.6 Sol", + ); + assert.equal( + formatAgentModelLabel("goose-claude-fable-5", "databricks_v2"), + "Claude Fable 5", + ); + assert.equal( + formatAgentModelLabel("goose-claude-opus-4-8", "databricks_v2"), + "Claude Opus 4.8", + ); + assert.equal( + formatAgentModelLabel("goose-claude-opus-5", "databricks_v2"), + "Claude Opus 5", + ); + assert.equal( + formatAgentModelLabel("goose-claude-sonnet-5", "databricks_v2"), + "Claude Sonnet 5", + ); + assert.equal( + formatAgentModelLabel("goose-kimi-k3", "databricks_v2"), + "Kimi K3", + ); +}); + +test("resolveModelLabel — Databricks alias labels stay provider-scoped", () => { + assert.equal( + resolveModelLabel("goose-gpt-5-6-sol", null, "openai"), + "goose-gpt-5-6-sol", + ); +}); + +test("formatAgentModelLabel — bare family IDs remain raw", () => { + assert.equal(formatAgentModelLabel("gpt-5"), "gpt-5"); +}); + test("formatAgentModelLabel — known Databricks managed ID returns curated name", () => { assert.equal(formatAgentModelLabel("databricks-gpt-5-5"), "GPT-5.5"); assert.equal( formatAgentModelLabel("databricks-claude-opus-4-7"), "Claude Opus 4.7", ); + assert.equal( + formatAgentModelLabel("databricks-claude-opus-5"), + "Claude Opus 5", + ); + assert.equal( + formatAgentModelLabel("databricks-claude-sonnet-5"), + "Claude Sonnet 5", + ); + assert.equal(formatAgentModelLabel("databricks-kimi-k3"), "Kimi K3"); }); test("formatAgentModelLabel — unknown custom Databricks ID returns raw ID unchanged", () => { diff --git a/desktop/src/features/agents/lib/formatAgentModelLabel.ts b/desktop/src/features/agents/lib/formatAgentModelLabel.ts index 7bce26a9b4..5bc3a0f299 100644 --- a/desktop/src/features/agents/lib/formatAgentModelLabel.ts +++ b/desktop/src/features/agents/lib/formatAgentModelLabel.ts @@ -1,6 +1,6 @@ import { canonicalizeProvider, - DATABRICKS_MODEL_NAMES, + databricksRegistryLabel, resolveModelCapabilities, } from "../ui/modelCapabilities"; @@ -19,20 +19,22 @@ export { canonicalizeProvider }; * discovery contract (`{id, name: id}`) and any harness/version skew that * echoes the id as the name. * 2. Registry lookup by id: - * - `provider` supplied → provider-qualified exact record only. On a miss - * the raw id is returned; the unscoped `DATABRICKS_MODEL_NAMES` map is - * NOT consulted, so a Databricks endpoint id never leaks a curated label + * - `provider` supplied → Databricks v2 uses alias-aware exact records; + * every other provider uses provider-qualified exact records. On a miss + * the raw id is returned; the providerless registry tier is NOT + * consulted, so a Databricks endpoint id never leaks a curated label * through an anthropic/openai provider context (the P3-B contract). - * - `provider` absent → unscoped `DATABRICKS_MODEL_NAMES` map, for - * legacy/inherited ids with no provider on hand. + * - `provider` absent → alias-aware lookup over `databricks_v2` exact + * records, for legacy/inherited ids with no provider on hand. * 3. Raw id unchanged. * * Returns the empty string when both id and discoveredName are blank; use * `formatAgentModelLabel` when a null/empty id should render "Auto". * - * `resolveModelCapabilities` canonicalizes the provider internally, so callers - * pass the raw provider id. Only exact records carry a `registryLabel`, so a - * family/prefix hit yields `null` and correctly falls back to the raw id. + * `resolveModelCapabilities` canonicalizes the provider internally. The + * providerless registry lookup applies the same family-token stripping and + * unique-match guard as buzz-agent discovery; only unique exact-record aliases + * get a label. */ export function resolveModelLabel( id: string, @@ -46,15 +48,16 @@ export function resolveModelLabel( if (trimmedName && trimmedName !== trimmedId) return trimmedName; if (!trimmedId) return ""; if (provider?.trim()) { - // Provider-qualified exact-record tier (provider-scoped, no unscoped fallback). - const registryLabel = resolveModelCapabilities( - provider, - trimmedId, - ).registryLabel; + // Provider-qualified exact-record tier (provider-scoped, no providerless fallback). + const canonicalProvider = canonicalizeProvider(provider); + const registryLabel = + canonicalProvider === "databricks_v2" + ? databricksRegistryLabel(trimmedId) + : resolveModelCapabilities(provider, trimmedId).registryLabel; return registryLabel ?? trimmedId; } - // Providerless path: unscoped registry map for legacy/inherited ids. - return DATABRICKS_MODEL_NAMES.get(trimmedId) ?? trimmedId; + // Providerless path: alias-aware lookup for legacy/inherited ids. + return databricksRegistryLabel(trimmedId) ?? trimmedId; } /** diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs index 737d84b862..4a79d32837 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -3,10 +3,18 @@ import test from "node:test"; import { awaitLiveSwitchOutcome } from "./liveSwitchOutcome.ts"; -const MODEL = "goose-claude-fable-5"; +const REQUEST_ID = "req-abc"; +const CH_A = "channel-a"; +const CH_B = "channel-b"; function frame(status, overrides = {}) { - return { type: "switch_model", status, modelId: MODEL, ...overrides }; + return { + type: "switch_model", + status, + requestId: REQUEST_ID, + channelId: CH_A, + ...overrides, + }; } /** @@ -15,7 +23,7 @@ function frame(status, overrides = {}) { * no-ops, matching `observerRelayStore`), a manual timeout, and a deferred * `sendSwitches` the test resolves explicitly. */ -function harness(channelCount) { +function harness(channelIds, requestId = REQUEST_ID) { let listener = null; let timeoutCb = null; let unsubscribeCalls = 0; @@ -26,8 +34,8 @@ function harness(channelCount) { }); const outcome = awaitLiveSwitchOutcome({ - channelCount, - modelId: MODEL, + requestId, + channelIds, subscribe: (fn) => { listener = fn; return () => { @@ -61,47 +69,85 @@ function harness(channelCount) { }; } +const drainMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + test("awaitLiveSwitchOutcome fast sent on one channel does not mask a later unsupported on another", async () => { - const h = harness(2); + const h = harness([CH_A, CH_B]); // Channel A acks fast as `sent`; a first-ack-resolves impl would settle "ok" // here. The fail-fast contract must keep waiting and then reject on B. h.push(frame("sent")); - h.push(frame("unsupported_model")); + h.push(frame("unsupported_model", { channelId: CH_B })); assert.equal(await h.outcome, "unsupported"); }); -test("awaitLiveSwitchOutcome resolves ok only after the last channel acks", async () => { - const h = harness(3); +test("awaitLiveSwitchOutcome resolves ok only after every distinct channel acks", async () => { + const h = harness([CH_A, CH_B]); let settled = false; void h.outcome.then(() => { settled = true; }); - // The `.then` that flips `settled` flushes on a later microtask tick than a - // single drain, so a single `await Promise.resolve()` would let this - // assertion pass even against a first-ack-resolves bug. Draining several - // ticks guarantees a resolved promise's callback has run, so the interim - // `settled === false` checks deterministically regress an early resolve. - const drainMicrotasks = async () => { - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } - }; - - h.push(frame("sent")); + // Terminal success for channel A alone must not settle a two-channel pick. + h.push(frame("switched", { channelId: CH_A })); await drainMicrotasks(); - assert.equal(settled, false, "must not resolve on the first ack"); + assert.equal(settled, false, "must not resolve before every channel acks"); + + h.push(frame("switched", { channelId: CH_B })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome settles not_delivered immediately on a turn_ending frame without waiting for other channels or the timeout", async () => { + // `turn_ending` means the control oneshot was already consumed (a prior + // cancel is ending the turn) — the switch can't land and nothing applies + // later. It must fail-fast to "not_delivered", NOT count as a positive + // terminal. Three channels prove it never traverses the success-count path. + const h = harness([CH_A, CH_B, "channel-c"]); + h.push(frame("turn_ending")); + assert.equal(await h.outcome, "not_delivered"); + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); + + // A later positive frame must not re-resolve or re-unsubscribe. + h.push(frame("switched")); + assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); +}); + +test("awaitLiveSwitchOutcome settles not_delivered immediately on a no_active_turn frame", async () => { + // `no_active_turn` means neither an in-flight task nor an idle session-owning + // agent existed by the time the harness received the switch (a stale + // `activeTurns` snapshot). Nothing was applied and nothing rides a later + // session — fail-fast to "not_delivered", never a false "ok". + const h = harness([CH_A, CH_B]); + h.push(frame("no_active_turn")); + assert.equal(await h.outcome, "not_delivered"); + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); +}); + +test("awaitLiveSwitchOutcome ignores an unknown future status and settles via a real switched terminal", async () => { + // A status the picker doesn't know (a newer harness) must be inert — never + // default-counted as success. The pick stays open until a real `switched` + // terminal (or the timeout) settles it. + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("some_future_status")); + await drainMicrotasks(); + assert.equal(settled, false, "an unknown status must not settle the pick"); h.push(frame("switched")); - await drainMicrotasks(); - assert.equal(settled, false, "must not resolve before the last ack"); - - h.push(frame("turn_ending")); assert.equal(await h.outcome, "ok"); }); test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes exactly once", async () => { - const h = harness(2); + const h = harness([CH_A, CH_B]); h.push(frame("unsupported_model")); assert.equal(await h.outcome, "unsupported"); assert.equal(h.unsubscribeCalls, 1); @@ -113,42 +159,309 @@ test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); }); -test("awaitLiveSwitchOutcome ignores frames for a different model or control type", async () => { - const h = harness(1); - h.push(frame("sent", { modelId: "some-other-model" })); - h.push({ type: "cancel_turn", status: "sent", modelId: MODEL }); +test("awaitLiveSwitchOutcome settles failed immediately on an adapter-refused frame without waiting for other channels or the timeout", async () => { + const h = harness([CH_A, CH_B, "channel-c"]); + // Three channels, so a success-path impl would need three acks. A single + // `failure` frame must fail-fast to "failed" (not "unsupported", not "ok") + // before the other two channels reply — proving it never traverses the + // success-count path. + h.push(frame("failure")); + assert.equal(await h.outcome, "failed"); + // The timeout was cancelled (no 8s wait) and the listener detached exactly + // once — the frame settled synchronously, not via the fallback. + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); + + // A later frame must not re-resolve or re-unsubscribe. + h.push(frame("switched")); + assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); +}); + +test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then settles failed when the adapter rejection arrives", async () => { + // The real busy-path producer order: the harness acks `sent` immediately + // (the switch was delivered to the in-flight turn), then — after the requeued + // session consults the adapter — emits `failure` seconds later. With one + // active channel a first-ack-resolves impl would settle "ok" on `sent` and + // detach before `failure` arrives; this regresses that. + const h = harness([CH_A]); let settled = false; void h.outcome.then(() => { settled = true; }); - await Promise.resolve(); + + h.push(frame("sent")); + await drainMicrotasks(); + assert.equal(settled, false, "provisional `sent` must not resolve the pick"); + assert.equal(h.unsubscribeCalls, 0, "subscription stays alive after `sent`"); + assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`"); + + h.push(frame("failure")); + assert.equal(await h.outcome, "failed"); + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); +}); + +test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then resolves pending via the timeout when no positive terminal arrives", async () => { + // Busy-path success now emits a positive `switched` terminal when the + // requeued session applies the model — but a busy turn can outlast the + // fallback timeout. If no terminal arrives in time, the pick resolves + // `"pending"` (accepted, apply deferred), NEVER a false `"ok"`. + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("sent")); + await drainMicrotasks(); + assert.equal(settled, false, "provisional `sent` must not resolve the pick"); + assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`"); + + h.fireTimeout(); + assert.equal(await h.outcome, "pending"); + assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes"); +}); + +test("awaitLiveSwitchOutcome resolves ok when the busy-path deferred apply emits a positive switched terminal", async () => { + // The K1 mirror case: after the provisional `sent`, the requeued session + // applies the model and the harness emits a real `switched` terminal before + // the timeout. That positive frame — not timeout silence — resolves `"ok"`. + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("sent")); + await drainMicrotasks(); + assert.equal(settled, false, "provisional `sent` must not resolve the pick"); + + h.push(frame("switched")); + assert.equal(await h.outcome, "ok"); + assert.equal( + h.cancelTimeoutCalls, + 1, + "positive terminal cancels the timeout", + ); + assert.equal(h.unsubscribeCalls, 1); +}); + +test("awaitLiveSwitchOutcome never resolves ok when a delayed rejection lands after the timeout already resolved pending", async () => { + // The K1 named pin: a busy switch whose turn outlasts the timeout. The pick + // resolves `"pending"` at the timeout; the deferred apply then rejects. The + // late `failure` frame must not re-resolve, and the outcome is never `"ok"`. + const h = harness([CH_A]); + + h.push(frame("sent")); + h.fireTimeout(); + assert.equal(await h.outcome, "pending"); + assert.equal(h.unsubscribeCalls, 1, "timeout fallback detaches the listener"); + + // Deferred apply rejects after the fact: inert, the listener is gone. + h.push(frame("failure")); + assert.equal( + h.unsubscribeCalls, + 1, + "no re-resolve or re-unsubscribe on a late frame", + ); +}); + +test("awaitLiveSwitchOutcome ignores frames for a different request id or control type", async () => { + const h = harness([CH_A]); + // A replayed terminal frame from an EARLIER pick carries a different + // requestId; it must not advance this pick's count. + h.push(frame("switched", { requestId: "req-stale" })); + h.push({ + type: "cancel_turn", + status: "sent", + requestId: REQUEST_ID, + channelId: CH_A, + }); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + await drainMicrotasks(); assert.equal(settled, false, "unrelated frames must not advance the count"); h.push(frame("switched")); assert.equal(await h.outcome, "ok"); }); -test("awaitLiveSwitchOutcome resolves ok via the timeout fallback when the harness never replies", async () => { - const h = harness(2); +test("awaitLiveSwitchOutcome resolves pending via the timeout fallback when the harness never replies", async () => { + const h = harness([CH_A, CH_B]); h.fireTimeout(); - assert.equal(await h.outcome, "ok"); + assert.equal(await h.outcome, "pending"); assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes"); }); test("awaitLiveSwitchOutcome fires the per-channel sends after subscribing", async () => { - const h = harness(1); + const h = harness([CH_A]); // The subscription is registered before the sends fire, so a frame arriving - // mid-send is never dropped. Awaiting sendStarted proves sends ran. + // mid-send is never dropped. Awaiting sendStarted proves sends ran. Uses a + // terminal-success frame (`switched`) since the provisional `sent` no longer + // settles the pick on its own. await h.sendStarted; - h.push(frame("sent")); + h.push(frame("switched")); assert.equal(await h.outcome, "ok"); }); -test("awaitLiveSwitchOutcome with zero channels resolves ok at the timeout (no acks expected)", async () => { - // No active turns means channelCount 0: remaining starts at 0 but the success - // resolve only fires inside a frame callback, so with no frames the timeout - // fallback is what settles it. This documents the degenerate path. - const h = harness(0); +test("awaitLiveSwitchOutcome with zero channels resolves pending at the timeout (no acks expected)", async () => { + // No active turns means an empty channel set: the success resolve only fires + // inside a frame callback keyed on an expected channel, so with no frames the + // timeout fallback is what settles it — to `"pending"`, since no positive + // terminal confirmed. This documents the degenerate path. + const h = harness([]); h.fireTimeout(); + assert.equal(await h.outcome, "pending"); +}); + +test("awaitLiveSwitchOutcome ignores a reconnect replay of an identical terminal frame", async () => { + // The observer relay requests a five-minute replay on reconnect, so the SAME + // terminal frame for one channel can arrive twice. A scalar count would treat + // the replay as a second channel's ack and settle a two-channel pick early. + const h = harness([CH_A, CH_B]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("switched", { channelId: CH_A })); + h.push(frame("switched", { channelId: CH_A })); // replay of the same frame + await drainMicrotasks(); + assert.equal( + settled, + false, + "a duplicated channel-A success must not stand in for channel B", + ); + + h.push(frame("switched", { channelId: CH_B })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome does not let one channel's duplicated success mask another channel's later failure", async () => { + // Two channels: A succeeds and its frame is replayed; B rejects late. A + // per-frame count would resolve "ok" on A's duplicate before B's failure and + // report a false success. Counting per distinct channel keeps the pick open + // for B, which fail-fasts to "failed". + const h = harness([CH_A, CH_B]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("switched", { channelId: CH_A })); + h.push(frame("switched", { channelId: CH_A })); // duplicate for A + await drainMicrotasks(); + assert.equal(settled, false, "A's duplicate must not complete the pick"); + + h.push(frame("failure", { channelId: CH_B })); + assert.equal(await h.outcome, "failed"); +}); + +test("awaitLiveSwitchOutcome ignores a stale result from an overlapping same-model operation", async () => { + // Two picks for the same model overlap: this operation is `req-new`; a prior + // `req-old` pick's terminal frame (same model, same channel) is still in + // flight. Correlating on requestId — not modelId — keeps the old result from + // settling the new pick. + const h = harness([CH_A], "req-new"); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push({ + type: "switch_model", + status: "switched", + requestId: "req-old", + channelId: CH_A, + }); + await drainMicrotasks(); + assert.equal(settled, false, "a prior same-model pick's result is inert"); + + h.push({ + type: "switch_model", + status: "switched", + requestId: "req-new", + channelId: CH_A, + }); + assert.equal(await h.outcome, "ok"); +}); + +// The channel guard must run BEFORE status handling so a negative frame is +// correlated by channel too — a misrouted or channel-less `failure`/ +// `unsupported_model` carrying this pick's requestId must not fail it. This is +// the false-failure mirror of the false-success class the positive-terminal +// channel count already guards. +test("awaitLiveSwitchOutcome ignores a failure frame from a foreign channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + // Same requestId, but a channel this pick never fired to: inert. + h.push(frame("failure", { channelId: "channel-foreign" })); + await drainMicrotasks(); + assert.equal( + settled, + false, + "a foreign-channel failure must not fail the pick", + ); + + h.push(frame("switched", { channelId: CH_A })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome ignores an unsupported_model frame from a foreign channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("unsupported_model", { channelId: "channel-foreign" })); + await drainMicrotasks(); + assert.equal( + settled, + false, + "a foreign-channel unsupported_model must not fail the pick", + ); + + h.push(frame("switched", { channelId: CH_A })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome ignores a failure frame that carries no channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("failure", { channelId: undefined })); + await drainMicrotasks(); + assert.equal(settled, false, "a channel-less failure must not fail the pick"); + + h.push(frame("switched", { channelId: CH_A })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome ignores an unsupported_model frame that carries no channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("unsupported_model", { channelId: undefined })); + await drainMicrotasks(); + assert.equal( + settled, + false, + "a channel-less unsupported_model must not fail the pick", + ); + + h.push(frame("switched", { channelId: CH_A })); assert.equal(await h.outcome, "ok"); }); diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts index d12261e596..83792dcdab 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.ts +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts @@ -4,57 +4,146 @@ import type { ControlResultFrame } from "@/shared/api/types"; * Resolve the outcome of a live `switch_model` across one or more channels. * * A live switch fires a `switch_model` frame per active channel and learns each - * channel's result asynchronously over the observer relay. The fail-fast rule: - * any single `unsupported_model` result rejects the whole pick immediately; - * every other status must arrive from every channel before resolving success. - * If the harness never replies, the fallback timeout resolves `"ok"` — the - * override still rides the requeued/next session, we just can't confirm it - * synchronously. + * channel's result asynchronously over the observer relay. Two statuses + * fail-fast — any single frame rejects the whole pick immediately, without + * waiting for the other channels or the timeout: + * - `unsupported_model` → the target model isn't available for this agent. + * - `failure` → the adapter refused the switch (the session stays + * on its current model). + * Their causes differ, so they resolve to distinct outcomes (`"unsupported"` + * vs `"failed"`) the caller can message separately. + * + * `sent` is the busy-path PROVISIONAL ack: the switch was delivered to the + * in-flight turn, but the adapter isn't consulted until the requeued session + * runs. The real verdict lands later as a positive `switched` terminal (the + * deferred apply succeeded), a `failure`/`unsupported_model` frame (it didn't), + * so `sent` never settles the pick on its own — the subscription stays alive + * for the terminal frame. + * + * Success is only ever inferred from `switched` — the one status that means + * the model was APPLIED — which must arrive from every EXPECTED channel before + * resolving `"ok"`. The idle path emits it immediately; the busy path emits it + * when the requeued session applies the model. A busy turn routinely outlasts + * the fallback timeout, so the timeout NEVER resolves `"ok"` — it resolves + * `"pending"`: the switch was accepted and rides the requeued/next session, but + * we could not confirm the apply synchronously. The caller surfaces that + * truthfully rather than claiming a success that has not happened (and might + * yet be rejected). + * + * Two more statuses are non-delivery terminals — the harness never set the + * desired model and nothing applies later, so they can no more resolve `"ok"` + * than a `failure` can: + * - `turn_ending` → the control oneshot was already consumed (a prior + * cancel is ending the turn), so the switch can't land. + * - `no_active_turn` → neither an in-flight task nor an idle session-owning + * agent existed (a stale `activeTurns` snapshot between + * the picker read and the harness receipt). + * Both fail-fast to `"not_delivered"`, distinct from `"pending"` (which DID + * ride the requeued session): here the switch never landed at all. + * + * Any other status — a `sent` provisional ack, or an unknown future status — is + * inert: it is never counted as success. A new producer status that should + * settle the pick must add its own explicit branch. + * + * Two identity guards keep a stale or replayed frame from settling the wrong + * pick. The observer relay requests a five-minute replay on reconnect, so an + * old `control_result` for an earlier switch can re-arrive mid-pick: + * - `requestId` — an opaque per-pick correlator the harness echoes on every + * frame. Frames without a matching id are ignored, so a replayed result for + * a prior operation (which carried a different id, or none) is inert. + * - `channelId` — every frame, positive OR negative, must name a channel in + * the EXPECTED set before it can settle anything. A misrouted `failure` + * from a foreign channel (or a frame with no channel) can no more fail the + * pick than a foreign `switched` can satisfy it. Positive terminals are + * then counted once per DISTINCT expected channel, not once per frame, so + * two copies of one channel's `switched` can't satisfy a two-channel pick. * * The counting lives here, isolated from React and the relay so it can be unit * tested with synthetic frames and a fake clock. The caller injects the * relay subscription, the per-channel sends, and the timeout scheduler. */ export async function awaitLiveSwitchOutcome({ - channelCount, - modelId, + requestId, + channelIds, subscribe, sendSwitches, scheduleTimeout, }: { - /** Number of channels the switch was fired to — the success threshold. */ - channelCount: number; - /** Model being switched to; frames for any other model are ignored. */ - modelId: string; + /** Opaque per-pick id; frames without this exact id are ignored. */ + requestId: string; + /** Channels the switch was fired to — the distinct set to await. */ + channelIds: readonly string[]; /** Register a control-result listener; returns an unsubscribe function. */ subscribe: (listener: (frame: ControlResultFrame) => void) => () => void; /** Fire the per-channel `switch_model` sends. Resolves when all are sent. */ sendSwitches: () => Promise; /** Schedule the no-reply fallback; returns a cancel function. */ scheduleTimeout: (onTimeout: () => void) => () => void; -}): Promise<"ok" | "unsupported"> { - const settled = new Promise<"ok" | "unsupported">((resolve) => { +}): Promise<"ok" | "unsupported" | "failed" | "not_delivered" | "pending"> { + const expected = new Set(channelIds); + const settled = new Promise< + "ok" | "unsupported" | "failed" | "not_delivered" | "pending" + >((resolve) => { let unsubscribe = () => {}; let cancelTimeout = () => {}; - let remaining = channelCount; - const finish = (outcome: "ok" | "unsupported") => { + const succeeded = new Set(); + const finish = ( + outcome: "ok" | "unsupported" | "failed" | "not_delivered" | "pending", + ) => { cancelTimeout(); unsubscribe(); resolve(outcome); }; - cancelTimeout = scheduleTimeout(() => finish("ok")); + // No positive terminal in time: the switch was accepted but its deferred + // apply hasn't confirmed. Resolve indeterminate — never a false "ok". + cancelTimeout = scheduleTimeout(() => finish("pending")); unsubscribe = subscribe((frame) => { - if (frame.type !== "switch_model" || frame.modelId !== modelId) { + // Two identity guards run BEFORE any status handling, so they scope + // every decision — positive AND negative — to THIS pick's channels: + // - requestId: a replayed result for a prior operation carries a + // different id (or none) and is ignored. + // - channelId: the frame must name an EXPECTED channel. A negative + // frame (`failure`/`unsupported_model`) misrouted from a foreign + // channel, or carrying no channel at all, must not fail this pick + // any more than a foreign positive frame may satisfy it. + if (frame.type !== "switch_model" || frame.requestId !== requestId) { + return; + } + if (!frame.channelId || !expected.has(frame.channelId)) { return; } if (frame.status === "unsupported_model") { - // Any single failure rejects the whole pick immediately. + // Model unavailable — reject the whole pick immediately. finish("unsupported"); return; } - // sent / switched / turn_ending — count as success for this channel. - remaining -= 1; - if (remaining <= 0) { + if (frame.status === "failure") { + // Adapter refused the switch — reject immediately. The session stays + // on its current model; distinct outcome so the caller can say why. + finish("failed"); + return; + } + if (frame.status === "turn_ending" || frame.status === "no_active_turn") { + // Non-delivery terminal: the harness never set the desired model and + // nothing applies later (`turn_ending` = the control oneshot was + // already consumed; `no_active_turn` = no in-flight task and no idle + // session-owning agent). Fail-fast, distinct from `"pending"` — here + // the switch never landed at all. + finish("not_delivered"); + return; + } + if (frame.status !== "switched") { + // Anything else — the provisional `sent` ack, or an unknown future + // status — is inert. Only `switched` (the model was APPLIED) counts. + // A busy `sent` is settled later by its own `switched`/`failure` + // terminal, or by the timeout resolving `"pending"`. + return; + } + // `switched` — the model was applied for this channel. Count each + // expected channel once: a duplicate frame for a channel already + // recorded (a replay, or a two-copy fan-out) is a no-op. + succeeded.add(frame.channelId); + if (succeeded.size >= expected.size) { finish("ok"); } }); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.invoke.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.invoke.test.mjs new file mode 100644 index 0000000000..5ce7bda4c8 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.invoke.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { fetchPersonaCatalogPublications } from "./personaCatalogRelay.ts"; + +function installTauriInvoke(handler) { + globalThis.window ??= {}; + window.__TAURI_INTERNALS__ = { invoke: handler }; +} + +test("persona catalog wrapper invokes the one native active-scope command", async (t) => { + const prior = globalThis.window; + t.after(() => { + mock.restoreAll(); + globalThis.window = prior; + }); + const expected = [{ eventId: "event-1", ownerPubkey: "alice" }]; + const calls = []; + installTauriInvoke((command, args) => { + calls.push([command, args]); + return Promise.resolve(expected); + }); + + assert.deepEqual(await fetchPersonaCatalogPublications(), expected); + assert.deepEqual(calls, [["fetch_persona_catalog", {}]]); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 5eda8a195f..49e7b45ebc 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -1,450 +1,32 @@ import assert from "node:assert/strict"; -import test, { mock } from "node:test"; -import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; +import test from "node:test"; -import { relayClient } from "@/shared/api/relayClient"; -import { emojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils.ts"; -import { - catalogPersonasFromPublications, - catalogPublicationsFromEvents, - fetchPersonaCatalogPublications, - personaEventIsShared, -} from "./personaCatalogRelay.ts"; +import { catalogPersonasFromPublications } from "./personaCatalogRelay.ts"; -const ALICE_SECRET = new Uint8Array(32); -ALICE_SECRET[31] = 1; -const BOB_SECRET = new Uint8Array(32); -BOB_SECRET[31] = 2; -const ALICE = getPublicKey(ALICE_SECRET); -const BOB = getPublicKey(BOB_SECRET); +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); -function secretForOwner(owner) { - if (owner === ALICE) return ALICE_SECRET; - if (owner === BOB) return BOB_SECRET; - throw new Error(`No test secret for catalog owner ${owner}`); -} - -function personaEvent({ - createdAt, - id, - owner = ALICE, - sourcePersonaId = "reviewer", - shared = true, - avatarUrl = null, - displayName = "Relay Reviewer", - respondTo = null, - systemPrompt = "Review changes.", - sharedTag, - contentOverride, -}) { - return finalizeEvent( - { - created_at: createdAt, - kind: 30175, - tags: [ - ["d", sourcePersonaId], - ["test-id", id], - ...(shared - ? [sharedTag ?? ["shared", "true"]] - : sharedTag - ? [sharedTag] - : []), - ], - content: - contentOverride ?? - JSON.stringify({ - display_name: displayName, - system_prompt: systemPrompt, - avatar_url: avatarUrl, - runtime: "goose", - model: "claude", - provider: null, - name_pool: ["Reviewer"], - respond_to: respondTo, - respond_to_allowlist: respondTo === "allowlist" ? [BOB] : undefined, - parallelism: 4, - }), +function publication(overrides = {}) { + return { + eventId: "event-1", + ownerPubkey: ALICE, + sourcePersonaId: "reviewer", + createdAt: 1, + agent: { + displayName: "Relay Reviewer", + avatarUrl: null, + systemPrompt: "Review changes.", + runtime: null, + model: null, + provider: null, + namePool: [], + respondTo: null, + parallelism: null, }, - secretForOwner(owner), - ); -} - -test("a shared kind 30175 persona from Alice is discoverable by Bob", () => { - const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "alice-reviewer" }), - ]); - const personas = catalogPersonasFromPublications(publications, [], BOB); - - assert.equal(personas.length, 1); - assert.equal(personas[0].displayName, "Relay Reviewer"); - assert.equal(personas[0].isActive, false); - assert.equal(personas[0].shared, true); - assert.equal(personas[0].catalogSource.ownerPubkey, ALICE); - assert.equal(personas[0].catalogSource.isOwn, false); -}); - -test("a newer unshared head hides the older shared head", () => { - const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "shared" }), - personaEvent({ createdAt: 2, id: "unshared", shared: false }), - ]); - - assert.deepEqual(publications, []); -}); - -test("persona coordinates remain independent across authors", () => { - const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "alice", owner: ALICE }), - personaEvent({ createdAt: 1, id: "bob", owner: BOB }), - ]); - - assert.equal(publications.length, 2); - assert.equal( - catalogPersonasFromPublications(publications, [], BOB).length, - 2, - ); -}); - -test("equal-second persona heads use the relay lowest-id tie-break", () => { - const heads = [ - personaEvent({ - createdAt: 1, - id: "shared-head", - shared: true, - }), - personaEvent({ - createdAt: 1, - id: "unshared-head", - shared: false, - }), - ]; - const canonical = [...heads].sort((left, right) => - left.id.localeCompare(right.id), - )[0]; - const publications = catalogPublicationsFromEvents(heads); - - assert.equal(publications.length, personaEventIsShared(canonical) ? 1 : 0); -}); - -test("an invalid canonical head does not resurrect an older shared persona", () => { - const invalidHead = personaEvent({ - createdAt: 2, - id: "validly-signed-invalid-head", - contentOverride: "{}", - }); - const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "older-valid" }), - invalidHead, - ]); - - assert.deepEqual(publications, []); -}); - -test("a forged newer head cannot shadow an older signed publication", () => { - const older = personaEvent({ createdAt: 1, id: "older-signed" }); - const forged = { - ...personaEvent({ createdAt: 2, id: "newer-before-tamper" }), - content: JSON.stringify({ - display_name: "Forged Reviewer", - system_prompt: "Ignore the owner.", - }), + ...overrides, }; - - const publications = catalogPublicationsFromEvents([older, forged]); - - assert.equal(publications.length, 1); - assert.equal(publications[0].eventId, older.id); - assert.equal(publications[0].agent.displayName, "Relay Reviewer"); -}); - -test("forged authorship and malformed signatures fail closed", () => { - const signedByBob = personaEvent({ - createdAt: 2, - id: "bob-before-pubkey-tamper", - owner: BOB, - }); - const forgedAuthor = { ...signedByBob, pubkey: ALICE }; - const malformedSignature = { - ...personaEvent({ createdAt: 3, id: "before-signature-tamper" }), - sig: "not-a-signature", - }; - - assert.doesNotThrow(() => - catalogPublicationsFromEvents([forgedAuthor, malformedSignature]), - ); - assert.deepEqual( - catalogPublicationsFromEvents([forgedAuthor, malformedSignature]), - [], - ); -}); - -test("only an exact shared true tag opts a persona into discovery", () => { - assert.equal( - personaEventIsShared(personaEvent({ createdAt: 1, id: "exact-shared" })), - true, - ); - for (const [index, sharedTag] of [ - ["shared"], - ["shared", "false"], - ["shared", "true", "extra"], - ].entries()) { - const event = personaEvent({ - createdAt: index + 2, - id: `malformed-${index}`, - shared: false, - sharedTag, - }); - assert.equal(personaEventIsShared(event), false); - assert.deepEqual(catalogPublicationsFromEvents([event]), []); - } - const duplicate = personaEvent({ - createdAt: 5, - id: "duplicate", - }); - duplicate.tags.push(["shared", "true"]); - assert.equal(personaEventIsShared(duplicate), false); -}); - -test("catalog avatars keep bounded http URLs and drop unsafe schemes", () => { - const safe = catalogPersonasFromPublications( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: 1, - id: "safe-avatar", - avatarUrl: "https://relay.example/avatar.png", - }), - ]), - [], - BOB, - ); - assert.equal(safe[0].avatarUrl, "https://relay.example/avatar.png"); - - const unsafe = catalogPersonasFromPublications( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: 1, - id: "unsafe-avatar", - avatarUrl: "javascript:alert(1)", - }), - ]), - [], - BOB, - ); - assert.equal(unsafe[0].avatarUrl, null); -}); - -test("catalog rejects invisible or bidirectional formatting characters", () => { - for (const [index, character] of [ - "\u00ad", - "\u034f", - "\u200b", - "\u202e", - "\u2060", - "\u2066", - "\u3164", - "\u{e007f}", - ].entries()) { - assert.deepEqual( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: index + 1, - displayName: `Review${character}er`, - id: `unsafe-name-${index}`, - }), - ]), - [], - ); - assert.deepEqual( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: index + 1, - id: `unsafe-prompt-${index}`, - systemPrompt: `Review code.${character}`, - }), - ]), - [], - ); - } -}); - -test("catalog keeps rendered emoji sequences in names and instructions", () => { - for (const [index, emoji] of [ - "❤️", - "☕️", - "👩‍💻", - "🧑🏽‍💻", - "👨‍👩‍👧‍👦", - "1️⃣", - ].entries()) { - const publications = catalogPublicationsFromEvents([ - personaEvent({ - createdAt: index + 1, - displayName: `Reviewer ${emoji}`, - id: `rendered-emoji-${index}`, - systemPrompt: `Review changes ${emoji}`, - }), - ]); - - assert.equal(publications.length, 1); - assert.equal(publications[0].agent.displayName, `Reviewer ${emoji}`); - assert.equal(publications[0].agent.systemPrompt, `Review changes ${emoji}`); - } -}); - -test("catalog rejects detached emoji formatting and tag sequences", () => { - const taggedFlag = "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}"; - for (const [index, value] of [ - "Review\ufe0fer", - "Review\u200der", - "Review code.\u200d", - taggedFlag, - ].entries()) { - assert.deepEqual( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: index + 1, - displayName: value, - id: `detached-emoji-name-${index}`, - }), - ]), - [], - ); - assert.deepEqual( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: index + 1, - id: `detached-emoji-prompt-${index}`, - systemPrompt: value, - }), - ]), - [], - ); - } -}); - -test("catalog rejects layout controls in display names", () => { - for (const [index, character] of ["\n", "\t"].entries()) { - assert.deepEqual( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: index + 1, - displayName: `Relay${character}Reviewer`, - id: `unsafe-layout-name-${index}`, - }), - ]), - [], - ); - } -}); - -test("catalog keeps visible unicode and literal markdown instructions", () => { - const systemPrompt = - "Review changes.\n\t||This syntax must be shown literally.||"; - const publications = catalogPublicationsFromEvents([ - personaEvent({ - createdAt: 1, - displayName: "Relay Reviewer 🐝", - id: "visible-unicode", - systemPrompt, - }), - ]); - - assert.equal(publications[0].agent.displayName, "Relay Reviewer 🐝"); - assert.equal(publications[0].agent.systemPrompt, systemPrompt); -}); - -/** The avatar a catalog entry projects for `avatarUrl`, or null if dropped. */ -function catalogAvatarUrl(avatarUrl) { - const personas = catalogPersonasFromPublications( - catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "avatar-vector", avatarUrl }), - ]), - [], - BOB, - ); - return personas[0].avatarUrl; } -// An emoji avatar is self-contained, so it is the one `data:` avatar that can -// render on another member's machine. Dropping it left shared agents looking -// avatar-less in the catalog. -test("test_percent_encoded_emoji_svg_avatar_survives_the_catalog", () => { - const emojiAvatar = emojiAvatarDataUrl("🐝", "#FFCC00"); - - assert.equal(catalogAvatarUrl(emojiAvatar), emojiAvatar); -}); - -test("test_base64_svg_avatar_is_rejected", () => { - assert.equal( - catalogAvatarUrl(`data:image/svg+xml;base64,${btoa("")}`), - null, - ); -}); - -test("test_non_svg_data_avatar_is_rejected", () => { - assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null); -}); - -test("test_legacy_inline_raster_avatar_survives_the_catalog", () => { - for (const mime of ["png", "jpeg", "gif", "webp"]) { - const avatar = `data:image/${mime};base64,iVBORw0KGgo=`; - assert.equal(catalogAvatarUrl(avatar), avatar); - } -}); - -test("test_inline_raster_avatar_rejects_unbounded_or_malformed_payloads", () => { - const prefix = "data:image/png;base64,"; - const payloadLength = 256 * 1_024 - prefix.length; - const validPayloadLength = payloadLength - (payloadLength % 4); - const withinCap = `${prefix}${"a".repeat(validPayloadLength - 2)}==`; - assert.ok(withinCap.length <= 256 * 1_024); - assert.equal(catalogAvatarUrl(withinCap), withinCap); - assert.equal( - catalogAvatarUrl( - `${withinCap}${"a".repeat(256 * 1_024 - withinCap.length + 1)}`, - ), - null, - ); - assert.equal(catalogAvatarUrl("data:image/png;base64,not base64"), null); - assert.equal(catalogAvatarUrl("data:image/bmp;base64,aA=="), null); -}); - -test("test_oversized_inline_svg_avatar_is_rejected", () => { - const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`; - assert.equal(withinCap.length, 8_192); - assert.equal(catalogAvatarUrl(withinCap), withinCap); - assert.equal(catalogAvatarUrl(`${withinCap}a`), null); -}); - -// Catalog avatars render through `` (ProfileAvatar → AvatarImage), -// where an SVG document is never scripted, so a script-bearing avatar is -// accepted and inert rather than filtered — the projection must not silently -// start sanitizing markup it does not render. -test("test_script_bearing_inline_svg_avatar_is_accepted_and_rendered_inert", () => { - const scripted = `data:image/svg+xml,${encodeURIComponent( - '', - )}`; - - assert.equal(catalogAvatarUrl(scripted), scripted); -}); - -test("foreign allowlist behavior imports as owner-only", () => { - const personas = catalogPersonasFromPublications( - catalogPublicationsFromEvents([ - personaEvent({ - createdAt: 1, - id: "allowlist", - respondTo: "allowlist", - }), - ]), - [], - BOB, - ); - - assert.equal(personas[0].respondTo, "owner-only"); - assert.deepEqual(personas[0].respondToAllowlist, []); -}); - test("a pending local share does not appear before relay confirmation", () => { const localPersona = { id: "local-reviewer", @@ -501,13 +83,11 @@ function localPersona(overrides = {}) { // stored catalogSource coordinate links the copy back to the publication. test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_selection", () => { const publisherAvatar = "https://relay.example/publisher.png"; - const publications = catalogPublicationsFromEvents([ - personaEvent({ - createdAt: 1, - id: "alice-reviewer", - avatarUrl: publisherAvatar, + const publications = [ + publication({ + agent: { ...publication().agent, avatarUrl: publisherAvatar }, }), - ]); + ]; const copy = localPersona({ id: "a-fresh-uuid", displayName: "Locally Renamed Reviewer", @@ -535,9 +115,7 @@ test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_select }); test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { - const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "alice-reviewer" }), - ]); + const publications = [publication()]; // A same-named local persona with no provenance is a different agent. const unrelated = localPersona({ id: "unrelated" }); @@ -554,9 +132,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { // Provenance is per-owner: the same d-tag under a different publisher is a // different agent, so a copy of Alice's must not mask Bob's entry. test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { - const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "bob-reviewer", owner: BOB }), - ]); + const publications = [publication({ ownerPubkey: BOB })]; const copyOfAlices = localPersona({ id: "copy-of-alices", catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, @@ -573,9 +149,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { }); test("test_own_publication_still_resolves_by_local_id", () => { - const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "alice-reviewer" }), - ]); + const publications = [publication()]; const own = localPersona({ id: "reviewer", shared: true }); const personas = catalogPersonasFromPublications(publications, [own], ALICE); @@ -583,126 +157,3 @@ test("test_own_publication_still_resolves_by_local_id", () => { assert.equal(personas[0].id, "reviewer"); assert.equal(personas[0].catalogSource.isOwn, true); }); - -function pageOfEvents(count, startId, createdAt) { - return Array.from({ length: count }, (_, index) => - personaEvent({ - createdAt: typeof createdAt === "function" ? createdAt(index) : createdAt, - id: `event-${startId + index}`, - sourcePersonaId: `persona-${startId + index}`, - }), - ); -} - -function stubPagedRelay(pages) { - const filters = []; - mock.method(relayClient, "fetchEvents", (filter) => { - filters.push(filter); - return Promise.resolve(pages[filters.length - 1] ?? []); - }); - return filters; -} - -// A single limit-capped fetch drops every entry past the relay's clamp, making -// those agents undiscoverable. The walk must keep going while pages come back -// full, and must carry an `until` cursor derived from the oldest event seen. -test("test_full_page_is_followed_by_a_cursored_request_for_older_events", async (t) => { - t.after(() => mock.restoreAll()); - const filters = stubPagedRelay([ - pageOfEvents(500, 0, (index) => 10_000 - index), - pageOfEvents(3, 500, 9_000), - ]); - - const publications = await fetchPersonaCatalogPublications(); - - assert.equal(filters.length, 2, "a full page must be followed by another"); - assert.equal(filters[0].until, undefined, "the first page has no cursor"); - assert.equal( - filters[1].until, - 10_000 - 499, - "the cursor must be the oldest created_at from the previous page", - ); - assert.equal( - publications.length, - 503, - "entries past the first page must still be discoverable", - ); -}); - -test("test_invalid_events_cannot_control_the_catalog_cursor", async (t) => { - t.after(() => mock.restoreAll()); - const validEvents = pageOfEvents(499, 0, (index) => 10_000 - index); - const invalidOldest = { - ...personaEvent({ - createdAt: 1, - id: "invalid-oldest-cursor", - sourcePersonaId: "invalid-oldest-cursor", - }), - sig: "not-a-signature", - }; - const filters = stubPagedRelay([ - [...validEvents, invalidOldest], - pageOfEvents(1, 500, 9_000), - ]); - - const publications = await fetchPersonaCatalogPublications(); - - assert.equal(filters.length, 2); - assert.equal( - filters[1].until, - 10_000 - 498, - "the cursor must be derived only from verified events", - ); - assert.equal(publications.length, 500); - assert.equal( - publications.some( - (publication) => publication.sourcePersonaId === "invalid-oldest-cursor", - ), - false, - ); -}); - -test("test_short_first_page_does_not_issue_a_second_request", async (t) => { - t.after(() => mock.restoreAll()); - const filters = stubPagedRelay([pageOfEvents(2, 0, 10_000)]); - - const publications = await fetchPersonaCatalogPublications(); - - assert.equal(filters.length, 1); - assert.equal(publications.length, 2); -}); - -// `until` is inclusive on the relay, so consecutive pages overlap on the -// boundary timestamp. Without id dedupe the repeats would be counted twice. -test("test_overlapping_pages_are_deduped_by_event_id", async (t) => { - t.after(() => mock.restoreAll()); - const firstPage = pageOfEvents(500, 0, (index) => 10_000 - index); - const secondPage = [ - // The boundary event repeats because `until` includes its timestamp. - firstPage[firstPage.length - 1], - ...pageOfEvents(2, 500, 9_000), - ]; - stubPagedRelay([firstPage, secondPage]); - - const publications = await fetchPersonaCatalogPublications(); - - assert.equal(publications.length, 502, "the repeated event must count once"); -}); - -// The stop-on-no-progress guard: a full page whose events all share one -// created_at cannot advance the cursor, so paging must terminate instead of -// re-requesting the same page forever. -test("test_full_page_of_tied_timestamps_terminates_the_walk", async (t) => { - t.after(() => mock.restoreAll()); - const tiedPage = pageOfEvents(500, 0, 10_000); - const filters = stubPagedRelay([tiedPage, tiedPage, tiedPage, tiedPage]); - - const publications = await fetchPersonaCatalogPublications(); - - assert.equal( - filters.length, - 2, - "the walk must stop once a page contributes nothing new", - ); - assert.equal(publications.length, 500); -}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 3f7cd9fdd2..63a357e448 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -1,12 +1,9 @@ -import { relayClient } from "@/shared/api/relayClient"; import type { AgentPersona, CatalogSourceCoordinate, - RelayEvent, RespondToMode, } from "@/shared/api/types"; -import { KIND_PERSONA } from "@/shared/constants/kinds"; -import { verifyEvent } from "nostr-tools/pure"; +import { invokeTauri } from "@/shared/api/tauri"; export type CatalogPersonaShareLevel = "not-shared" | "none"; @@ -41,385 +38,19 @@ export type CatalogPersona = AgentPersona & { type JsonObject = Record; -const MAX_AGENT_DISPLAY_NAME_CHARACTERS = 128; -const MAX_AGENT_SYSTEM_PROMPT_BYTES = 64 * 1_024; -const EMOJI_VARIATION_SELECTOR = 0xfe0f; -const ZERO_WIDTH_JOINER = 0x200d; -const EXTENDED_PICTOGRAPHIC_RE = /^\p{Extended_Pictographic}$/u; - -function isProhibitedAgentTextCharacter( - characters: readonly string[], - index: number, - allowLayoutControls: boolean, -): boolean { - const character = characters[index]; - if (character === undefined) return false; - const codePoint = character.codePointAt(0); - if (codePoint === undefined) return false; - - const isControl = - codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); - const isAllowedLayoutControl = - allowLayoutControls && (codePoint === 0x09 || codePoint === 0x0a); - if (isControl && !isAllowedLayoutControl) return true; - if (isAllowedEmojiFormatCharacter(characters, index)) return false; - - return ( - codePoint === 0x00ad || - codePoint === 0x034f || - codePoint === 0x061c || - (codePoint >= 0x115f && codePoint <= 0x1160) || - (codePoint >= 0x17b4 && codePoint <= 0x17b5) || - (codePoint >= 0x180b && codePoint <= 0x180f) || - (codePoint >= 0x200b && codePoint <= 0x200f) || - (codePoint >= 0x202a && codePoint <= 0x202e) || - (codePoint >= 0x2060 && codePoint <= 0x206f) || - codePoint === 0x3164 || - (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || - codePoint === 0xfeff || - codePoint === 0xffa0 || - (codePoint >= 0xfff0 && codePoint <= 0xfff8) || - (codePoint >= 0x1bca0 && codePoint <= 0x1bca3) || - (codePoint >= 0x1d173 && codePoint <= 0x1d17a) || - (codePoint >= 0xe0000 && codePoint <= 0xe0fff) - ); -} - -function isAllowedEmojiFormatCharacter( - characters: readonly string[], - index: number, -): boolean { - const codePoint = characters[index]?.codePointAt(0); - if (codePoint === EMOJI_VARIATION_SELECTOR) { - const previous = characters[index - 1]; - return previous !== undefined && isEmojiVariationBase(previous); - } - if (codePoint !== ZERO_WIDTH_JOINER) return false; - - const next = characters[index + 1]; - return ( - hasPrecedingEmojiBase(characters, index) && - next !== undefined && - EXTENDED_PICTOGRAPHIC_RE.test(next) - ); -} - -function hasPrecedingEmojiBase( - characters: readonly string[], - index: number, -): boolean { - for (let previous = index - 1; previous >= 0; previous -= 1) { - const character = characters[previous]; - const codePoint = character?.codePointAt(0); - if ( - codePoint === EMOJI_VARIATION_SELECTOR || - (codePoint !== undefined && codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) - ) { - continue; - } - return character !== undefined && EXTENDED_PICTOGRAPHIC_RE.test(character); - } - return false; -} - -function isEmojiVariationBase(character: string): boolean { - return ( - /^[#*0-9]$/u.test(character) || EXTENDED_PICTOGRAPHIC_RE.test(character) - ); -} - -function isSafeAgentDefinitionText( - displayName: string, - systemPrompt: string, -): boolean { - const displayNameCharacters = [...displayName]; - const systemPromptCharacters = [...systemPrompt]; - return ( - displayName.trim().length > 0 && - displayNameCharacters.length <= MAX_AGENT_DISPLAY_NAME_CHARACTERS && - new TextEncoder().encode(systemPrompt).length <= - MAX_AGENT_SYSTEM_PROMPT_BYTES && - !displayNameCharacters.some((_character, index) => - isProhibitedAgentTextCharacter(displayNameCharacters, index, false), - ) && - !systemPromptCharacters.some((_character, index) => - isProhibitedAgentTextCharacter(systemPromptCharacters, index, true), - ) - ); -} - -function eventHasValidSignature(event: RelayEvent): boolean { - try { - // Verify a fresh wire-shaped value. nostr-tools memoizes successful checks - // on event objects; relay input must never inherit a stale verification - // marker from an object that was subsequently mutated. - return verifyEvent({ - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig, - }); - } catch { - return false; - } -} - function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } -function extractTag(event: RelayEvent, name: string): string | null { - const matches = event.tags.filter( - (tag) => tag.length >= 2 && tag[0] === name && typeof tag[1] === "string", - ); - return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; -} - -export function personaEventIsShared(event: RelayEvent): boolean { - const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); - return ( - sharedTags.length === 1 && - sharedTags[0]?.length === 2 && - sharedTags[0]?.[1] === "true" - ); -} - -function isSafeHttpUrl(value: unknown): value is string { - if ( - typeof value !== "string" || - value.length === 0 || - value.length > 2_048 || - /[\s()]/u.test(value) - ) { - return false; - } - try { - const parsed = new URL(value); - return parsed.protocol === "https:" || parsed.protocol === "http:"; - } catch { - return false; - } -} - /** - * Emoji avatars are the one `data:` avatar a catalog entry keeps. - * - * They persist as inline, percent-encoded SVG (`emojiAvatarDataUrl` in - * `ProfileAvatarEditor.utils.ts`), so they are self-contained and render on - * any member's machine — unlike a bundled runtime-default avatar, whose local - * asset path means nothing to another install. The accepted shape is exactly - * that prefix: the trailing comma is what rejects `;base64` payloads, and - * every other `data:` MIME stays rejected. Catalog avatars render through - * `` (`ProfileAvatar` → `AvatarImage`), where SVG script never - * executes, so bounding the length is the remaining concern — 8 KiB is an - * order of magnitude above the ~700 characters an emoji avatar encodes to. + * Fetch the active community catalog through the shared native relay session. + * Relay scoping, paging, signature verification, and head selection are native; + * this boundary intentionally accepts no caller-supplied relay or identity. */ -const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; -const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; - -/** - * Shared persona heads can carry an uploaded avatar as an inline raster. Keep - * those self-contained images renderable without accepting arbitrary `data:` - * URLs: only the raster MIME types browsers decode in ``, strict base64 - * shape, and a bound no larger than the relay's event-content ceiling. - */ -const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; -const INLINE_RASTER_AVATAR_RE = - /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; - -function isInlineSvgAvatar(value: unknown): value is string { - return ( - typeof value === "string" && - value.startsWith(INLINE_SVG_AVATAR_PREFIX) && - value.length <= MAX_INLINE_SVG_AVATAR_LENGTH - ); -} - -function isInlineRasterAvatar(value: unknown): value is string { - if ( - typeof value !== "string" || - value.length > MAX_INLINE_RASTER_AVATAR_LENGTH - ) { - return false; - } - const match = INLINE_RASTER_AVATAR_RE.exec(value); - return match !== null && (match[1]?.length ?? 0) % 4 === 0; -} - -function optionalString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - -function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { - let parsed: unknown; - try { - parsed = JSON.parse(event.content); - } catch { - return null; - } - if (!isObject(parsed)) return null; - - const displayName = parsed.display_name; - const systemPrompt = - typeof parsed.system_prompt === "string" ? parsed.system_prompt : ""; - if ( - typeof displayName !== "string" || - !isSafeAgentDefinitionText(displayName, systemPrompt) - ) { - return null; - } - - const avatarUrl = - isSafeHttpUrl(parsed.avatar_url) || - isInlineSvgAvatar(parsed.avatar_url) || - isInlineRasterAvatar(parsed.avatar_url) - ? parsed.avatar_url - : null; - const namePool = Array.isArray(parsed.name_pool) - ? parsed.name_pool.filter( - (candidate): candidate is string => typeof candidate === "string", - ) - : []; - const respondTo = - parsed.respond_to === "allowlist" - ? "owner-only" - : parsed.respond_to === "owner-only" || parsed.respond_to === "anyone" - ? parsed.respond_to - : null; - const parallelism = - typeof parsed.parallelism === "number" && - Number.isInteger(parsed.parallelism) && - parsed.parallelism >= 1 && - parsed.parallelism <= 32 - ? parsed.parallelism - : null; - - return { - displayName, - avatarUrl, - systemPrompt, - runtime: optionalString(parsed.runtime), - model: optionalString(parsed.model), - provider: optionalString(parsed.provider), - namePool, - respondTo, - parallelism, - }; -} - -/** - * Collapse relay results to the canonical NIP-33 head for each persona - * coordinate, then keep only exact `["shared", "true"]` heads. - * - * The relay normally returns one replaceable head. The client-side collapse is - * defense in depth for older relays and fixtures, and deliberately claims the - * coordinate before parsing so an invalid or unshared newest head cannot - * resurrect an older shared definition. - */ -export function catalogPublicationsFromEvents( - events: readonly RelayEvent[], -): PersonaCatalogPublication[] { - return catalogPublicationsFromVerifiedEvents( - events.filter(eventHasValidSignature), - ); -} - -function catalogPublicationsFromVerifiedEvents( - events: readonly RelayEvent[], -): PersonaCatalogPublication[] { - const sorted = [...events].sort( - (left, right) => - right.created_at - left.created_at || left.id.localeCompare(right.id), - ); - const seenCoordinates = new Set(); - const publications: PersonaCatalogPublication[] = []; - - for (const event of sorted) { - if (event.kind !== KIND_PERSONA) continue; - const sourcePersonaId = extractTag(event, "d"); - if (!sourcePersonaId) continue; - const ownerPubkey = event.pubkey.toLowerCase(); - const coordinate = `${ownerPubkey}:${sourcePersonaId}`; - if (seenCoordinates.has(coordinate)) continue; - seenCoordinates.add(coordinate); - - if (!personaEventIsShared(event)) continue; - const agent = parsePersonaContent(event); - if (!agent) continue; - publications.push({ - eventId: event.id, - ownerPubkey, - sourcePersonaId, - createdAt: event.created_at, - agent, - }); - } - - return publications; -} - -/** - * Events per catalog page. - * - * Kept well under the relay's 1,000-row `query_events` clamp so a page that - * comes back full is a reliable "there may be more" signal rather than a - * silently truncated result. - */ -const CATALOG_PAGE_SIZE = 500; - -/** - * Hard bound on pages walked, so a relay that keeps returning full pages can - * never spin this forever. - */ -const MAX_CATALOG_PAGES = 40; - -/** - * Read every shared persona event, page by page. - * - * A single `limit`-capped fetch silently truncates once a community publishes - * more agents than the relay's clamp, and the entries that fall off are simply - * undiscoverable. Paging walks backwards through `created_at` using the only - * cursor a WS `REQ` filter carries — `until` — which the relay treats as - * *inclusive*, so consecutive pages overlap on tied timestamps. Two things - * follow, and both are load-bearing: - * - * - dedupe by event id, because the boundary events repeat; and - * - stop when a page contributes nothing new, because a page whose events all - * share one `created_at` would otherwise be requested forever. - */ -export async function fetchPersonaCatalogPublications(): Promise< +export function fetchPersonaCatalogPublications(): Promise< PersonaCatalogPublication[] > { - const byId = new Map(); - let until: number | undefined; - - for (let page = 0; page < MAX_CATALOG_PAGES; page += 1) { - const events = await relayClient.fetchEvents({ - kinds: [KIND_PERSONA], - limit: CATALOG_PAGE_SIZE, - ...(until === undefined ? {} : { until }), - }); - - const sizeBefore = byId.size; - let oldestCreatedAt = Number.POSITIVE_INFINITY; - for (const event of events) { - if (!eventHasValidSignature(event)) continue; - byId.set(event.id, event); - oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); - } - - // A short page is the end of the catalog; a page of only-repeats means the - // cursor cannot advance past a run of tied timestamps. - if (events.length < CATALOG_PAGE_SIZE || byId.size === sizeBefore) { - break; - } - until = oldestCreatedAt; - } - - return catalogPublicationsFromVerifiedEvents([...byId.values()]); + return invokeTauri("fetch_persona_catalog"); } function publicationToPersona( diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 88e8bba754..68fa290ad2 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -272,7 +272,7 @@ function appendAgentEvents( } if (added.length === 0) return null; - const sortedAdded = added.sort(compareObserverEvents); + const sortedAdded = [...added].sort(compareObserverEvents); const sorted = allAtEnd ? [...current, ...sortedAdded] : [...current, ...sortedAdded].sort(compareObserverEvents); @@ -468,9 +468,19 @@ function processLiveObserverEvents( // callbacks. Those callbacks historically observed their triggering frame // in the raw/transcript stores; batching must preserve that visibility while // deferring only the global external-store publication. - const addedEvents = appendAgentEvents(agentPubkey, events); + // + // Dispatch iterates the ACCEPTED events, not the raw envelope: the observer + // relay requests a five-minute replay on reconnect, so an already-seen frame + // can re-arrive. `appendAgentEvents` drops those as duplicates and returns + // only the newly-accepted set; dispatching that set keeps a replayed + // `control_result` from re-settling a live model switch, and likewise + // prevents any other side-effect listener (latest-live tracking, management + // requests, session-config capture, lifecycle) from firing twice for one + // frame. Every such listener is a command or idempotent cache write — none + // depends on duplicate re-delivery — so deduping is strictly correct. + const accepted = appendAgentEvents(agentPubkey, events); - for (const parsed of events) { + for (const parsed of accepted ?? []) { // Track the latest-live-session-id per (agent, channel) on the live path. // Only set when the parsed event carries both a sessionId and channelId, // so we never attribute a session to the wrong channel. @@ -500,7 +510,9 @@ function processLiveObserverEvents( void putAgentSessionConfig(agentPubkey, parsed.payload); onSessionConfigCaptured?.(agentPubkey); } else if (parsed.kind === "control_result") { - dispatchControlResult(agentPubkey, parsed.payload); + // Thread the envelope's channelId into the frame so the ModelPicker can + // count a terminal switch result once per distinct channel. + dispatchControlResult(agentPubkey, parsed.payload, parsed.channelId); } else if (parsed.kind === "managed_agent_runtime_lifecycle") { void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch( (error) => { @@ -512,8 +524,8 @@ function processLiveObserverEvents( // Preserve the harness's envelope backpressure: retained state was committed // before specialized callbacks, but external-store subscribers publish once. - if (addedEvents) { - notifyListeners({ agentPubkey, events: addedEvents }); + if (accepted) { + notifyListeners({ agentPubkey, events: accepted }); } } @@ -639,7 +651,11 @@ function isControlResultFrame(payload: unknown): payload is ControlResultFrame { ); } -function dispatchControlResult(agentPubkey: string, payload: unknown) { +function dispatchControlResult( + agentPubkey: string, + payload: unknown, + channelId: string | null, +) { if (!isControlResultFrame(payload)) { return; } @@ -647,8 +663,13 @@ function dispatchControlResult(agentPubkey: string, payload: unknown) { if (!subscribers) { return; } + // The channelId lives on the observer envelope, not the inner payload, so + // stamp it onto the frame here. Listeners (the ModelPicker) count a terminal + // switch result once per distinct channel; the envelope is the only place a + // late `control_result` carries its channel identity. + const frame: ControlResultFrame = { ...payload, channelId }; for (const subscriber of subscribers) { - subscriber(payload); + subscriber(frame); } } diff --git a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx index c2422466e4..b47cee5e3c 100644 --- a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx +++ b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx @@ -143,7 +143,10 @@ function AgentCardViewerContent({ toast.success(`Sent ${agentName}'s card.`); closeCardViewer(); } else if (sent === false) { - toast.error("Couldn’t send the card. Try again."); + toast.error( + sendController.getCurrentError() ?? + "Couldn’t send the card. Try again.", + ); } } diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 046bd13c47..e5f3a74530 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -289,6 +289,30 @@ function ProfileConfigSection({ ); } +/** + * #3493: caveat shown when the surface was read from a user-set + * `CLAUDE_CONFIG_DIR`. Claude Code keys its stored login to the config-dir + * path, so a custom dir maps to a fresh Keychain namespace — the agent starts + * logged out unless `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set to match the + * default login. + */ +function ClaudeConfigDirNotice() { + return ( +
+

+ ⚠ Custom CLAUDE_CONFIG_DIR{" "} + active — config is read from that directory. Claude Code keys its login + to the config-dir path, so a custom dir creates a new Keychain + namespace. The agent will need to re-authenticate unless you also set{" "} + + CLAUDE_SECURESTORAGE_CONFIG_DIR + {" "} + to match your default login. +

+
+ ); +} + export function AgentConfigPanel({ advancedMode = "collapsed", onEdit, @@ -355,7 +379,9 @@ export function AgentConfigSurfaceRows({ }: AgentConfigSurfaceRowsProps) { const [advancedOpen, setAdvancedOpen] = React.useState(false); - const { normalized, advanced, extensions, runtimeId } = data; + const { normalized, advanced, extensions, runtimeId, sources } = data; + const mcpConfigFilePath = sources.mcpConfigFilePath; + const claudeConfigDirCustom = data.claudeConfigDirCustom ?? false; const normalizedEntries = ( Object.entries(normalized) as [ @@ -414,6 +440,7 @@ export function AgentConfigSurfaceRows({ > @@ -430,6 +457,8 @@ export function AgentConfigSurfaceRows({ ))} ) : null} + + {claudeConfigDirCustom ? : null}
); } @@ -457,6 +486,7 @@ export function AgentConfigSurfaceRows({ @@ -485,6 +515,8 @@ export function AgentConfigSurfaceRows({ ) : null} ) : null} + + {claudeConfigDirCustom ? : null} ); } diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f7ee098833..62d85d385d 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -25,6 +25,7 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents"; +import { EffortPickerField } from "./EffortPickerField"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { ADVANCED_FIELDS_MOTION_TRANSITION, @@ -267,10 +268,10 @@ export function AgentInstanceEditDialog({ return runtimeSupportsLlmProviderSelection(matched?.id ?? ""); }, [runtimes, originalAgentCommand]); - // The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime - // (that is what runs once the override is cleared, not the current override). - // Falls back to dual-match (command path, then id) when no persona or its runtime is unset. - // This single prospective id feeds BOTH the block-save gate and submit so they always agree. + // The runtime id active after submit — the single prospective id feeding BOTH + // the block-save gate and submit so they always agree. Inheriting resolves + // from the LINKED PERSONA's runtime (what runs once the override is cleared), + // falling back to dual-match (command path, then id) when no persona. const prospectiveRuntimeId = React.useMemo(() => { if (!inheritHarness) { return selectedRuntime?.id ?? selectedRuntimeId; @@ -425,11 +426,10 @@ export function AgentInstanceEditDialog({ selectedRuntime, }); - // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display. - // The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating. - // D2/D3: the top-level API key owns display, while the readiness gate keeps - // the complete required-key list. The effective snapshot covers persona - // inheritance during an instance inherit transition. + // D2/D3: the top-level API key owns display while the readiness gate keeps the + // complete required-key list; advancedRequiredEnvKeys drives EnvVarsEditor + // display only. The effective snapshot covers persona inheritance during an + // instance inherit transition. const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider); const personaSatisfied = providerApiKeyEnvVar != null && @@ -693,11 +693,9 @@ export function AgentInstanceEditDialog({ : normalizedModel !== (agent.model ?? null) ? normalizedModel : undefined, - // Tri-state provider persistence keyed on providerRuntimeCapability: - // "capable" → persist: value if changed, omit if unchanged. - // "locked" → clear: send null if provider was set, else omit. - // "unknown" → omit always (never send null for a transient state). - // llmProviderFieldVisible is for UX visibility only; not used here. + // Tri-state provider persistence keyed on providerRuntimeCapability + // (see the classification comment above for the capable/locked/unknown + // contract). llmProviderFieldVisible is UX visibility only; not used here. provider: linkedPersona != null ? undefined @@ -1128,6 +1126,8 @@ export function AgentInstanceEditDialog({ ) : null} + + setAiDefaultsOpen(true)} triggerRef={aiDefaultsTriggerRef} diff --git a/desktop/src/features/agents/ui/EffortPickerField.tsx b/desktop/src/features/agents/ui/EffortPickerField.tsx new file mode 100644 index 0000000000..a06f17ac11 --- /dev/null +++ b/desktop/src/features/agents/ui/EffortPickerField.tsx @@ -0,0 +1,81 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks"; +import { persistAgentEffortLevel } from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types"; +import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions"; +import { + effortPickerState, + effortSelectionToPersistedValue, +} from "./effortPicker"; +import { PersonaDropdownField } from "./PersonaDropdownField"; + +/** + * Thinking-effort write control for the edit dialog (B5, v4 direct-write). + * + * Local-only by construction: the write calls `persistAgentEffortLevel`, which + * the Rust command rejects for non-local backends (remote effort is set at + * deploy time via `policy_env`). So the control renders only for a local + * backend AND once the adapter has advertised a `thought_level` configId + * (discovered from the running session — absent pre-first-session and for + * runtimes/models without effort support). The read-only configured-vs-running + * two-facts display lives in `AgentConfigPanel`; this is the write control. + * + * Direct-write: each selection persists immediately and invalidates the config + * surface so the panel's canonical tier reflects the new next-spawn value. + */ +export function EffortPickerField({ + agent, + config, +}: { + agent: ManagedAgent; + config: RuntimeConfigSurface | undefined; +}) { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: (level: string | null) => + persistAgentEffortLevel(agent.pubkey, level), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + }), + }); + const { visible, options, selectValue } = effortPickerState({ + backend: agent.backend, + effortConfigId: config?.effortConfigId, + effortOptions: config?.effortOptions, + currentEffort: config?.normalized.thinkingEffort?.value ?? null, + }); + + if (!visible) { + return null; + } + + return ( +
+ + + mutation.mutate(effortSelectionToPersistedValue(value)) + } + options={options} + placeholder="Adapter default" + value={selectValue} + /> +

+ Applied at the next session start. +

+ {mutation.error instanceof Error ? ( +

{mutation.error.message}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/McpServersSection.test.mjs b/desktop/src/features/agents/ui/McpServersSection.test.mjs new file mode 100644 index 0000000000..526acbca5e --- /dev/null +++ b/desktop/src/features/agents/ui/McpServersSection.test.mjs @@ -0,0 +1,90 @@ +/** + * #3493 provenance: the MCP servers section must attribute its entries to the + * ACTUAL config file the reader read — which, under a custom CLAUDE_CONFIG_DIR, + * is the isolated `/.claude.json`, not the default `~/.claude.json`. + * + * Before this fix `mcpConfigFilePath` was carried on the DTO but no component + * consumed it, so the panel listed the correct servers with no file + * attribution at all. These pin the rendered contract. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { + McpServersSection, + mcpConfigFileCaption, +} from "./McpServersSection.tsx"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const CLAUDE_MCP = [{ name: "sentinel", kind: "stdio", enabled: true }]; + +test("mcpConfigFileCaption_customPath_returnsFileAttribution", () => { + assert.equal( + mcpConfigFileCaption("/tmp/iso/.claude.json"), + "From config file (/tmp/iso/.claude.json)", + ); +}); + +test("mcpConfigFileCaption_nullPath_returnsNull", () => { + assert.equal(mcpConfigFileCaption(null), null); + assert.equal(mcpConfigFileCaption(undefined), null); +}); + +test("McpServersSection_customConfigDir_rendersIsolatedFilePath", async () => { + const { render } = await import("@testing-library/react"); + const React = await import("react"); + + const { container } = render( + React.createElement(McpServersSection, { + extensions: CLAUDE_MCP, + mcpConfigFilePath: "/tmp/iso/.claude.json", + runtimeId: "claude", + variant: "compact", + }), + ); + + assert.match(container.textContent, /sentinel/); + assert.match( + container.textContent, + /From config file \(\/tmp\/iso\/\.claude\.json\)/, + ); +}); + +test("McpServersSection_noConfigPath_omitsFileAttribution", async () => { + const { render } = await import("@testing-library/react"); + const React = await import("react"); + + const { container } = render( + React.createElement(McpServersSection, { + extensions: CLAUDE_MCP, + mcpConfigFilePath: null, + runtimeId: "claude", + variant: "compact", + }), + ); + + assert.match(container.textContent, /sentinel/); + assert.doesNotMatch(container.textContent, /From config file/); +}); diff --git a/desktop/src/features/agents/ui/McpServersSection.tsx b/desktop/src/features/agents/ui/McpServersSection.tsx index f92f77a392..db3de3b200 100644 --- a/desktop/src/features/agents/ui/McpServersSection.tsx +++ b/desktop/src/features/agents/ui/McpServersSection.tsx @@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn"; type McpServersSectionProps = { extensions: ExtensionEntry[]; runtimeId: string | null; + mcpConfigFilePath?: string | null; variant?: "compact" | "profile"; buzzAgentSlot?: React.ReactNode; }; @@ -19,9 +20,19 @@ export function shouldRenderMcpServers( return runtimeId === "buzz-agent" || extensions.length > 0; } +// #3493: the servers are read from the isolated `.claude.json` under a custom +// `CLAUDE_CONFIG_DIR`. Attribute them to that actual file so the panel never +// implies the default `~/.claude.json` when isolation is in effect. +export function mcpConfigFileCaption( + mcpConfigFilePath: string | null | undefined, +): string | null { + return mcpConfigFilePath ? `From config file (${mcpConfigFilePath})` : null; +} + export function McpServersSection({ buzzAgentSlot, extensions, + mcpConfigFilePath, runtimeId, variant = "compact", }: McpServersSectionProps) { @@ -31,6 +42,8 @@ export function McpServersSection({ return null; } + const fileCaption = mcpConfigFileCaption(mcpConfigFilePath); + return (
)} + + {extensions.length > 0 && fileCaption ? ( +

+ {fileCaption} +

+ ) : null}
); } diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 863cd85119..0bc6f9646a 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -109,26 +109,39 @@ export function ModelPicker({ }, [configSurface]); // Send a live `switch_model` frame to each channel the agent is working in - // and wait for the harness to acknowledge. Any single `unsupported_model` - // result rejects the whole pick immediately; all other statuses must arrive - // from every channel before resolving success. + // and wait for the harness to acknowledge. A single `unsupported_model` + // (model unavailable) or `failure` (adapter refused) result rejects the whole + // pick immediately. The busy-path `sent` ack is provisional (the adapter + // isn't consulted until the requeued session); success is confirmed only by a + // real positive terminal frame from every channel, and if none arrives before + // the timeout the pick resolves `"pending"` (accepted, apply deferred). const sendLiveSwitch = React.useCallback( (modelId: string) => { const channelIds = activeTurns.map((turn) => turn.channelId); + // Opaque per-pick correlator. The harness echoes it on the immediate ack + // and the late terminal frame, so a five-minute reconnect replay of an + // earlier pick's result cannot settle this one. + const requestId = crypto.randomUUID(); return awaitLiveSwitchOutcome({ - channelCount: channelIds.length, - modelId, + requestId, + channelIds, subscribe: (listener) => subscribeControlResults(agent.pubkey, listener), sendSwitches: async () => { await Promise.all( channelIds.map((channelId) => - switchManagedAgentModel(agent.pubkey, channelId, modelId), + switchManagedAgentModel( + agent.pubkey, + channelId, + modelId, + requestId, + ), ), ); }, - // No reply in time: treat as sent. The override still rides the - // requeued/next session; we just can't confirm synchronously. + // No positive terminal in time: resolve `"pending"`. The override still + // rides the requeued/next session; we just can't confirm synchronously, + // and must not claim a success that hasn't happened. scheduleTimeout: (onTimeout) => { const timeout = window.setTimeout(onTimeout, 8_000); return () => window.clearTimeout(timeout); @@ -148,6 +161,31 @@ export function ModelPicker({ toast.error("That model isn't available for this agent."); return; } + if (outcome === "failed") { + toast.error( + "Couldn't switch models — the agent kept its current model.", + ); + return; + } + if (outcome === "not_delivered") { + // The switch never reached a session: the turn was already ending, or + // no active turn remained by the time the harness received it. Nothing + // was applied and nothing rides a later session — tell the truth. + toast.error( + "Couldn't switch models — the agent wasn't running a turn to switch.", + ); + return; + } + if (outcome === "pending") { + // The switch was accepted but its apply is deferred to the next + // session (the agent is mid-turn) and didn't confirm before the + // fallback timeout. Tell the truth instead of claiming success. + toast.info( + "Model switch pending — applies when the current turn finishes.", + ); + onModelChanged?.(); + return; + } toast.success("Model switched for this session."); onModelChanged?.(); return; diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 5cf4f9ea3b..13eae7971b 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -391,7 +391,10 @@ export function SnapshotShareDialog({ toast.success(`Sent a copy of ${displayName}`); onOpenChange(false); } else if (sent === false) { - toast.error(`Couldn’t send ${itemLabel}. Try again.`); + toast.error( + snapshotSendController.getCurrentError() ?? + `Couldn’t send ${itemLabel}. Try again.`, + ); } } diff --git a/desktop/src/features/agents/ui/effortPicker.test.mjs b/desktop/src/features/agents/ui/effortPicker.test.mjs new file mode 100644 index 0000000000..28c22ec9d9 --- /dev/null +++ b/desktop/src/features/agents/ui/effortPicker.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EFFORT_DEFAULT_DROPDOWN_VALUE, + effortPickerState, + effortSelectionToPersistedValue, +} from "./effortPicker.ts"; + +const localBackend = { type: "local" }; +const providerBackend = { type: "provider", id: "openai", config: {} }; +const options = [ + { value: "low", displayName: "Low" }, + { value: "high", displayName: "High" }, +]; + +test("effort picker renders for a local backend with a discovered configId", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.equal(state.visible, true); +}); + +test("effort picker is hidden for a provider backend even when a configId exists", () => { + const state = effortPickerState({ + backend: providerBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "high", + }); + assert.equal(state.visible, false); +}); + +test("effort picker is hidden for a local backend without a discovered configId", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: undefined, + effortOptions: undefined, + currentEffort: null, + }); + assert.equal(state.visible, false); +}); + +test("options lead with the adapter-default sentinel then adapter values", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.deepEqual(state.options, [ + { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE }, + { label: "Low", value: "low" }, + { label: "High", value: "high" }, + ]); +}); + +test("option label falls back to the raw value when displayName is absent", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: [{ value: "medium" }], + currentEffort: null, + }); + assert.deepEqual(state.options[1], { label: "medium", value: "medium" }); +}); + +test("current effort preselects the matching option", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "high", + }); + assert.equal(state.selectValue, "high"); +}); + +test("an unknown current effort falls back to the adapter-default sentinel", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "extreme", + }); + assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE); +}); + +test("a null current effort selects the adapter-default sentinel", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE); +}); + +test("the sentinel selection persists as null (clear to adapter default)", () => { + assert.equal( + effortSelectionToPersistedValue(EFFORT_DEFAULT_DROPDOWN_VALUE), + null, + ); +}); + +test("a concrete selection persists as its explicit effort level", () => { + assert.equal(effortSelectionToPersistedValue("high"), "high"); +}); diff --git a/desktop/src/features/agents/ui/effortPicker.ts b/desktop/src/features/agents/ui/effortPicker.ts new file mode 100644 index 0000000000..515355e4ad --- /dev/null +++ b/desktop/src/features/agents/ui/effortPicker.ts @@ -0,0 +1,71 @@ +import type { + AcpConfigOptionValue, + ManagedAgentBackend, +} from "@/shared/api/types"; +import type { PersonaDropdownOption } from "./agentConfigOptions"; + +/** + * Sentinel dropdown value for "no explicit effort" — reverts the agent to the + * adapter default at the next spawn. Distinct from any adapter option value. + */ +export const EFFORT_DEFAULT_DROPDOWN_VALUE = "__effort_default__"; + +/** + * Pure gating + option compute for the effort write control in the edit dialog. + * + * The picker is a LOCAL-only, direct-write control: it calls + * `persistAgentEffortLevel`, which the Rust command rejects for non-local + * backends (remote effort is set at deploy time via `policy_env`). So the UI + * must not offer it for a provider backend, and there's nothing to pick until + * the adapter has advertised a `thought_level` config option (discovered from + * the running session — `effortConfigId` is absent pre-first-session and for + * runtimes/models that don't support effort). + * + * `visible` is the single gate the dialog renders on: local backend AND a + * discovered `effortConfigId`. + */ +export function effortPickerState({ + backend, + effortConfigId, + effortOptions, + currentEffort, +}: { + backend: ManagedAgentBackend; + effortConfigId: string | undefined; + effortOptions: readonly AcpConfigOptionValue[] | undefined; + currentEffort: string | null; +}): { + visible: boolean; + options: PersonaDropdownOption[]; + selectValue: string; +} { + const visible = backend.type === "local" && effortConfigId !== undefined; + + const options: PersonaDropdownOption[] = [ + { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE }, + ...(effortOptions ?? []).map((option) => ({ + label: option.displayName ?? option.value, + value: option.value, + })), + ]; + + // Preselect the currently-configured effort when it maps to a known option; + // otherwise fall back to the adapter-default sentinel (also the null case). + const trimmed = currentEffort?.trim() ?? ""; + const selectValue = + trimmed.length > 0 && + (effortOptions ?? []).some((option) => option.value === trimmed) + ? trimmed + : EFFORT_DEFAULT_DROPDOWN_VALUE; + + return { visible, options, selectValue }; +} + +/** + * Map a dropdown selection back to the value persisted via + * `persistAgentEffortLevel`: the sentinel clears effort (null → adapter + * default), any other value is the explicit effort level. + */ +export function effortSelectionToPersistedValue(value: string): string | null { + return value === EFFORT_DEFAULT_DROPDOWN_VALUE ? null : value; +} diff --git a/desktop/src/features/agents/ui/modelCapabilities.ts b/desktop/src/features/agents/ui/modelCapabilities.ts index bd160c7b81..bce4af829a 100644 --- a/desktop/src/features/agents/ui/modelCapabilities.ts +++ b/desktop/src/features/agents/ui/modelCapabilities.ts @@ -358,15 +358,48 @@ export function resolveModelCapabilities( export const DATABRICKS_V2_KNOWN_MODELS: ReadonlyArray = MANIFEST.databricks_v2_known_models; -/** - * Databricks endpoint-id → display-name registry, derived at runtime from the - * manifest's `databricks_v2` exact records (the only exact records that carry a - * `registry_label`). Feeds the providerless registry tier of - * `resolveModelLabel`. Derived, not hand-listed — the manifest stays the single - * source of truth, so there is no second table to keep in sync. - */ -export const DATABRICKS_MODEL_NAMES: ReadonlyMap = new Map( - MANIFEST.exact_records - .filter((rec) => rec.provider === "databricks_v2") - .map((rec) => [rec.raw_model_id, rec.registry_label] as const), -); +export type RegistryLabelRecord = { + readonly provider: string; + readonly raw_model_id: string; + readonly registry_label: string; +}; + +export function databricksRegistryLabelForRecords( + rawModelId: string, + records: ReadonlyArray, + familyTokens: ReadonlyArray, +): string | null { + if (!rawModelId.trim()) return null; + + const idLower = rawModelId.toLowerCase(); + const exact = records.find( + (rec) => + rec.provider === "databricks_v2" && + rec.raw_model_id.toLowerCase() === idLower, + ); + if (exact) return exact.registry_label; + + const strippedQuery = stripCatalogPrefix(idLower, familyTokens); + if (strippedQuery === idLower) return null; + let matchingRecord: RegistryLabelRecord | null = null; + for (const rec of records) { + if (rec.provider !== "databricks_v2") continue; + const strippedRecord = stripCatalogPrefix( + rec.raw_model_id.toLowerCase(), + familyTokens, + ); + if (strippedRecord === strippedQuery) { + if (matchingRecord) return null; + matchingRecord = rec; + } + } + return matchingRecord?.registry_label ?? null; +} + +export function databricksRegistryLabel(rawModelId: string): string | null { + return databricksRegistryLabelForRecords( + rawModelId, + MANIFEST.exact_records, + MANIFEST.family_tokens, + ); +} diff --git a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs index 9446219493..b124b0b61d 100644 --- a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs +++ b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { + databricksRegistryLabelForRecords, ManifestSchema, resolveModelCapabilities, } from "./modelCapabilities.ts"; diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts index e14481b750..d7e4daca2c 100644 --- a/desktop/src/features/agents/ui/useSnapshotSendController.ts +++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts @@ -350,6 +350,12 @@ export type UseSnapshotSendControllerResult = { /** Relay moderation identity to exclude from the people picker. */ relaySelfPubkey: string | null; state: SnapshotSendState; + /** + * Read the latest error synchronously — right after `beginSend` resolves the + * render-captured `state.error` is stale until the next commit, so callers + * that toast on failure must read through here. + */ + getCurrentError: () => string | null; /** * Execute destination creation plus prepare → encode → upload → send behind * one concurrency guard. A second call while the first is in flight returns @@ -390,6 +396,15 @@ export function useSnapshotSendController( error: null, }); + // Mirror `state` into a ref so callers can read the latest error + // synchronously right after `beginSend` resolves — the render-captured + // `state` in their closure is stale until the next render commits. + const stateRef = React.useRef(state); + const commitState = React.useCallback((next: SnapshotSendState) => { + stateRef.current = next; + setState(next); + }, []); + // Single-concurrency guard covering the full encode → upload → send action. // Stored in a ref so it survives re-renders without triggering effects. const guardRef = React.useRef(createSendGuard()); @@ -417,7 +432,7 @@ export function useSnapshotSendController( checkEligibilityFn: () => checkSendEligibility(queryClient, channelId), uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename), sendFn: (args) => sendMutation.mutateAsync(args), - setStateFn: setState, + setStateFn: commitState, buildMessageFn: (descriptor) => { const message = buildOutgoingMessage("", [descriptor]); return attachmentLabel?.trim() @@ -430,15 +445,15 @@ export function useSnapshotSendController( : message; }, }), - setState, + commitState, ); } const reset = React.useCallback(() => { if (!guardRef.current.inFlight) { - setState({ phase: "idle", error: null }); + commitState({ phase: "idle", error: null }); } - }, []); + }, [commitState]); return { isDmSafetyReady: @@ -447,6 +462,7 @@ export function useSnapshotSendController( relaySelfQuery.status === "success"), relaySelfPubkey: relaySelfQuery.data ?? null, state, + getCurrentError: () => stateRef.current.error, beginSend, reset, }; diff --git a/desktop/src/features/agents/useOpenAgentActivity.ts b/desktop/src/features/agents/useOpenAgentActivity.ts index e8cfc0e8ff..4be71953b1 100644 --- a/desktop/src/features/agents/useOpenAgentActivity.ts +++ b/desktop/src/features/agents/useOpenAgentActivity.ts @@ -2,7 +2,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { useChannelReferences } from "@/features/channels/openChannelDirectory"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import type { Channel } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -77,13 +77,27 @@ export function useOpenAgentActivity() { const { goChannel } = useAppNavigation(); const relayAgentsQuery = useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data; - const channelsQuery = useChannelsQuery(); - const channels = channelsQuery.data; + // Agent metadata and the working-signal snapshot are both finite id sources. + // Resolve them by id, never by scanning the all-open directory, so an agent + // can link to a readable open channel the viewer has not browsed this session. + const activityChannelIds = React.useMemo( + () => [ + ...(relayAgents ?? []).flatMap((agent) => agent.channelIds), + ...(relayAgents ?? []).flatMap((agent) => + getAgentWorkingState(agent.pubkey).channels.map( + (working) => working.channelId, + ), + ), + ], + [relayAgents], + ); + const { channelsById, isReady: areChannelsReady } = + useChannelReferences(activityChannelIds); const findOpenableChannel = React.useCallback( (channelId: string): boolean => - isChannelOpenable(channels?.find((entry) => entry.id === channelId)), - [channels], + isChannelOpenable(channelsById.get(channelId)), + [channelsById], ); const resolveChannelId = React.useCallback( @@ -93,7 +107,7 @@ export function useOpenAgentActivity() { (agent) => normalizePubkey(agent.pubkey) === key, ); const openableChannelIds = new Set( - (channels ?? []) + [...channelsById.values()] .filter((channel) => isChannelOpenable(channel)) .map((channel) => channel.id), ); @@ -103,7 +117,7 @@ export function useOpenAgentActivity() { // Deliberately an unsubscribed snapshot: this callback runs on click // (and in canOpenAgentActivity), not in render, so we don't need to // recompute when working state changes — its deps are only - // [channels, relayAgents]. Worst case the preferred working-channel + // [channelsById, relayAgents]. Worst case the preferred working-channel // target lags a just-changed signal; the member-channel fallback in // resolveOpenableActivityChannelId keeps the destination valid. workingChannelIds: getAgentWorkingState(pubkey).channels.map( @@ -111,7 +125,7 @@ export function useOpenAgentActivity() { ), }); }, - [channels, relayAgents], + [channelsById, relayAgents], ); const canOpenAgentActivity = React.useCallback( @@ -127,12 +141,12 @@ export function useOpenAgentActivity() { // optimistic until channels resolve so "View activity log" doesn't // flicker in on cold start; openAgentActivity still guards the actual // navigation. - if (channels === undefined) { + if (!areChannelsReady) { return true; } return resolveChannelId(pubkey) !== null; }, - [channels, onOpenAgentSession, resolveChannelId], + [areChannelsReady, onOpenAgentSession, resolveChannelId], ); const openAgentActivity = React.useCallback( @@ -143,14 +157,17 @@ export function useOpenAgentActivity() { // an inaccessible room (in place or via navigation) would expose that // room's activity content, so we warn and stop instead. if (options?.channelId) { - if (!findOpenableChannel(options.channelId)) { - toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE); - return false; - } if (!onOpenAgentSession) { + if (!findOpenableChannel(options.channelId)) { + toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE); + return false; + } void goChannel(options.channelId, { agentSession: pubkey }); return true; } + // A channel-scoped AgentSessionProvider belongs to the channel view + // already authorized by its route. Do not reject its own current + // channel while the member/reference query is still settling. onOpenAgentSession(pubkey, options.channelId); return true; } diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index fb2b6864b3..007ffeef44 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -27,11 +27,11 @@ import type { Channel, ChannelDetail, CreateChannelInput, - OpenDmInput, SetChannelPurposeInput, SetChannelTopicInput, UpdateChannelInput, } from "@/shared/api/types"; +import type { OpenDmInput } from "@/shared/api/tauriChannels"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; @@ -68,7 +68,7 @@ const channelTypeOrder = { dm: 2, } as const; -function sortChannels(channels: Channel[]) { +export function sortChannels(channels: Channel[]) { const uniqueChannels = new Map(); for (const channel of channels) { diff --git a/desktop/src/features/channels/lib/threadPanelLayout.ts b/desktop/src/features/channels/lib/threadPanelLayout.ts index d07aec0f35..3d441bdaf6 100644 --- a/desktop/src/features/channels/lib/threadPanelLayout.ts +++ b/desktop/src/features/channels/lib/threadPanelLayout.ts @@ -3,11 +3,22 @@ import type * as React from "react"; import { THREAD_FOCUS_COLUMN_MAX_WIDTH_PX } from "@/features/channels/lib/threadFocusLayout"; export type ThreadPanelLayoutProps = { + canResetWidth?: boolean; columnMaxWidthPx?: number; + enterMotion?: boolean; headerLeading?: React.ReactNode; + /** Replaces the default "Thread" label. Channel threads leave this unset. */ + headerTitle?: string; + headerTitleAriaLabel?: string; isFocusMode: boolean; isSinglePanelView?: boolean; layout?: "standalone" | "split"; + showBackButton?: boolean; + onHeaderTitleClick?: () => void; + onResetWidth?: () => void; + onResizeStart?: React.PointerEventHandler; + splitPaneClamp?: boolean; + testId?: string; transparentChrome?: boolean; }; diff --git a/desktop/src/features/channels/observedUnreadNative.test.mjs b/desktop/src/features/channels/observedUnreadNative.test.mjs new file mode 100644 index 0000000000..fd58a3316f --- /dev/null +++ b/desktop/src/features/channels/observedUnreadNative.test.mjs @@ -0,0 +1,912 @@ +/** + * Native-mode tests for the observed-unread store. + * + * Every other suite in this directory runs with no `window.__TAURI_INTERNALS__`, + * so `invokeTauri` throws and the hook takes the localStorage fallback. That + * makes the whole native protocol untested — the first test here fails if the + * native path is not entered, so the rest cannot silently become tautologies. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + installDOMShim, + installFreshStorage, + makeObservedEvent, + mountHook, + mountUnreadChannels, +} from "./observedUnreadTestHarness.mjs"; +import { + installNativeRig, + makeStubRelayClient, +} from "./observedUnreadNativeRig.mjs"; + +installDOMShim(); +installFreshStorage(); + +import { act } from "react"; +import { + readObservedUnreadFromStorage, + writeObservedUnreadToStorage, +} from "./observedUnreadStorage.ts"; + +const RELAY = "wss://relay.example.com"; +const NOW_S = Math.floor(Date.now() / 1_000); + +const DEFAULT_PROPS = { + relay: RELAY, + isReady: true, + readStateVersion: 0, + getTs: () => null, + getOwn: () => null, +}; + +function makeRefs() { + return { + eventsRef: { current: new Map() }, + latestRef: { current: new Map() }, + }; +} + +/** Let the hook's promise chain settle (open/ingest are async). */ +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +test("marker advances queued before native readiness are ingested after open", async () => { + installFreshStorage(); + let harness; + let releaseOpen; + const openGate = new Promise((resolve) => { + releaseOpen = resolve; + }); + const rig = installNativeRig(); + const invoke = globalThis.window.__TAURI_INTERNALS__.invoke; + globalThis.window.__TAURI_INTERNALS__.invoke = async (command, args) => { + if (command === "observed_unread_open_scope") await openGate; + return invoke(command, args); + }; + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: "pk-delayed-open-marker", + getTs: () => NOW_S, + }, + makeRefs(), + ); + + harness.api.syncMarkers(["channel-before-ready"]); + assert.equal( + rig.requests("observed_unread_ingest").length, + 0, + "the marker cannot be ingested before the native scope opens", + ); + + releaseOpen(); + await settle(); + await settle(); + + assert.deepEqual(rig.markerUpdates(), [ + { contextId: "channel-before-ready", readAt: NOW_S }, + ]); + } finally { + releaseOpen?.(); + await harness?.unmount(); + rig.restore(); + } +}); + +test("native no-op marker delta does not notify the renderer", async () => { + installFreshStorage(); + let harness; + let notifications = 0; + const rig = installNativeRig(); + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: "pk-no-op-marker", + getTs: () => NOW_S, + onPruned: () => { + notifications += 1; + }, + }, + makeRefs(), + ); + await settle(); + assert.equal(notifications, 1, "opening the native snapshot notifies once"); + + harness.api.syncMarkers(["channel-empty"]); + await settle(); + + assert.equal( + rig.requests("observed_unread_ingest").length, + 1, + "the marker must still advance the native revision and ack sequence", + ); + assert.equal( + notifications, + 1, + "an empty projection delta must not trigger a renderer feedback render", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("native snapshotRequired response still reopens and notifies", async () => { + installFreshStorage(); + let harness; + let notifications = 0; + const scope = { pubkey: "pk-snapshot-required", relayUrl: RELAY }; + const rig = installNativeRig(); + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: scope.pubkey, + getTs: () => NOW_S, + onPruned: () => { + notifications += 1; + }, + }, + makeRefs(), + ); + await settle(); + rig.scope(scope).lastSequence = -1; + + harness.api.syncMarkers(["channel-gap"]); + await settle(); + await settle(); + + assert.equal( + rig.requests("observed_unread_open_scope").length, + 2, + "a sequence gap must reopen the scope even when it carries no projection rows", + ); + assert.equal( + notifications, + 2, + "the replacement snapshot must still notify the renderer", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +// ── Entry: the boundary that makes every other test meaningful ──────────────── + +test("native mode is ENTERED: the hook opens the scope over the bridge", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig(); + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: "pk-entry" }, + makeRefs(), + ); + await settle(); + + assert.equal( + rig.requests("observed_unread_open_scope").length, + 1, + "the hook must call observed_unread_open_scope — if this fails, the suite is measuring the localStorage fallback and every assertion below is vacuous", + ); + assert.equal( + harness.api.isNative(), + true, + "isNative() must be true after a successful open", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("native mode is NOT entered when the bridge fails, and the hook says so", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig({ + failCommands: new Set(["observed_unread_open_scope"]), + }); + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: "pk-entry-fail" }, + makeRefs(), + ); + await settle(); + + assert.equal( + harness.api.isNative(), + false, + "a failed open must leave the hook on the declared fallback path", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("late scope-A flush rejection falls back under A without mutating native scope B", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig(); + const invoke = globalThis.window.__TAURI_INTERNALS__.invoke; + const scopeA = { pubkey: "pk-late-flush-a", relayUrl: RELAY }; + const scopeB = { pubkey: "pk-late-flush-b", relayUrl: RELAY }; + let rejectA; + const delayedA = new Promise((_, reject) => { + rejectA = reject; + }); + globalThis.window.__TAURI_INTERNALS__.invoke = (command, args = {}) => { + if ( + command === "observed_unread_ingest" && + args.request?.scope.pubkey === scopeA.pubkey + ) { + return delayedA; + } + return invoke(command, args); + }; + + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: scopeA.pubkey }, + makeRefs(), + ); + await settle(); + harness.api.schedule( + harness.api.currentScope, + "channel-a", + makeObservedEvent({ id: "event-a", createdAt: NOW_S }), + ); + await act(async () => { + globalThis.dispatchEvent({ type: "pagehide" }); + await Promise.resolve(); + }); + + writeObservedUnreadToStorage( + scopeB.pubkey, + scopeB.relayUrl, + new Map([ + [ + "channel-b", + new Map([ + [ + "event-b", + makeObservedEvent({ id: "event-b", createdAt: NOW_S + 1 }), + ], + ]), + ], + ]), + ); + await harness.render({ ...DEFAULT_PROPS, pubkey: scopeB.pubkey }); + await settle(); + assert.equal(harness.api.isNative(), true, "scope B must open natively"); + assert.ok( + harness.api.projectionsRef.current.has("channel-b"), + "scope B's native projection must be installed", + ); + + const sentinelB = new Map([ + [ + "storage-b", + new Map([ + [ + "storage-event-b", + makeObservedEvent({ + id: "storage-event-b", + createdAt: NOW_S + 2, + }), + ], + ]), + ], + ]); + writeObservedUnreadToStorage(scopeB.pubkey, scopeB.relayUrl, sentinelB); + const projectionsB = new Map(harness.api.projectionsRef.current); + const storedB = readObservedUnreadFromStorage( + scopeB.pubkey, + scopeB.relayUrl, + ); + + await act(async () => { + rejectA(new Error("scope A flush failed after B opened")); + await delayedA.catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.deepEqual( + harness.api.projectionsRef.current, + projectionsB, + "scope A's rejection must not alter scope B's projections", + ); + assert.equal( + harness.api.isNative(), + true, + "scope A's rejection must not disable scope B's native store", + ); + assert.deepEqual( + readObservedUnreadFromStorage(scopeB.pubkey, scopeB.relayUrl), + storedB, + "scope A's rejection must not write into scope B's storage", + ); + assert.ok( + readObservedUnreadFromStorage(scopeA.pubkey, scopeA.relayUrl) + ?.get("channel-a") + ?.has("event-a"), + "scope A's unacked event must be preserved under A's storage key", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("native ingest rejection retries the originally rejected marker", async () => { + installFreshStorage(); + let harness; + const failOnceCommands = new Set(["observed_unread_ingest"]); + const rig = installNativeRig({ failOnceCommands }); + const scope = { pubkey: "pk-ingest-marker-retry", relayUrl: RELAY }; + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: scope.pubkey, + getTs: () => NOW_S, + }, + makeRefs(), + ); + await settle(); + + harness.api.syncMarkers(["channel-failed"]); + await settle(); + await settle(); + + assert.equal( + rig.scope(scope).markers.get("channel-failed"), + NOW_S, + "the marker rejected on its first attempt must survive the reopen and retry", + ); + assert.equal( + rig.requests("observed_unread_open_scope").length, + 2, + "recovery must refresh the native sequence and revision before retrying", + ); + assert.equal( + harness.api.isNative(), + true, + "a successful retry must keep native persistence healthy", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("native ingest rejection retries the originally rejected destructive clear", async () => { + installFreshStorage(); + let harness; + const failOnceCommands = new Set(); + const rig = installNativeRig({ failOnceCommands }); + const scope = { pubkey: "pk-ingest-clear-retry", relayUrl: RELAY }; + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: scope.pubkey }, + makeRefs(), + ); + await settle(); + + const store = rig.scope(scope); + store.events.set("evt-clear-retry", { + channelId: "channel-clear-retry", + id: "evt-clear-retry", + createdAt: NOW_S, + rootId: null, + highPriority: false, + countsTowardBadge: true, + countsTowardAppBadge: true, + }); + store.channelLatest.set("channel-clear-retry", NOW_S); + failOnceCommands.add("observed_unread_ingest"); + + harness.api.removeChannel("channel-clear-retry"); + await settle(); + await settle(); + + assert.equal( + store.events.has("evt-clear-retry"), + false, + "the removeChannel rejected on its first attempt must still delete events", + ); + assert.equal( + store.channelLatest.has("channel-clear-retry"), + false, + "the retried clear must also remove the channel latest anchor", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("native ingest rejection retries the originally rejected membership delta", async () => { + installFreshStorage(); + let harness; + const failOnceCommands = new Set(["observed_unread_ingest"]); + const rig = installNativeRig({ failOnceCommands }); + const scope = { pubkey: "pk-ingest-membership-retry", relayUrl: RELAY }; + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: scope.pubkey }, + makeRefs(), + ); + await settle(); + + harness.api.updateMembership("followed", "root-retry", true); + await settle(); + await settle(); + + assert.ok( + rig.scope(scope).membership.has("followed\u0000root-retry"), + "the membership delta rejected on its first attempt must survive the retry", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("native retry and reopen rejection applies the mutation to fallback state", async () => { + installFreshStorage(); + let harness; + const failCommands = new Set(); + const rig = installNativeRig({ failCommands }); + const refs = makeRefs(); + refs.eventsRef.current.set( + "channel-failed", + new Map([ + [ + "evt-fallback", + makeObservedEvent({ id: "evt-fallback", createdAt: NOW_S }), + ], + ]), + ); + refs.latestRef.current.set("channel-failed", NOW_S); + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: "pk-ingest-reopen-failure", + getTs: () => NOW_S, + }, + refs, + ); + await settle(); + failCommands.add("observed_unread_ingest"); + failCommands.add("observed_unread_open_scope"); + + harness.api.syncMarkers(["channel-failed"]); + await settle(); + await settle(); + + assert.equal( + harness.api.isNative(), + false, + "if neither retry nor authoritative reopen succeeds, isNative must declare the path unhealthy", + ); + assert.equal( + refs.eventsRef.current.has("channel-failed"), + false, + "the rejected marker must still prune equivalent JS fallback state", + ); + assert.equal( + refs.latestRef.current.has("channel-failed"), + false, + "fallback latest state must stay consistent with the applied marker", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +// ── D1: local mark-read must reach the native store ────────────────────────── + +test("D1: local markChannelRead sends a read marker to the native store", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig(); + try { + const PUBKEY = "pk-d1"; + const CHANNEL = "channel-d1"; + harness = await mountUnreadChannels({ + pubkey: PUBKEY, + relay: RELAY, + channels: [{ id: CHANNEL, name: "d1", channelType: "stream" }], + relayClient: makeStubRelayClient(), + }); + await settle(); + + const readAt = new Date(NOW_S * 1_000).toISOString(); + await act(async () => { + harness.markChannelRead(CHANNEL, readAt); + }); + await settle(); + + const markers = rig.markerUpdates(); + assert.ok( + markers.some((marker) => marker.contextId === CHANNEL), + `local mark-read must reach observed_unread_ingest as a marker for ${CHANNEL}; saw ${JSON.stringify(markers)}`, + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("D1 control: the rig DOES record markers when syncMarkers is called directly", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig(); + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: "pk-d1-control", + getTs: () => NOW_S, + }, + makeRefs(), + ); + await settle(); + + harness.api.syncMarkers(["channel-control"]); + await settle(); + + assert.deepEqual( + rig.markerUpdates(), + [{ contextId: "channel-control", readAt: NOW_S }], + "positive control: the marker path is observable through the rig, so a zero-marker result above means the code did not send one", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +// ── D2: maxTrigger must survive in native mode ─────────────────────────────── + +test("D2: a catch-up maxTrigger with no notifying event still advances native latest", async () => { + installFreshStorage(); + const CHANNEL = "channel-d2"; + const MAX_TRIGGER = NOW_S - 10; + let harness; + const rig = installNativeRig({ + catchUpChannels: (request) => + request.channels.map((channel) => ({ + status: "success", + channelId: channel.id, + // The regression case: a trigger newer than the read marker that does + // NOT survive the notify filter, so it produces no observed event. + observedEvents: [], + maxTrigger: MAX_TRIGGER, + activityRows: [], + discovered: { participated: [], authored: [], mentioned: [] }, + })), + }); + try { + harness = await mountUnreadChannels({ + pubkey: "pk-d2", + relay: RELAY, + channels: [{ id: CHANNEL, name: "d2", channelType: "stream" }], + relayClient: makeStubRelayClient(), + }); + await settle(); + await settle(); + + assert.equal( + rig.requests("unread_catch_up").length >= 1, + true, + "catch-up must have run for this assertion to mean anything", + ); + assert.equal( + rig + .scope({ pubkey: "pk-d2", relayUrl: RELAY }) + .channelLatest.get(CHANNEL), + MAX_TRIGGER, + `maxTrigger ${MAX_TRIGGER} must survive as the channel latest anchor even when no observed row is returned`, + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +// ── D3: an empty seed must not wipe accumulated native membership ──────────── + +test("D3: reopening with an empty membership seed preserves discovered membership", async () => { + installFreshStorage(); + let first; + let second; + const rig = installNativeRig(); + const scope = { pubkey: "pk-d3", relayUrl: RELAY }; + const emptySeed = { + participatedRootIds: [], + authoredRootIds: [], + mentionedRootIds: [], + followedRootIds: [], + mutedRootIds: [], + mutedChannelIds: [], + }; + try { + first = await mountHook( + { ...DEFAULT_PROPS, pubkey: scope.pubkey, membershipSeed: emptySeed }, + makeRefs(), + ); + await settle(); + + // Catch-up discovery writes membership incrementally, as the commit + // message's ownership story describes. + first.api.updateMembership("participated", "root-discovered", true); + await settle(); + assert.ok( + rig.scope(scope).membership.has("participated\u0000root-discovered"), + "precondition: discovery must have written membership natively", + ); + await first.unmount(); + first = null; + + // Restart with an empty renderer seed (localStorage cleared / read failed). + second = await mountHook( + { ...DEFAULT_PROPS, pubkey: scope.pubkey, membershipSeed: emptySeed }, + makeRefs(), + ); + await settle(); + + assert.ok( + rig.scope(scope).membership.has("participated\u0000root-discovered"), + "an empty renderer seed must not delete membership the native store accumulated", + ); + } finally { + await first?.unmount(); + await second?.unmount(); + rig.restore(); + } +}); + +// ── Matrix rows that only became reachable once native mode was enterable ───── + +test("matrix: a replayed sequence is a no-op, not a second mutation", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig(); + const scope = { pubkey: "pk-replay", relayUrl: RELAY }; + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: scope.pubkey }, + makeRefs(), + ); + await settle(); + + harness.api.updateMembership("followed", "root-1", true); + await settle(); + const afterFirst = rig.scope(scope).revision; + + const replay = rig.requests("observed_unread_ingest").at(-1); + const response = await globalThis.window.__TAURI_INTERNALS__.invoke( + "observed_unread_ingest", + { request: replay }, + ); + + assert.equal( + response.kind, + "snapshot", + "replay must return a snapshot, not a delta", + ); + assert.equal( + rig.scope(scope).revision, + afterFirst, + "replaying an acked sequence must not advance the revision", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("matrix: a sequence gap is rejected with snapshotRequired", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig(); + const scope = { pubkey: "pk-gap", relayUrl: RELAY }; + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: scope.pubkey }, + makeRefs(), + ); + await settle(); + + const current = rig.scope(scope); + const response = await globalThis.window.__TAURI_INTERNALS__.invoke( + "observed_unread_ingest", + { + request: { + scope, + sequence: current.lastSequence + 2, + baseRevision: current.revision, + events: [], + markers: [], + membership: [], + clearChannels: [], + clearAll: false, + }, + }, + ); + + assert.equal(response.kind, "snapshotRequired"); + assert.equal( + rig.scope(scope).lastSequence, + current.lastSequence, + "a gap must not advance the ack", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("matrix: an ingested event reaches the projection the badge reads", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig(); + const scope = { pubkey: "pk-project", relayUrl: RELAY }; + try { + const refs = makeRefs(); + harness = await mountHook({ ...DEFAULT_PROPS, pubkey: scope.pubkey }, refs); + await settle(); + + harness.api.schedule( + harness.api.currentScope, + "channel-p", + makeObservedEvent({ id: "evt-p", createdAt: NOW_S }), + ); + harness.flushNative?.(); + await act(async () => { + globalThis.dispatchEvent( + new (class extends Event { + constructor() { + super("pagehide"); + } + })(), + ); + }); + await settle(); + + assert.equal( + harness.api.projectionsRef.current.get("channel-p")?.count, + 1, + "the native projection must carry the ingested event", + ); + assert.equal(harness.api.latestForChannel("channel-p"), NOW_S); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("matrix: a rebuilt store generation is reopened instead of wedging on the old revision", async () => { + installFreshStorage(); + let harness; + const rig = installNativeRig({ + newGeneration: (() => { + let generation = 0; + return () => `gen-${++generation}`; + })(), + }); + const scope = { pubkey: "pk-epoch", relayUrl: RELAY }; + try { + harness = await mountHook( + { ...DEFAULT_PROPS, pubkey: scope.pubkey }, + makeRefs(), + ); + await settle(); + harness.api.updateMembership("followed", "before-rebuild", true); + await settle(); + assert.equal( + rig.scope(scope).revision, + 1, + "precondition: renderer holds revision 1", + ); + + const rebuilt = rig.rebuildScope(scope); + harness.api.updateMembership("followed", "after-rebuild", true); + await settle(); + await settle(); + + assert.equal(rebuilt.generation, "gen-2"); + assert.ok( + rig.requests("observed_unread_open_scope").length >= 2, + "generation mismatch must reopen for a replacement snapshot", + ); + assert.equal(harness.api.isNative(), true); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +// ── The badge lane below the projection ────────────────────────────────────── +// +// `matrix: an ingested event reaches the projection the badge reads` asserts +// projectionsRef — the map. It never reads the `rawUnread` memo that turns a +// projection into unreadChannelIds / unreadChannelCounts, so the whole native +// badge lane had no witness: forcing `nativeProjection?.count ?? 0` to a +// constant 0 left the full 4,919-test suite green. This closes that. + +test("native: an ingested event reaches the hook's unread counts, not just the projection", async () => { + installFreshStorage(); + const CHANNEL = "channel-badge"; + const scope = { pubkey: "pk-badge", relayUrl: RELAY }; + let first; + let second; + const rig = installNativeRig(); + try { + // First mount creates the native scope. + first = await mountUnreadChannels({ + pubkey: scope.pubkey, + relay: RELAY, + channels: [{ id: CHANNEL, channelType: "channel" }], + relayClient: makeStubRelayClient(), + }); + await settle(); + const store = rig.scope(scope); + assert.ok(store, "precondition: native mode must be entered"); + await first.unmount(); + first = null; + + // A notifying event exists natively when the renderer reopens. + store.events.set("evt-badge", { + id: "evt-badge", + channelId: CHANNEL, + createdAt: NOW_S, + rootId: null, + highPriority: false, + countsTowardBadge: true, + countsTowardAppBadge: true, + }); + assert.equal( + store.projections().find((p) => p.channelId === CHANNEL)?.count, + 1, + "precondition: the native projection itself must count the event", + ); + + // Reopen: the open snapshot carries the projection into the renderer. + second = await mountUnreadChannels({ + pubkey: scope.pubkey, + relay: RELAY, + channels: [{ id: CHANNEL, channelType: "channel" }], + relayClient: makeStubRelayClient(), + }); + await settle(); + await settle(); + + assert.equal( + second.result.unreadChannelCounts.get(CHANNEL), + 1, + "the native badgeCount must reach unreadChannelCounts; asserting projectionsRef alone leaves this lane untested", + ); + assert.ok( + second.result.unreadChannelIds.has(CHANNEL), + "the channel must appear in unreadChannelIds", + ); + } finally { + await first?.unmount(); + await second?.unmount(); + rig.restore(); + } +}); diff --git a/desktop/src/features/channels/observedUnreadNativeRig.mjs b/desktop/src/features/channels/observedUnreadNativeRig.mjs new file mode 100644 index 0000000000..aac72768b0 --- /dev/null +++ b/desktop/src/features/channels/observedUnreadNativeRig.mjs @@ -0,0 +1,381 @@ +/** + * Fake native observed-unread store for tests. + * + * `invokeTauri` calls `window.__TAURI_INTERNALS__.invoke`, which does not exist + * under the Node test shim — so without this rig every mount silently takes the + * localStorage fallback and no test can enter the native path. Install this + * BEFORE mounting to make `observedPersistence.isNative()` true. + * + * The model mirrors `desktop/src-tauri/src/observed_unread.rs` closely enough to + * assert the protocol: one scope row (generation/revision/last_sequence/ + * migration_complete/membership_seeded), observed events, read markers, + * membership, deterministic pruning, and the same projection fold. Keep the two + * in step — a divergence here is a test that certifies the wrong contract. + * + * Exported from a non-test file so the `src/**\/*.test.mjs` glob never picks it + * up as a suite. + */ + +const HORIZON_SECONDS = 7 * 24 * 60 * 60; +const PER_CHANNEL_CAP = 1_000; +const GLOBAL_CAP = 5_000; + +const SEED_KINDS = [ + ["participated", "participatedRootIds"], + ["authored", "authoredRootIds"], + ["mentioned", "mentionedRootIds"], + ["followed", "followedRootIds"], + ["muted_root", "mutedRootIds"], + ["muted_channel", "mutedChannelIds"], +]; + +/** Mirror of `ObservedUnreadScope::key` in observed_unread.rs. */ +function scopeKey(scope) { + return `${scope.pubkey.trim().toLowerCase()}:${scope.relayUrl + .trim() + .replace(/\/+$/, "")}`; +} + +function validLegacyEvent(value, channelId) { + if (typeof value !== "object" || value === null) return null; + const { id, createdAt, rootId, highPriority } = value; + if (typeof id !== "string" || typeof createdAt !== "number") return null; + if (typeof highPriority !== "boolean") return null; + if (typeof value.countsTowardBadge !== "boolean") return null; + if (typeof value.countsTowardAppBadge !== "boolean") return null; + if (rootId !== null && rootId !== undefined && typeof rootId !== "string") + return null; + return { + channelId, + id, + createdAt, + rootId: rootId ?? null, + highPriority, + countsTowardBadge: value.countsTowardBadge, + countsTowardAppBadge: value.countsTowardAppBadge, + }; +} + +class Scope { + constructor(generation) { + this.generation = generation; + this.revision = 0; + this.lastSequence = 0; + this.migrationComplete = false; + this.membershipSeeded = false; + /** Map — `ON CONFLICT(scope,event_id) DO NOTHING`. */ + this.events = new Map(); + /** Map. */ + this.channelLatest = new Map(); + /** Map. */ + this.markers = new Map(); + /** Set<`${kind}\u0000${value}`>. */ + this.membership = new Set(); + } + + prune(nowSeconds) { + const cutoff = nowSeconds - HORIZON_SECONDS; + for (const [id, event] of this.events) { + if (event.createdAt <= cutoff) this.events.delete(id); + } + // Deterministic order, matching `ORDER BY created_at DESC, event_id DESC`. + const newestFirst = (a, b) => + b.createdAt - a.createdAt || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0); + const byChannel = new Map(); + for (const event of this.events.values()) { + const bucket = byChannel.get(event.channelId) ?? []; + bucket.push(event); + byChannel.set(event.channelId, bucket); + } + for (const bucket of byChannel.values()) { + for (const event of bucket.sort(newestFirst).slice(PER_CHANNEL_CAP)) { + this.events.delete(event.id); + } + } + for (const event of [...this.events.values()] + .sort(newestFirst) + .slice(GLOBAL_CAP)) { + this.events.delete(event.id); + } + } + + /** Mirror of `projections()`. */ + projections() { + const marker = (key) => this.markers.get(key) ?? 0; + const byChannel = new Map( + [...this.channelLatest].map(([channelId, latest]) => [ + channelId, + { + channelId, + latest, + count: 0, + badgeCount: 0, + appBadgeCount: 0, + topLevelUnread: false, + highPriorityUnread: false, + }, + ]), + ); + for (const event of this.events.values()) { + let readAt = Math.max(marker(event.channelId), marker(`msg:${event.id}`)); + if (event.rootId) + readAt = Math.max(readAt, marker(`thread:${event.rootId}`)); + if (event.createdAt <= readAt) continue; + const entry = byChannel.get(event.channelId) ?? { + channelId: event.channelId, + latest: 0, + count: 0, + badgeCount: 0, + appBadgeCount: 0, + topLevelUnread: false, + highPriorityUnread: false, + }; + entry.latest = Math.max(entry.latest, event.createdAt); + entry.count += 1; + entry.badgeCount += event.countsTowardBadge ? 1 : 0; + entry.appBadgeCount += event.countsTowardAppBadge ? 1 : 0; + entry.topLevelUnread ||= !event.rootId; + entry.highPriorityUnread ||= event.highPriority; + byChannel.set(event.channelId, entry); + } + return [...byChannel.values()].sort((a, b) => + a.channelId < b.channelId ? -1 : a.channelId > b.channelId ? 1 : 0, + ); + } +} + +/** + * Install the fake native bridge on `window.__TAURI_INTERNALS__`. + * + * Returns a handle for asserting against the store and the recorded IPC calls. + * Call `restore()` in a finally block (or let the next install replace it). + */ +export function installNativeRig(options = {}) { + const { + now = () => Math.floor(Date.now() / 1_000), + newGeneration = () => `gen-${Math.random().toString(16).slice(2)}`, + catchUpChannels = () => [], + failCommands = new Set(), + failOnceCommands = new Set(), + } = options; + + const scopes = new Map(); + const calls = []; + const previous = globalThis.window?.__TAURI_INTERNALS__; + + const ensureScope = (key) => { + let scope = scopes.get(key); + if (!scope) { + scope = new Scope(newGeneration()); + scopes.set(key, scope); + } + return scope; + }; + + const snapshot = (scope, request) => ({ + kind: "snapshot", + scope: request.scope, + generation: scope.generation, + revision: scope.revision, + lastAckedSequence: scope.lastSequence, + migrationComplete: scope.migrationComplete, + membershipSeeded: scope.membershipSeeded, + channels: scope.projections(), + }); + + const openScope = (request) => { + const scope = ensureScope(scopeKey(request.scope)); + if (!scope.migrationComplete) { + const channels = request.legacyPayload?.eventsByChannel; + if (channels && typeof channels === "object") { + for (const [channelId, events] of Object.entries(channels)) { + if (!Array.isArray(events)) continue; + for (const value of events) { + const event = validLegacyEvent(value, channelId); + if (event && !scope.events.has(event.id)) + scope.events.set(event.id, event); + } + } + } + scope.migrationComplete = true; + } + if (!scope.membershipSeeded && request.membershipSeed) { + // The renderer seed establishes initial ownership once; subsequent opens + // preserve membership accumulated by native catch-up discovery. + scope.membership.clear(); + for (const [kind, field] of SEED_KINDS) { + for (const value of request.membershipSeed[field] ?? []) { + scope.membership.add(`${kind}\u0000${value}`); + } + } + scope.membershipSeeded = true; + } + scope.prune(now()); + return snapshot(scope, request); + }; + + const ingest = (request) => { + const scope = ensureScope(scopeKey(request.scope)); + if (request.sequence <= scope.lastSequence) { + return { + ...snapshot(scope, request), + migrationComplete: true, + membershipSeeded: true, + }; + } + if ( + request.sequence !== scope.lastSequence + 1 || + request.baseRevision !== scope.revision + ) { + return { + kind: "snapshotRequired", + scope: request.scope, + generation: scope.generation, + revision: scope.revision, + lastAckedSequence: scope.lastSequence, + }; + } + const before = new Map( + scope.projections().map((item) => [item.channelId, item]), + ); + if (request.clearAll) { + scope.events.clear(); + scope.channelLatest.clear(); + } + for (const channelId of request.clearChannels) { + scope.channelLatest.delete(channelId); + for (const [id, event] of scope.events) { + if (event.channelId === channelId) scope.events.delete(id); + } + } + for (const latest of request.channelLatest ?? []) { + scope.channelLatest.set( + latest.channelId, + Math.max( + scope.channelLatest.get(latest.channelId) ?? 0, + latest.createdAt, + ), + ); + } + for (const event of request.events) { + if (!scope.events.has(event.id)) { + scope.events.set(event.id, { ...event, rootId: event.rootId ?? null }); + } + } + for (const update of request.membership) { + const key = `${update.kind}\u0000${update.value}`; + if (update.present) scope.membership.add(key); + else scope.membership.delete(key); + } + for (const update of request.markers) { + if (update.readAt === null || update.readAt === undefined) { + scope.markers.delete(update.contextId); + } else { + scope.markers.set( + update.contextId, + Math.max(scope.markers.get(update.contextId) ?? 0, update.readAt), + ); + } + } + scope.prune(now()); + const after = scope.projections(); + const afterIds = new Set(after.map((item) => item.channelId)); + const baseRevision = scope.revision; + scope.revision += 1; + scope.lastSequence = request.sequence; + return { + kind: "delta", + scope: request.scope, + generation: scope.generation, + baseRevision, + revision: scope.revision, + ackedSequence: request.sequence, + upserts: after.filter( + (item) => + JSON.stringify(before.get(item.channelId)) !== JSON.stringify(item), + ), + removed: [...before.keys()].filter((id) => !afterIds.has(id)), + }; + }; + + const handlers = { + observed_unread_open_scope: (args) => openScope(args.request), + observed_unread_ingest: (args) => ingest(args.request), + unread_catch_up: (args) => ({ + channels: catchUpChannels(args.request), + }), + // ReadStateManager reaches the bridge for signing/encryption. Serve inert + // values so a real manager can initialize without a Tauri host. + sign_event: (args) => + JSON.stringify({ + id: `signed-${calls.length}`, + pubkey: "rig-pubkey", + created_at: args.createdAt ?? now(), + kind: args.kind, + tags: args.tags, + content: args.content, + sig: "rig-sig", + }), + nip44_encrypt_to_self: (args) => args.plaintext, + nip44_decrypt_from_self: (args) => args.ciphertext, + }; + + const invoke = async (command, args = {}) => { + calls.push({ command, args }); + if (failCommands.has(command) || failOnceCommands.delete(command)) { + throw new Error(`rig: ${command} configured to fail`); + } + const handler = handlers[command]; + if (!handler) throw new Error(`rig: unhandled command ${command}`); + return handler(args); + }; + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + globalThis.window.__TAURI_INTERNALS__ = { invoke }; + + return { + calls, + /** Recorded requests for one command, in order. */ + requests: (command) => + calls + .filter((call) => call.command === command) + .map((call) => call.args.request), + /** Every marker update sent to the native store, flattened. */ + markerUpdates: () => + calls + .filter((call) => call.command === "observed_unread_ingest") + .flatMap((call) => call.args.request.markers), + scope: (scope) => scopes.get(scopeKey(scope)), + rebuildScope: (scope) => { + const rebuilt = new Scope(newGeneration()); + rebuilt.migrationComplete = true; + rebuilt.membershipSeeded = true; + scopes.set(scopeKey(scope), rebuilt); + return rebuilt; + }, + restore: () => { + if (previous === undefined) delete globalThis.window.__TAURI_INTERNALS__; + else globalThis.window.__TAURI_INTERNALS__ = previous; + }, + }; +} + +/** + * Minimal RelayClient stand-in so a real ReadStateManager can initialize. + * `useReadState` returns no-op markers unless a relayClient is supplied, and a + * no-op `markContextRead` cannot exercise the local read path at all. + */ +export function makeStubRelayClient() { + return { + fetchEvents: async () => [], + fetchFirstEvent: async () => null, + subscribeLive: async () => async () => {}, + subscribeToReconnects: () => () => {}, + publishEvent: async (event) => event, + }; +} diff --git a/desktop/src/features/channels/observedUnreadTestHarness.mjs b/desktop/src/features/channels/observedUnreadTestHarness.mjs index da3cfa3e95..5ae2a393c4 100644 --- a/desktop/src/features/channels/observedUnreadTestHarness.mjs +++ b/desktop/src/features/channels/observedUnreadTestHarness.mjs @@ -256,6 +256,7 @@ export async function mountHook(props, refs) { getTs, getOwn, onPruned, + membershipSeed, }) { apiRef.current = useObservedUnreadPersistence( pubkey, @@ -266,7 +267,7 @@ export async function mountHook(props, refs) { getOwn, refs.eventsRef, refs.latestRef, - { onPruned: onPruned ?? (() => {}) }, + { onPruned: onPruned ?? (() => {}), membershipSeed }, ); return null; } @@ -311,6 +312,8 @@ export function seedStorage(pubkey, relay, channelId, eventId = "evt-1") { export async function mountUnreadChannels({ pubkey, relay = "wss://relay.example.com", + channels = [], + relayClient, }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -318,13 +321,15 @@ export async function mountUnreadChannels({ let capturedMarkChannelRead = null; let capturedMarkAllChannelsRead = null; + let capturedResult = null; function Inner({ pubkey: pk }) { - const result = useUnreadChannels([], null, { + const result = useUnreadChannels(channels, null, { pubkey: pk, - relayClient: undefined, + relayClient, relayUrl: relay, }); + capturedResult = result; capturedMarkChannelRead = result.markChannelRead; capturedMarkAllChannelsRead = result.markAllChannelsRead; return null; @@ -350,6 +355,9 @@ export async function mountUnreadChannels({ await render(pubkey); return { + get result() { + return capturedResult; + }, get markChannelRead() { return capturedMarkChannelRead; }, diff --git a/desktop/src/features/channels/openChannelDirectory.test.mjs b/desktop/src/features/channels/openChannelDirectory.test.mjs new file mode 100644 index 0000000000..5be6adcc3a --- /dev/null +++ b/desktop/src/features/channels/openChannelDirectory.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeOpenChannelDirectory } from "./openChannelDirectory.ts"; + +function makeChannel(id, name, channelType = "stream") { + return { + id, + name, + channelType, + visibility: channelType === "dm" ? "private" : "open", + description: "", + topic: null, + purpose: null, + memberCount: 0, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + }; +} + +test("mergeOpenChannelDirectory_appendsNonMemberOpenChannels", () => { + const member = makeChannel("general", "General"); + const openOnly = { ...makeChannel("random", "Random"), isMember: false }; + + const merged = mergeOpenChannelDirectory([member], [member, openOnly]); + + assert.deepEqual( + merged.map((channel) => channel.id).sort(), + ["general", "random"], + "no non-member open channel may be silently lost", + ); +}); + +test("mergeOpenChannelDirectory_prefersMemberEntryForSharedId", () => { + // The member list carries optimistic mutations and poll timestamps, so its + // entry must win over the directory's snapshot for a shared channel id. + const memberEntry = { ...makeChannel("general", "General"), memberCount: 9 }; + const directoryEntry = { + ...makeChannel("general", "General"), + memberCount: 1, + isMember: false, + }; + + const merged = mergeOpenChannelDirectory([memberEntry], [directoryEntry]); + + assert.equal(merged.length, 1, "shared id must not duplicate"); + assert.strictEqual( + merged[0], + memberEntry, + "the member entry must win for a shared id", + ); +}); + +test("mergeOpenChannelDirectory_returnsMemberListWhenDirectoryAbsent", () => { + const memberList = [makeChannel("general", "General")]; + + assert.strictEqual( + mergeOpenChannelDirectory(memberList, undefined), + memberList, + "an un-fetched directory must return the member list untouched", + ); + assert.strictEqual( + mergeOpenChannelDirectory(memberList, []), + memberList, + "an empty directory must return the member list untouched", + ); +}); diff --git a/desktop/src/features/channels/openChannelDirectory.ts b/desktop/src/features/channels/openChannelDirectory.ts new file mode 100644 index 0000000000..0e0ed56214 --- /dev/null +++ b/desktop/src/features/channels/openChannelDirectory.ts @@ -0,0 +1,282 @@ +import * as React from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; + +import { getChannelDetails, getOpenChannelDirectory } from "@/shared/api/tauri"; +import type { Channel, ChannelDetail } from "@/shared/api/types"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { + useStableArrayShallow, + useStableMap, +} from "@/shared/hooks/useStableReference"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + canFetchChannelsForIdentity, + channelsQueryKey, + sortChannels, + useChannelsQuery, +} from "@/features/channels/hooks"; + +/** + * Discovery superset: every joinable open channel plus this identity's own + * channels. Distinct from {@link channelsQueryKey} (member-only) so the browser + * and search can hold the wider list without it entering the 60s poll cache. + * Nested under {@link channelsQueryKey}, so channel mutations that invalidate + * the member list (join, leave, archive) also refresh a mounted directory. + */ +export const openChannelDirectoryQueryKey = [ + ...channelsQueryKey, + "open-directory", +] as const; + +/** Suppresses redundant directory scans while a browse/search session is open. */ +export const OPEN_CHANNEL_DIRECTORY_STALE_TIME_MS = 5 * 60_000; + +/** + * Reconstructs the pre-split merged shape: the member list (authoritative for + * shared ids, since it carries optimistic mutations and poll timestamps) plus + * every open channel the member list omits. Callers feed this to the discovery + * surfaces so no non-member open channel is silently lost when the directory is + * fetched separately from the 60s poll. Exported for regression coverage. + */ +export function mergeOpenChannelDirectory( + memberChannels: Channel[], + directoryChannels: Channel[] | undefined, +): Channel[] { + if (!directoryChannels || directoryChannels.length === 0) { + return memberChannels; + } + const memberIds = new Set(memberChannels.map((channel) => channel.id)); + const directoryOnly = directoryChannels.filter( + (channel) => !memberIds.has(channel.id), + ); + return directoryOnly.length === 0 + ? memberChannels + : sortChannels([...memberChannels, ...directoryOnly]); +} + +/** + * Fetches the open-channel directory on demand — the discovery superset that + * `useChannelsQuery` intentionally omits from the 60s poll. Callers pass + * `enabled` so the unbounded all-open relay scan runs only while the channel + * browser is open or a global search is active. + * + * When no consumer is mounted, a mutation's invalidation only marks the shared + * key stale, deferring the scan until it is next needed. + */ +export function useOpenChannelDirectoryQuery(options?: { enabled?: boolean }) { + const { activeCommunity } = useCommunities(); + const relayUrl = activeCommunity?.relayUrl ?? null; + const identityQuery = useIdentityQuery(); + const ownerPubkey = identityQuery.data?.pubkey ?? null; + + return useQuery({ + enabled: + (options?.enabled ?? true) && + relayUrl !== null && + canFetchChannelsForIdentity(ownerPubkey, identityQuery.isError), + queryKey: openChannelDirectoryQueryKey, + queryFn: async () => sortChannels(await getOpenChannelDirectory()), + staleTime: OPEN_CHANNEL_DIRECTORY_STALE_TIME_MS, + }); +} + +/** + * Observes the open-channel directory cache without ever triggering the + * all-open scan (`enabled: false`). Returns the directory only when a + * discovery surface (browser, global search, route preview) has already + * fetched it this session; otherwise `undefined`. This is the "warm cache + * only" seam: reference resolution reads a directory populated by active + * discovery but never initiates it while composing or rendering messages. + */ +export function useWarmOpenChannelDirectory(): Channel[] | undefined { + return useQuery({ + enabled: false, + queryKey: openChannelDirectoryQueryKey, + queryFn: async () => sortChannels(await getOpenChannelDirectory()), + staleTime: OPEN_CHANNEL_DIRECTORY_STALE_TIME_MS, + }).data; +} + +/** + * The channels resolvable without any network fetch: the member list unioned + * with a warm open-channel directory. Multi-id and name-bearing consumers use + * this — a non-member open channel resolves once the reader has browsed or + * searched channels this session, and stays inert (safe) on a cold cache, + * which is the ruled product boundary for name references. + */ +export function useChannelSources(options?: { enabled?: boolean }): { + memberChannels: Channel[]; + warmDirectory: Channel[] | undefined; + isReady: boolean; +} { + const channelsQuery = useChannelsQuery(options); + return { + memberChannels: channelsQuery.data ?? [], + warmDirectory: useWarmOpenChannelDirectory(), + isReady: channelsQuery.isSuccess, + }; +} + +export function useResolvedChannelDirectory(options?: { enabled?: boolean }): { + channels: Channel[]; + isReady: boolean; +} { + const { memberChannels, warmDirectory, isReady } = useChannelSources(options); + const channels = React.useMemo( + () => mergeOpenChannelDirectory(memberChannels, warmDirectory), + [memberChannels, warmDirectory], + ); + return { channels, isReady }; +} + +/** Holds a resolved reference (or a cached miss) across a browse session. */ +export const CHANNEL_REFERENCE_STALE_TIME_MS = 5 * 60_000; + +/** + * Returns a reference-query key nested under {@link channelsQueryKey}, so a + * membership mutation's channel invalidation also drops a cached miss once the + * channel becomes visible. Exported for mounted-hook regressions that prove + * channel-reference misses never use the all-open directory key. + */ +export function channelReferenceQueryKey(channelId: string) { + return [...channelsQueryKey, "reference", channelId] as const; +} + +/** + * Detail metadata does not establish membership. Only member channels and + * non-member open channels may be navigated to from a resolved reference. + */ +export function isChannelReferenceOpenable( + channel: Channel | undefined, +): channel is Channel { + return ( + channel !== undefined && (channel.isMember || channel.visibility === "open") + ); +} + +/** + * A channel detail event carries no membership tag, so `fromRawChannel` + * defaults `isMember` to true. A reference only reaches the bounded fetch + * when the id is absent from the member list, so it is by definition not a + * member: force `isMember: false` here so `isChannelOpenable` keeps a fetched + * private channel non-openable. + */ +function channelFromFetchedDetail(detail: ChannelDetail): Channel { + return { ...detail, isMember: false }; +} + +/** + * Shared bounded detail query for one unresolved channel id. Both single- and + * multi-reference consumers use this exact key, fetch, and miss-cache policy, + * so concurrent surfaces dedupe in React Query rather than creating parallel + * reference caches. + */ +function channelReferenceQueryOptions({ + channelId, + enabled, +}: { + channelId: string; + enabled: boolean; +}) { + return { + enabled, + queryKey: channelReferenceQueryKey(channelId), + queryFn: async (): Promise => { + try { + return channelFromFetchedDetail(await getChannelDetails(channelId)); + } catch (error) { + if (String(error).includes("channel not found")) { + return null; + } + throw error; + } + }, + retry: false, + staleTime: CHANNEL_REFERENCE_STALE_TIME_MS, + }; +} + +function uniqueChannelIds( + channelIds: readonly (string | null | undefined)[], +): string[] { + return [ + ...new Set( + channelIds.filter((channelId): channelId is string => Boolean(channelId)), + ), + ]; +} + +/** + * Resolves a finite set of channel ids without ever initiating directory + * discovery. Known member/warm-directory entries win immediately; only the + * remaining ids issue bounded `get_channel_details` requests. Per-id query + * keys intentionally match `useChannelReference`, which shares in-flight + * work and five-minute misses across every consumer. + */ +export function useChannelReferences( + channelIds: readonly (string | null | undefined)[], + options?: { enabled?: boolean }, +): { channelsById: ReadonlyMap; isReady: boolean } { + const ids = useStableArrayShallow( + React.useMemo(() => uniqueChannelIds(channelIds), [channelIds]), + ); + const { memberChannels, warmDirectory, isReady } = useChannelSources(options); + const knownById = React.useMemo(() => { + const channelsById = new Map(); + for (const channel of warmDirectory ?? []) { + channelsById.set(channel.id, channel); + } + for (const channel of memberChannels) { + channelsById.set(channel.id, channel); + } + return channelsById; + }, [memberChannels, warmDirectory]); + + const { activeCommunity } = useCommunities(); + const relayUrl = activeCommunity?.relayUrl ?? null; + const identityQuery = useIdentityQuery(); + const ownerPubkey = identityQuery.data?.pubkey ?? null; + const canFetch = + (options?.enabled ?? true) && + isReady && + relayUrl !== null && + canFetchChannelsForIdentity(ownerPubkey, identityQuery.isError); + const fetchQueries = useQueries({ + queries: ids.map((channelId) => + channelReferenceQueryOptions({ + channelId, + enabled: canFetch && !knownById.has(channelId), + }), + ), + }); + const channelsById = React.useMemo(() => { + const resolved = new Map(knownById); + for (let index = 0; index < ids.length; index += 1) { + const channel = fetchQueries[index]?.data; + if (channel) { + resolved.set(ids[index], channel); + } + } + return resolved; + }, [fetchQueries, ids, knownById]); + + return { channelsById: useStableMap(channelsById), isReady }; +} + +/** + * Resolves a single channel id to its metadata (name + visibility) for a + * reference surface — a permalink chip, project origin, repo-access channel. + * Resolution order: the member list, then a warm open directory, then a + * bounded per-id `get_channel_details` fetch after the member list settles + * (one addressable kind:39000 event, no all-open scan). A genuine "not found" + * is cached as a resolved miss so an inaccessible id does not refetch on every + * render; a transient relay error stays unresolved (retryable) rather than + * caching a false miss. + */ +export function useChannelReference( + channelId: string | null | undefined, +): Channel | undefined { + const ids = React.useMemo(() => (channelId ? [channelId] : []), [channelId]); + const { channelsById } = useChannelReferences(ids); + return channelId ? channelsById.get(channelId) : undefined; +} diff --git a/desktop/src/features/channels/openChannelDirectoryResolver.test.mjs b/desktop/src/features/channels/openChannelDirectoryResolver.test.mjs new file mode 100644 index 0000000000..e54873308c --- /dev/null +++ b/desktop/src/features/channels/openChannelDirectoryResolver.test.mjs @@ -0,0 +1,716 @@ +/** + * Mounted contracts for bounded channel-reference resolution. These exercise + * the real React Query hooks and Tauri boundary: a channel reference may fetch + * one detail event, but must never start the all-open directory scan. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +Object.assign(globalThis, { + HTMLElement: dom.window.HTMLElement, + HTMLIFrameElement: dom.window.HTMLIFrameElement, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + document: dom.window.document, + localStorage: dom.window.localStorage, + self: dom.window, + window: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +// The discussion facepile renders UserProfilePopover, which mounts HuddleProvider; +// its audio-device effects touch navigator.mediaDevices, absent in jsdom. +Object.defineProperty(dom.window.navigator, "mediaDevices", { + configurable: true, + value: { + addEventListener: () => {}, + enumerateDevices: async () => [], + removeEventListener: () => {}, + }, +}); +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; + +globalThis.__TAURI_INTERNALS__ = { + invoke: (command, args) => ipc.invoke(command, args), + transformCallback: () => 1, +}; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; +// @tauri-apps/api reads unregisterListener off window during listener teardown. +globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} }; +dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ = + globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__; + +const ipc = { + detailCalls: [], + directoryCalls: 0, + detail: async () => { + throw new Error("unconfigured detail response"); + }, + search: async () => ({ found: 0, hits: [] }), + users: async () => ({ missing: [], profiles: {} }), + async invoke(command, args) { + if (command === "get_channel_details") { + this.detailCalls.push(args.channelId); + return this.detail(args.channelId); + } + if (command === "get_open_channel_directory") { + this.directoryCalls += 1; + return []; + } + if (command === "search_messages") return this.search(args); + if (command === "get_users_batch") return this.users(args); + // HuddleProvider (mounted transitively via the discussion facepile's + // profile popover) registers Tauri event listeners. Absorb them so the + // panel can render; its audio probes are all best-effort and swallow the + // unmocked-command throw below. + if (command.startsWith("plugin:event|")) return 0; + throw new Error(`unmocked Tauri command: ${command}`); + }, + reset() { + this.detailCalls = []; + this.directoryCalls = 0; + this.detail = async () => { + throw new Error("unconfigured detail response"); + }; + this.search = async () => ({ found: 0, hits: [] }); + this.users = async () => ({ missing: [], profiles: {} }); + }, +}; + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let HuddleProvider; +let useChannelReference; +let useSearchResults; +let channelReferenceQueryKey; +let channelsQueryKey; +let openChannelDirectoryQueryKey; +let isChannelReferenceOpenable; +let useChannelReferences; +let useOpenAgentActivity; +let useReminderSources; +let DiscussionChannelsPanel; +let createMarkdownComponents; +let renderCachedMarkdown; +let MarkdownRuntimeContext; +let relayAgentsQueryKey; +let createMemoryHistory; +let createRootRoute; +let createRoute; +let createRouter; +let RouterProvider; + +const COMMUNITY = { + addedAt: "2026-08-19T00:00:00.000Z", + id: "reference-test-community", + name: "Reference test", + relayUrl: "ws://reference.test", +}; +const VIEWER = "a".repeat(64); + +function rawChannel({ id, name, visibility = "open" }) { + return { + archived_at: null, + channel_type: "stream", + description: "", + id, + is_member: false, + last_message_at: null, + member_count: 0, + member_pubkeys: [], + name, + participant_pubkeys: [], + participants: [], + purpose: null, + topic: null, + ttl_deadline: null, + ttl_seconds: null, + visibility, + }; +} + +function rawDetail(channel) { + return { + ...channel, + created_at: "2026-08-19T00:00:00.000Z", + created_by: VIEWER, + max_members: null, + nip29_group_id: null, + purpose_set_at: null, + purpose_set_by: null, + topic_required: false, + topic_set_at: null, + topic_set_by: null, + updated_at: "2026-08-19T00:00:00.000Z", + }; +} + +function channel({ id, name, isMember = true, visibility = "open" }) { + return { + archivedAt: null, + channelType: "stream", + description: "", + id, + isMember, + lastMessageAt: null, + memberCount: 0, + memberPubkeys: [], + name, + participantPubkeys: [], + participants: [], + purpose: null, + topic: null, + ttlDeadline: null, + ttlSeconds: null, + visibility, + }; +} + +function createClient({ memberChannels = [], warmChannels } = {}) { + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + }, + }); + client.setQueryData(["identity"], { pubkey: VIEWER }); + client.setQueryData(channelsQueryKey, memberChannels); + if (warmChannels) { + client.setQueryData(openChannelDirectoryQueryKey, warmChannels); + } + return client; +} + +async function mountReference(client, channelId) { + let value; + function Probe({ id }) { + value = useChannelReference(id); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const render = async (id) => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Probe, { id }), + ), + ), + ); + }); + }; + + await render(channelId); + return { + get value() { + return value; + }, + render, + async settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }, + async unmount() { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + }, + }; +} + +before(async () => { + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + )); + ({ HuddleProvider } = await import("@/features/huddle")); + ({ + channelReferenceQueryKey, + openChannelDirectoryQueryKey, + useChannelReference, + useChannelReferences, + } = await import("./openChannelDirectory.ts")); + ({ channelsQueryKey } = await import("./hooks.ts")); + ({ relayAgentsQueryKey } = await import("@/features/agents/hooks.ts")); + ({ useOpenAgentActivity } = await import( + "@/features/agents/useOpenAgentActivity.ts" + )); + ({ useReminderSources } = await import( + "@/features/reminders/ui/RemindersPanel.tsx" + )); + ({ DiscussionChannelsPanel } = await import( + "@/features/projects/ui/DiscussionChannels.tsx" + )); + ({ createMarkdownComponents } = await import("@/shared/ui/markdown.tsx")); + ({ renderCachedMarkdown } = await import( + "@/shared/ui/markdown/nodeCache.ts" + )); + ({ MarkdownRuntimeContext } = await import( + "@/shared/ui/markdown/runtimeContext.ts" + )); + ({ + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, + } = await import("@tanstack/react-router")); + ({ useSearchResults } = await import( + "@/features/search/useSearchResults.ts" + )); + ({ isChannelReferenceOpenable } = await import("./openChannelDirectory.ts")); +}); + +beforeEach(() => { + ipc.reset(); + localStorage.clear(); + localStorage.setItem("buzz-communities", JSON.stringify([COMMUNITY])); + localStorage.setItem("buzz-active-community-id", COMMUNITY.id); +}); + +afterEach(() => ipc.reset()); +after(() => dom.window.close()); + +test("opening global search with an empty query does not scan the open directory", async () => { + const client = createClient(); + let search; + function Probe() { + search = useSearchResults({ channels: [], enabled: true }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Probe), + ), + ), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.equal(search.query, ""); + assert.equal(ipc.directoryCalls, 0); + + await act(async () => root.unmount()); + client.clear(); + container.remove(); +}); + +test("an unknown id fetches one detail without scanning the open directory", async () => { + const client = createClient(); + ipc.detail = async (channelId) => + rawDetail(rawChannel({ id: channelId, name: "remote" })); + const mounted = await mountReference(client, "unknown-channel"); + + await mounted.settle(); + + assert.deepEqual(ipc.detailCalls, ["unknown-channel"]); + assert.equal(ipc.directoryCalls, 0); + assert.equal(mounted.value?.name, "remote"); + await mounted.unmount(); +}); + +test("member and warm-directory references avoid the bounded detail request", async () => { + const memberClient = createClient({ + memberChannels: [channel({ id: "member", name: "member" })], + }); + const member = await mountReference(memberClient, "member"); + await member.settle(); + assert.equal(member.value?.name, "member"); + await member.unmount(); + + const warmClient = createClient({ + warmChannels: [channel({ id: "warm", isMember: false, name: "warm" })], + }); + const warm = await mountReference(warmClient, "warm"); + await warm.settle(); + assert.equal(warm.value?.name, "warm"); + assert.deepEqual(ipc.detailCalls, []); + assert.equal(ipc.directoryCalls, 0); + await warm.unmount(); +}); + +test("fetched private metadata remains non-openable", async () => { + const client = createClient(); + ipc.detail = async (channelId) => + rawDetail( + rawChannel({ id: channelId, name: "private", visibility: "private" }), + ); + const mounted = await mountReference(client, "private-channel"); + + await mounted.settle(); + + assert.equal(mounted.value?.isMember, false); + assert.equal(mounted.value?.visibility, "private"); + assert.equal(isChannelReferenceOpenable(mounted.value), false); + assert.equal(ipc.directoryCalls, 0); + await mounted.unmount(); +}); + +test("a not-found detail result is cached as a five-minute miss", async () => { + const client = createClient(); + ipc.detail = async () => { + throw new Error("channel not found"); + }; + const first = await mountReference(client, "missing-channel"); + await first.settle(); + + assert.equal(first.value, undefined); + assert.deepEqual(ipc.detailCalls, ["missing-channel"]); + assert.equal( + client.getQueryData(channelReferenceQueryKey("missing-channel")), + null, + ); + await first.unmount(); + + const second = await mountReference(client, "missing-channel"); + await second.settle(); + assert.deepEqual(ipc.detailCalls, ["missing-channel"]); + assert.equal(ipc.directoryCalls, 0); + await second.unmount(); +}); + +async function mountWithRouter(client, Component) { + const rootRoute = createRootRoute({ + component: () => React.createElement(Component), + }); + const channelRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/channels/$channelId", + component: () => null, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([channelRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + HuddleProvider, + null, + React.createElement(RouterProvider, { router }), + ), + ), + ), + ); + }); + return { + container, + async settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }, + async unmount() { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + }, + }; +} + +async function mountMarkdownReference(client, content, variant) { + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content, + variant, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + resolveChannelReferences: true, + }, + }, + markdown, + ), + ), + ), + ); + }); + return { + container, + async settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }, + async unmount() { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + }, + }; +} + +test("markdown message links resolve private destinations without a directory scan", async () => { + const channelId = "private-markdown-channel"; + const messageId = "e".repeat(64); + const link = `buzz://message?channel=${channelId}&id=${messageId}`; + const renderPaths = [ + ["CommonMark autolink", `<${link}>`], + ["bare message-link node", link], + ]; + + for (const [path, content] of renderPaths) { + const client = createClient(); + ipc.detail = async (id) => + rawDetail(rawChannel({ id, name: "private", visibility: "private" })); + const mounted = await mountMarkdownReference( + client, + content, + `private-message-link-${path}`, + ); + await mounted.settle(); + + assert.deepEqual(ipc.detailCalls, [channelId], path); + assert.equal(ipc.directoryCalls, 0, path); + assert.equal( + mounted.container.querySelector("button[data-message-link]"), + null, + `${path} private destination must not render a clickable pill`, + ); + assert.notEqual( + mounted.container.querySelector( + "span[data-message-link][data-buzz-link]", + ), + null, + `${path} private destination must render an inert message-link pill`, + ); + await mounted.unmount(); + ipc.reset(); + } +}); + +test("authored-label channel and message links respect the private-destination gate", async () => { + // Authored-label deep links must route through the same bounded detail + // lookup + openable gate as the pill paths, regardless of parser family: + // - buzz://channel/ and buzz://channel// reach the + // gate via ChannelDeepLinkAnchor's authored branch, and + // - the canonical buzz://message?channel=&id= form (produced by + // buildMessageLink) reaches it via resolveMessageLinkRenderTarget's + // "label" branch. + // A private channel must render inert on every route regardless of the + // display text. + const channelId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const messageId = "b".repeat(64); + const channelLink = `buzz://channel/${channelId}`; + const channelMessageLink = `buzz://channel/${channelId}/${messageId}`; + const canonicalMessageLink = `buzz://message?channel=${channelId}&id=${messageId}`; + const renderPaths = [ + ["channel variant", `[private channel](${channelLink})`], + [ + "channel-path message variant", + `[private message](${channelMessageLink})`, + ], + ["canonical message variant", `[private message](${canonicalMessageLink})`], + ]; + + for (const [path, content] of renderPaths) { + const client = createClient(); + ipc.detail = async (id) => + rawDetail(rawChannel({ id, name: "private", visibility: "private" })); + const mounted = await mountMarkdownReference( + client, + content, + `private-authored-label-${path}`, + ); + await mounted.settle(); + + assert.deepEqual(ipc.detailCalls, [channelId], `${path}: bounded detail`); + assert.equal(ipc.directoryCalls, 0, `${path}: no directory scan`); + assert.equal( + mounted.container.querySelector("button"), + null, + `${path} private destination must not render a clickable element`, + ); + assert.notEqual( + mounted.container.querySelector("span[data-buzz-link]"), + null, + `${path} private destination must render an inert node`, + ); + await mounted.unmount(); + ipc.reset(); + } +}); + +test("multi-id references dedupe cold ids and share the single-id query cache", async () => { + const client = createClient(); + ipc.detail = async (channelId) => + rawDetail(rawChannel({ id: channelId, name: `#${channelId}` })); + let references; + function Probe() { + references = useChannelReferences(["cold", "cold", "other"]); + return null; + } + const mounted = await mountWithRouter(client, Probe); + await mounted.settle(); + + assert.deepEqual(ipc.detailCalls.sort(), ["cold", "other"]); + assert.equal(references.channelsById.get("cold")?.name, "#cold"); + assert.equal(ipc.directoryCalls, 0); + await mounted.unmount(); +}); + +test("agent activity opens a cold readable channel without a directory scan", async () => { + const client = createClient(); + const agentPubkey = "b".repeat(64); + client.setQueryData(relayAgentsQueryKey, [ + { + pubkey: agentPubkey, + ownerPubkey: VIEWER, + name: "Agent", + agentType: "agent", + channels: [], + channelIds: ["cold-agent-channel"], + capabilities: [], + status: "online", + respondTo: null, + respondToAllowlist: [], + }, + ]); + ipc.detail = async (channelId) => + rawDetail(rawChannel({ id: channelId, name: "cold-agent" })); + let activity; + function Probe() { + activity = useOpenAgentActivity(); + return null; + } + const mounted = await mountWithRouter(client, Probe); + await mounted.settle(); + + assert.equal(activity.canOpenAgentActivity(agentPubkey), true); + assert.equal(activity.openAgentActivity(agentPubkey), true); + assert.deepEqual(ipc.detailCalls, ["cold-agent-channel"]); + assert.equal(ipc.directoryCalls, 0); + await mounted.unmount(); +}); + +test("reminder sources label a cold readable channel without a directory scan", async () => { + const client = createClient(); + const reminder = { + id: "reminder", + eventId: "event", + createdAt: 1, + content: { + status: "pending", + target: { + eventId: "message", + channelId: "cold-reminder-channel", + preview: "Reminder source", + authorPubkey: "c".repeat(64), + }, + }, + }; + ipc.detail = async (channelId) => + rawDetail(rawChannel({ id: channelId, name: "cold-reminder" })); + let sources; + function Probe() { + sources = useReminderSources([reminder]); + return null; + } + const mounted = await mountWithRouter(client, Probe); + await mounted.settle(); + + assert.equal(sources.get("reminder")?.channelLabel, "cold-reminder"); + assert.deepEqual(ipc.detailCalls, ["cold-reminder-channel"]); + assert.equal(ipc.directoryCalls, 0); + await mounted.unmount(); +}); + +test("discussion rows label a cold readable channel without a directory scan", async () => { + const client = createClient(); + ipc.search = async () => ({ + found: 1, + hits: [ + { + event_id: "event", + content: "discussion", + kind: 9, + pubkey: "d".repeat(64), + channel_id: "abc12345-cold-discussion-channel", + channel_name: null, + created_at: 1, + score: 1, + }, + ], + }); + ipc.detail = async (channelId) => + rawDetail(rawChannel({ id: channelId, name: "cold-discussion" })); + const mounted = await mountWithRouter(client, () => + React.createElement(DiscussionChannelsPanel, { + query: "discussion query", + repositoryName: "repo", + }), + ); + await mounted.settle(); + await mounted.settle(); + + assert.match(mounted.container.textContent, /#cold-discussion/); + assert.doesNotMatch(mounted.container.textContent, /#abc12345/); + assert.deepEqual(ipc.detailCalls, ["abc12345-cold-discussion-channel"]); + assert.equal(ipc.directoryCalls, 0); + await mounted.unmount(); +}); diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 69e511fb40..512ee6899f 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -78,7 +78,7 @@ export function AddChannelBotTeamsSection({

- +
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 641b81490b..c1933f14bb 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -60,7 +60,7 @@ import { import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents"; import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { useChannelReference } from "@/features/channels/openChannelDirectory"; type AgentSessionThreadPanelProps = { agent: ChannelAgentSessionAgent; @@ -218,22 +218,12 @@ export function AgentSessionThreadPanel({ }); // Scope label input: prefer the passed channel's name; when the pane is // channel-scoped without a full Channel object (#1380's channelId prop), - // resolve the name from the channels cache. - const channelsQuery = useChannelsQuery({ - enabled: Boolean(sessionChannelId), - }); - const scopeChannelName = React.useMemo(() => { - if (!sessionChannelId) { - return null; - } - if (channel && channel.id === sessionChannelId) { - return channel.name; - } - return ( - channelsQuery.data?.find((entry) => entry.id === sessionChannelId) - ?.name ?? null - ); - }, [channel, channelsQuery.data, sessionChannelId]); + // resolve that one id through the bounded reference query. + const referencedChannel = useChannelReference(sessionChannelId); + const scopeChannelName = + channel && channel.id === sessionChannelId + ? channel.name + : (referencedChannel?.name ?? null); const scopeLabel = sessionChannelId ? scopeChannelName ? `#${scopeChannelName}` diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a96103..cfa84f02e3 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -10,7 +10,12 @@ import { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -26,7 +31,6 @@ type BotActivityBarProps = { variant?: "toolbar" | "inline"; }; -const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; @@ -106,7 +110,7 @@ export function BotActivityComposerAction({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer]); const closeWithDelay = React.useCallback(() => { diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 566cfa3fab..aea0f9323e 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -5,6 +5,7 @@ import { DoorClosed, DoorOpen, Trash2, + Workflow as WorkflowIcon, } from "lucide-react"; import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; @@ -21,11 +22,15 @@ import { useUpdateChannelMutation, } from "@/features/channels/hooks"; import { compareMembersByRole } from "@/features/channels/lib/memberUtils"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelWorkflowsQuery } from "@/features/workflows/hooks"; import { DEFAULT_EPHEMERAL_TTL_SECONDS, formatTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; -import type { Channel, ChannelMember } from "@/shared/api/types"; +import type { Channel, ChannelMember, Workflow } from "@/shared/api/types"; +import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { Button } from "@/shared/ui/button"; @@ -55,6 +60,7 @@ import { PANEL_OVERLAY_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; import { ChannelCanvas } from "./ChannelCanvas"; +import { ChannelWorkflowsSection } from "./ChannelWorkflowsSection"; import { CHANNEL_FORM_FIELD_CONTROL_CLASS, CHANNEL_FORM_FIELD_SHELL_CLASS, @@ -101,15 +107,24 @@ export function ChannelManagementSheet({ transparentChrome = false, }: ChannelManagementSheetProps) { const { isDark } = useTheme(); + const { goNewWorkflowForChannel, goWorkflow } = useAppNavigation(); + const { + openNewWorkflow: openNewWorkflowOverlay, + openWorkflow: openWorkflowOverlay, + } = useWorkflowEditorOverlay(); const isSplitLayout = layout === "split"; const auxiliaryPanelMode = getAuxiliaryPanelMode( isSplitLayout, !isSplitLayout, ); const channelId = channel?.id ?? null; + const workflowsEnabled = useFeatureEnabled("workflows"); const detailsQuery = useChannelDetailsQuery(channelId, open); const membersQuery = useChannelMembersQuery(channelId, open); const canvasQuery = useCanvasQuery(channelId, channelId !== null && open); + const workflowsQuery = useChannelWorkflowsQuery( + workflowsEnabled && channelId !== null && open ? channelId : null, + ); const updateChannelDetailsMutation = useUpdateChannelMutation(channelId); const archiveChannelMutation = useArchiveChannelMutation(channelId); const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId); @@ -160,9 +175,11 @@ export function ChannelManagementSheet({ const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false); const [hasUserEditedChannelDraft, setHasUserEditedChannelDraft] = React.useState(false); - const [activeView, setActiveView] = React.useState<"summary" | "canvas">( - "summary", - ); + const [activeView, setActiveView] = React.useState< + "summary" | "canvas" | "workflows" + >("summary"); + const visibleActiveView = + workflowsEnabled || activeView !== "workflows" ? activeView : "summary"; const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } = useDeferredModalOpen(); @@ -237,6 +254,33 @@ export function ChannelManagementSheet({ onOpenChange(next); } + // Workflows open as a modal above the channel settings Workflows view. Keep + // that view mounted behind the editor so every completed close path (clean, + // dirty-discard, or create cancel) returns to the exact surface that opened + // it. The navigation fallbacks still close the sheet before changing routes; + // canonical /workflows deep links stay unchanged either way. + function handleOpenWorkflow(workflow: Workflow) { + if (openWorkflowOverlay) { + openWorkflowOverlay(workflow.id, workflow); + return; + } + + handlePanelOpenChange(false); + void goWorkflow(workflow.id); + } + + function handleCreateWorkflow() { + if (!channelId) return; + + if (openNewWorkflowOverlay) { + openNewWorkflowOverlay(channelId); + return; + } + + handlePanelOpenChange(false); + void goNewWorkflowForChannel(channelId); + } + const currentVisibility = detail?.visibility ?? channel.visibility; const currentTtlSeconds = detail?.ttlSeconds ?? null; const nextVisibility: "open" | "private" = isPrivateDraft @@ -338,7 +382,7 @@ export function ChannelManagementSheet({ onPointerDownOutside={(event) => event.preventDefault()} > = { }; type ChannelManagementPanelContentProps = { - activeView: "summary" | "canvas"; + activeView: "summary" | "canvas" | "workflows"; archiveChannelMutation: ChannelMutation; canEditChannel: boolean; canEditNarrative: boolean; @@ -580,6 +632,15 @@ type ChannelManagementPanelContentProps = { canvasQuery: { isLoading: boolean }; channelId: string | null; currentPubkey?: string; + workflowsEnabled: boolean; + workflowsQuery: { + data?: Workflow[]; + error: unknown; + isLoading: boolean; + refetch: () => Promise; + }; + onCreateWorkflow: () => void; + onOpenWorkflow: (workflow: Workflow) => void; deleteChannelMutation: ChannelMutation; detailsError: unknown; handleDeleteChannel: () => Promise; @@ -598,7 +659,9 @@ type ChannelManagementPanelContentProps = { onOpenMembers?: () => void; onOpenChange: (open: boolean) => void; resolvedChannel: Channel; - setActiveView: React.Dispatch>; + setActiveView: React.Dispatch< + React.SetStateAction<"summary" | "canvas" | "workflows"> + >; unarchiveChannelMutation: ChannelMutation; }; @@ -614,6 +677,10 @@ function ChannelManagementPanelContent({ canvasQuery, channelId, currentPubkey, + workflowsEnabled, + workflowsQuery, + onCreateWorkflow, + onOpenWorkflow, deleteChannelMutation, detailsError, handleDeleteChannel, @@ -663,12 +730,18 @@ function ChannelManagementPanelContent({ backButtonTestId="channel-management-back" mode={mode} onBack={ - activeView === "canvas" ? () => setActiveView("summary") : undefined + activeView !== "summary" + ? () => setActiveView("summary") + : undefined } > - {activeView === "canvas" ? "Canvas" : "Channel Settings"} + {activeView === "canvas" + ? "Canvas" + : activeView === "workflows" + ? "Workflows" + : "Channel Settings"} @@ -749,14 +822,45 @@ function ChannelManagementPanelContent({ {canOpenCanvas ? ( +
+ setActiveView("canvas")} + testId="channel-canvas-ingress" + trailing={canvasQuery.isLoading ? "Loading..." : undefined} + /> + {workflowsEnabled ? ( + setActiveView("workflows")} + testId="channel-workflows-ingress" + trailing={ + workflowsQuery.isLoading ? "Loading..." : undefined + } + /> + ) : null} +
+ ) : workflowsEnabled ? ( setActiveView("canvas")} - testId="channel-canvas-ingress" - trailing={canvasQuery.isLoading ? "Loading..." : undefined} + description={ + workflowsQuery.isLoading + ? undefined + : `${workflowsQuery.data?.length ?? 0} workflow${workflowsQuery.data?.length === 1 ? "" : "s"}` + } + icon={WorkflowIcon} + label="Workflows" + onClick={() => setActiveView("workflows")} + testId="channel-workflows-ingress" + trailing={workflowsQuery.isLoading ? "Loading..." : undefined} /> ) : null} @@ -871,7 +975,7 @@ function ChannelManagementPanelContent({

) : null}
- ) : ( + ) : activeView === "canvas" ? (
- )} + ) : activeView === "workflows" && workflowsEnabled ? ( + void workflowsQuery.refetch()} + workflows={workflowsQuery.data ?? []} + /> + ) : null} ); diff --git a/desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx b/desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx new file mode 100644 index 0000000000..a30392ca6c --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx @@ -0,0 +1,77 @@ +import { Plus, Workflow as WorkflowIcon } from "lucide-react"; + +import type { Workflow } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { FieldGroup } from "./ChannelManagementSheetRows"; + +export function ChannelWorkflowsSection({ + error, + loading, + onCreate, + onOpen, + onRetry, + workflows, +}: { + error: unknown; + loading: boolean; + onCreate: () => void; + onOpen: (workflow: Workflow) => void; + onRetry: () => void; + workflows: Workflow[]; +}) { + return ( +
+ {loading ? ( +

+ Loading workflows... +

+ ) : error instanceof Error ? ( +
+

{error.message}

+ +
+ ) : workflows.length > 0 ? ( + + {workflows.map((workflow) => ( + + ))} + + ) : ( +

+ No workflows in this channel yet. +

+ )} + + +
+ ); +} diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index b01ec59f4e..43e4d80e6b 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -7,6 +7,7 @@ type RightAuxiliaryPaneProps = { canResetWidth: boolean; children: React.ReactNode; constrainToAvailableSpace?: boolean; + detached?: boolean; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; testId?: string; @@ -17,6 +18,7 @@ export function RightAuxiliaryPane({ canResetWidth, children, constrainToAvailableSpace = true, + detached = false, onResetWidth, onResizeStart, testId, @@ -25,7 +27,10 @@ export function RightAuxiliaryPane({ return (