mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
Stack: [#5112](https://github.com/block/buzz/pull/5112) → this PR Stacks on the team catalog backend PR (#5112). Contains the `desktop/**` changes that turn the add-agent surface into a single Community Catalog browsing both shared agents and shared teams. ## Owner catalog sync pipeline `usePersonaSync.ts` gains a hydration pipeline in `startPersonaSync` so a fresh device that comes online after another already published the owner's 30178 catalog head reconstructs the complete state without falsely retracting it. The owner's persona/team/managed-agent/30178/deletion history is backfilled up front (a live-only subscription gets no history — reconnect-replay's since-cursor is undefined until the first live event), then a live subscription takes over. Four properties keep a fresh sync from purging the owner's valid shared head: - **Paged backfill with a safe termination guarantee.** The relay serves each REQ newest-first and clamps `limit` to its advertised `max_limit`, so a large owner's history overflows one page — a newer 30178/30176 could return in-page while an older required 30175 constituent falls beyond it. `fetchOwnerHistoryToExhaustion` pages the full window with the `until` time cursor (the only cursor the WS REQ filter exposes; the DB `before_id` keyset is REST-only), deduping the inclusive-boundary rows. A short page terminates normally. A full page whose oldest event cannot advance the time-only cursor is a **dense boundary** — more than one page of events share one `created_at` second, which the WS filter has no `(created_at, id)` cursor to escape — and raises `PersonaHistoryDenseBoundaryError` rather than silently completing as if the history were exhausted and dropping the older constituents behind it. - **Constituents-before-catalog ordering.** Over the complete batch, `orderCatalogHeadsLast` stably defers every 30178 head past its 30175/30176 constituents. Reconciling a 30178 head before its personas hydrate makes the inbound team refresh fail member resolution and queue a dominating false tombstone; deferring the heads guarantees the constituents are all applied first, while newest-wins order within every other coordinate is untouched. - **Hydration boundary for concurrent live events.** The backfill fetch and the live subscription start concurrently into one reconcile chain. A live or replayed 30178 that arrives before the backfill reconciles its constituents would reproduce the same false-tombstone purge, so live events are buffered until the ordered backfill is dispatched and then drained in arrival order. Steady-state live events (after hydration completes) reconcile immediately. - **Explicit backfill failure policy.** A transient history-fetch rejection is retried with bounded backoff. When backfill cannot complete — retries exhausted, or a deterministic dense boundary — the pipeline enters a **degraded-live** state rather than leaving the subscription permanently inert with live events accumulating in the buffer: the hydration boundary still opens so buffered and future live events keep reconciling, but the whole catalog dependency set is dropped — the 30178 head, its 30175/30176 constituents, and any kind-5 deletion carrying a dependency-targeting `a` tag (classified by scanning *all* `a` tags, matching the backend's deletion router, which `find_map`s across every tag and routes the first signer-owned coordinate — so a malformed or foreign first `a` tag ahead of an owned 30176 cannot slip a destructive deletion through) — because backfill never fully hydrated the owner's constituents. Dropping only the 30178 head is not enough: the backend refreshes the catalog head after every team/persona save, and live delivery is newest-first, so a 30176 edit that adds a *new* member would reach the backend before that member's 30175 and falsely tombstone a witness-holding device's valid team. Holding the prior hydrated run's constituents on disk only proves the *old* revision is resolvable — it says nothing about a new member — which is why the entire dependency set is held rather than just the head. 30177 managed-agent runtime policy stays live (it drives no catalog refresh). A degraded device stays stale on team/persona edits until it self-heals on the next effect re-run (restart, or an identity/community switch) — the correct trade against destroying valid shared state. ## Data layer Relay paging, signature verification, NIP-33 head selection, and untrusted-content parsing for the kind `30178` team catalog live natively in the `fetch_team_catalog` Tauri command (`team_catalog.rs`), structurally mirroring `fetch_persona_catalog` (`persona_catalog.rs`). A catalog refresh crosses IPC once and never verifies a signature on the webview thread. `teamCatalogRelay.ts` is now a thin presentation and local-linkage layer over the verified projection — it shapes entries for display and links each to a local team, and never parses or verifies. Parsing is all-or-nothing, identical to the add-time re-fetch in `add_team_from_catalog`: a team with any invalid member fails to parse and the publication is dropped from the catalog, matching persona behavior. **Behavior delta:** the previously reviewed partial-render of invalid-member teams — a warning banner on an entry that could never be added — is removed. Invalid publications are dropped entirely rather than surfaced as un-addable. ## Hooks `useTeamCatalogRelay.ts` mirrors the persona catalog hook: a community-keyed query over `fetch_team_catalog`, live invalidation on kind `30178`, share/unshare, and add-from-catalog (which invalidates both the teams and personas stores, since adopting a team copies its members as local personas). ## CommunityCatalogDialog Single unified surface replacing the former separate dialogs. Agents and Teams appear as labeled sections with type-tagged selection and a teams-preferred launch. `TeamsSection`'s discover entry and the new-agent card both open this one dialog. `PersonaCatalogDialog.tsx` is removed; persona browsing now lives inside the unified dialog. ## TeamShareDialog Publishes and unshares team catalog entries via `set_team_shared`. ## e2e + screenshots `team-catalog.spec.ts` covers the browse + adopt flow; `team-catalog-screenshots.spec.ts` produces the pixel-regression set. `e2eBridge.ts` gains `mockTeamCatalogPublications`, which mirrors the native command's head selection and shared gate and performs only a shallow `v`/`name`/`members`-array shape check; per-member validation stays in the Rust command. ## Follow-ups None. The shared agent-definition text-safety policy (Unicode-control/bidi/zero-width rejection) already covers the team surface: #5112's `validate_team_catalog_content` chokepoint gates every field delivered verbatim to the harness on both the publish and adopt paths, and this PR's parse layer consumes that verified projection rather than re-validating on the webview thread. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
303 lines
9.8 KiB
TypeScript
303 lines
9.8 KiB
TypeScript
import { hexToBytes } from "@noble/hashes/utils.js";
|
|
import { expect, test } from "@playwright/test";
|
|
import { finalizeEvent } from "nostr-tools/pure";
|
|
|
|
import type { RelayEvent } from "@/shared/api/types";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
|
|
|
function ownerPrivateKeyFor(pubkey: string): Uint8Array {
|
|
const privateKey = Object.values(TEST_IDENTITIES).find(
|
|
(identity) => identity.pubkey === pubkey,
|
|
)?.privateKey;
|
|
if (!privateKey) {
|
|
throw new Error(`No test private key for ${pubkey}`);
|
|
}
|
|
return hexToBytes(privateKey);
|
|
}
|
|
|
|
const SHOTS = "test-results/team-catalog";
|
|
|
|
type CatalogMember = {
|
|
memberKey: string;
|
|
displayName: string;
|
|
systemPrompt: string;
|
|
model?: string | null;
|
|
runtime?: string | null;
|
|
provider?: string | null;
|
|
};
|
|
|
|
/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. */
|
|
function createTeamCatalogEvent(input: {
|
|
ownerPubkey: string;
|
|
teamDTag: string;
|
|
name: string;
|
|
description?: string | null;
|
|
instructions?: string | null;
|
|
members: CatalogMember[];
|
|
}): RelayEvent {
|
|
return finalizeEvent(
|
|
{
|
|
created_at: 1_721_750_400,
|
|
kind: 30178,
|
|
tags: [
|
|
["d", input.teamDTag],
|
|
["shared", "true"],
|
|
],
|
|
content: JSON.stringify({
|
|
v: 1,
|
|
name: input.name,
|
|
description: input.description ?? null,
|
|
instructions: input.instructions ?? null,
|
|
members: input.members.map((member) => ({
|
|
member_key: member.memberKey,
|
|
display_name: member.displayName,
|
|
system_prompt: member.systemPrompt,
|
|
avatar_url: null,
|
|
runtime: member.runtime ?? null,
|
|
model: member.model ?? null,
|
|
provider: member.provider ?? null,
|
|
})),
|
|
}),
|
|
},
|
|
ownerPrivateKeyFor(input.ownerPubkey),
|
|
);
|
|
}
|
|
|
|
/** A kind:30175 head, shaped exactly as `persona_catalog_content` projects it. */
|
|
function createPersonaCatalogEvent(input: {
|
|
ownerPubkey: string;
|
|
sourcePersonaId: string;
|
|
displayName: string;
|
|
systemPrompt: string;
|
|
}): RelayEvent {
|
|
return finalizeEvent(
|
|
{
|
|
created_at: 1_721_750_400,
|
|
kind: 30175,
|
|
tags: [
|
|
["d", input.sourcePersonaId],
|
|
["shared", "true"],
|
|
],
|
|
content: JSON.stringify({
|
|
display_name: input.displayName,
|
|
system_prompt: input.systemPrompt,
|
|
avatar_url: null,
|
|
runtime: null,
|
|
model: null,
|
|
provider: null,
|
|
name_pool: [],
|
|
}),
|
|
},
|
|
ownerPrivateKeyFor(input.ownerPubkey),
|
|
);
|
|
}
|
|
|
|
const PERSONA_CATALOG_EVENTS: RelayEvent[] = [
|
|
createPersonaCatalogEvent({
|
|
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
|
|
sourcePersonaId: "code-reviewer",
|
|
displayName: "Code Reviewer",
|
|
systemPrompt: "Review pull requests for correctness and edge cases.",
|
|
}),
|
|
];
|
|
|
|
const CATALOG_EVENTS: RelayEvent[] = [
|
|
createTeamCatalogEvent({
|
|
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
|
teamDTag: "release-review",
|
|
name: "Release Review",
|
|
description:
|
|
"Reads the diff, drafts the release note, and files the follow-ups.",
|
|
instructions:
|
|
"Coordinate as a unit. The reviewer and scribe share findings before the triager acts.",
|
|
members: [
|
|
{
|
|
memberKey: "reviewer",
|
|
displayName: "Reviewer",
|
|
systemPrompt: "Review the diff for correctness and edge cases.",
|
|
model: "claude-sonnet-4-5",
|
|
runtime: "claude-code",
|
|
provider: "anthropic",
|
|
},
|
|
{
|
|
memberKey: "scribe",
|
|
displayName: "Scribe",
|
|
systemPrompt: "Write the release note from the merged changes.",
|
|
},
|
|
{
|
|
memberKey: "triager",
|
|
displayName: "Triager",
|
|
systemPrompt: "File follow-ups for anything the review deferred.",
|
|
model: "gpt-5-codex",
|
|
},
|
|
],
|
|
}),
|
|
createTeamCatalogEvent({
|
|
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
|
|
teamDTag: "incident-desk",
|
|
name: "Incident Desk",
|
|
description: "Two agents that hold the timeline during an incident.",
|
|
members: [
|
|
{
|
|
memberKey: "commander",
|
|
displayName: "Commander",
|
|
systemPrompt: "Own the incident timeline and the comms cadence.",
|
|
},
|
|
{
|
|
memberKey: "investigator",
|
|
displayName: "Investigator",
|
|
systemPrompt: "Chase the root cause and report findings.",
|
|
},
|
|
],
|
|
}),
|
|
];
|
|
|
|
async function gotoAgentsView(page: import("@playwright/test").Page) {
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByTestId("open-agents-view")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await page.getByTestId("open-agents-view").click();
|
|
}
|
|
|
|
async function openTeamCatalog(page: import("@playwright/test").Page) {
|
|
await page.getByTestId("new-team-card").click();
|
|
await page.getByTestId("team-catalog-open").click();
|
|
await expect(page.getByTestId("community-catalog-dialog")).toBeVisible();
|
|
await waitForAnimations(page);
|
|
}
|
|
|
|
test.describe("team catalog screenshots", () => {
|
|
test.use({ viewport: { width: 1280, height: 900 } });
|
|
|
|
test("01 — catalog browse, add, and added states", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
await installMockBridge(page, { teamCatalogEvents: CATALOG_EVENTS });
|
|
await gotoAgentsView(page);
|
|
await openTeamCatalog(page);
|
|
|
|
// 1. Browsing another member's publication: list, provenance, per-member
|
|
// model. Release Review is not the default selection, so click it.
|
|
const releaseReview = `community-catalog-team-${TEST_IDENTITIES.alice.pubkey}:release-review`;
|
|
await page.getByTestId(releaseReview).click();
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("community-catalog-dialog").screenshot({
|
|
path: `${SHOTS}/catalog-browse.png`,
|
|
});
|
|
|
|
// 2. Expand the Reviewer member row to show metadata + instruction.
|
|
await page.getByTestId("community-catalog-member-expand-reviewer").click();
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("community-catalog-dialog").screenshot({
|
|
path: `${SHOTS}/catalog-member-expanded.png`,
|
|
});
|
|
|
|
// 3. Team instructions section is visible (Release Review has instructions).
|
|
// Collapse the expanded member row first so the team-instructions state
|
|
// is visually distinct from the expanded-member screenshot above.
|
|
await page.getByTestId("community-catalog-member-expand-reviewer").click();
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("community-catalog-dialog").screenshot({
|
|
path: `${SHOTS}/catalog-team-instructions.png`,
|
|
});
|
|
|
|
// 4. Adding closes the dialog and names the team in the notice.
|
|
await page.getByTestId("community-catalog-add-team").click();
|
|
await expect(
|
|
page.getByText("Added Release Review to your teams."),
|
|
).toBeVisible();
|
|
|
|
// 5. Reopened: the action reads "Added to my teams" and is inert, so a
|
|
// second copy of the same publication is not offered.
|
|
await openTeamCatalog(page);
|
|
await page.getByTestId(releaseReview).click();
|
|
await expect(page.getByTestId("community-catalog-add-team")).toHaveText(
|
|
"Added to my teams",
|
|
);
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("community-catalog-dialog").screenshot({
|
|
path: `${SHOTS}/catalog-added.png`,
|
|
});
|
|
});
|
|
|
|
test("02 — empty catalog", async ({ page }) => {
|
|
await installMockBridge(page, { teamCatalogEvents: [] });
|
|
await gotoAgentsView(page);
|
|
await openTeamCatalog(page);
|
|
|
|
await page.getByTestId("community-catalog-dialog").screenshot({
|
|
path: `${SHOTS}/catalog-empty.png`,
|
|
});
|
|
});
|
|
|
|
test("03 — share dialog before and after publishing", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
await installMockBridge(page, {
|
|
personas: [
|
|
{
|
|
id: "custom:release-analyst",
|
|
displayName: "Release Analyst",
|
|
systemPrompt: "Summarise the release.",
|
|
},
|
|
{
|
|
id: "custom:release-scribe",
|
|
displayName: "Release Scribe",
|
|
systemPrompt: "Write the release note.",
|
|
},
|
|
],
|
|
teams: [
|
|
{
|
|
id: "team-release-010",
|
|
name: "Release Crew",
|
|
description: "Ships the release notes.",
|
|
personaIds: ["custom:release-analyst", "custom:release-scribe"],
|
|
},
|
|
],
|
|
});
|
|
await gotoAgentsView(page);
|
|
|
|
await page.getByLabel("Release Crew team actions").click();
|
|
await page.getByRole("menuitem", { name: "Share" }).click();
|
|
await expect(page.getByTestId("team-share-dialog")).toBeVisible();
|
|
await waitForAnimations(page);
|
|
|
|
// 4. Catalog access sits alongside the existing snapshot-share controls,
|
|
// defaulting to unchecked (not shared).
|
|
await page.getByTestId("team-share-dialog").screenshot({
|
|
path: `${SHOTS}/share-not-shared.png`,
|
|
});
|
|
|
|
// 5. Published: toggle the Switch to share, the toast names the effect.
|
|
await page.getByTestId("team-share-catalog-access").click();
|
|
await expect(page.getByTestId("team-share-catalog-access")).toBeChecked();
|
|
await expect(
|
|
page.getByText("Published Release Crew to the community catalog."),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ path: `${SHOTS}/share-published.png` });
|
|
});
|
|
|
|
test("04 — both sections populated (agents + teams)", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
personaCatalogEvents: PERSONA_CATALOG_EVENTS,
|
|
teamCatalogEvents: CATALOG_EVENTS,
|
|
});
|
|
await gotoAgentsView(page);
|
|
await openTeamCatalog(page);
|
|
|
|
// The Agents section header is visible alongside Teams in the sidebar.
|
|
await expect(
|
|
page.locator('[data-testid^="community-catalog-agent-"]'),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
page.locator('[data-testid^="community-catalog-team-"]'),
|
|
).toHaveCount(2);
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("community-catalog-dialog").screenshot({
|
|
path: `${SHOTS}/catalog-both-sections.png`,
|
|
});
|
|
});
|
|
});
|