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>
607 lines
19 KiB
TypeScript
607 lines
19 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import { hexToBytes } from "@noble/hashes/utils.js";
|
||
import { finalizeEvent } from "nostr-tools/pure";
|
||
|
||
import type { RelayEvent } from "@/shared/api/types";
|
||
|
||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||
import { seedActiveIdentity } from "../helpers/onboarding";
|
||
|
||
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.
|
||
*
|
||
* The catalog read path runs through the shared signature gate (the #4220
|
||
* hardening ported into `catalogRelay.ts`), so a discoverable head must carry
|
||
* a valid signature over its own contents. Sign with the owner's test key
|
||
* rather than stamping a placeholder `sig`, mirroring the persona fixtures in
|
||
* `agents.spec.ts`. The signed `id` is content-derived, so callers read it off
|
||
* the returned event instead of supplying one. */
|
||
function createTeamCatalogEvent(input: {
|
||
ownerPubkey: string;
|
||
teamDTag: string;
|
||
name: string;
|
||
description?: string | null;
|
||
instructions?: string | null;
|
||
members: CatalogMember[];
|
||
createdAt?: number;
|
||
shared?: boolean;
|
||
}): RelayEvent {
|
||
const ownerPrivateKey = Object.values(TEST_IDENTITIES).find(
|
||
(identity) => identity.pubkey === input.ownerPubkey,
|
||
)?.privateKey;
|
||
if (!ownerPrivateKey) {
|
||
throw new Error(`No test private key for ${input.ownerPubkey}`);
|
||
}
|
||
return finalizeEvent(
|
||
{
|
||
created_at: input.createdAt ?? 1_721_750_400,
|
||
kind: 30178,
|
||
tags: [
|
||
["d", input.teamDTag],
|
||
...(input.shared === false ? [] : [["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,
|
||
})),
|
||
}),
|
||
},
|
||
hexToBytes(ownerPrivateKey),
|
||
);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function openTeamShareDialog(
|
||
page: import("@playwright/test").Page,
|
||
teamName: string,
|
||
) {
|
||
await page.getByLabel(`${teamName} team actions`).click();
|
||
await page.getByRole("menuitem", { name: "Share" }).click();
|
||
await expect(page.getByTestId("team-share-dialog")).toBeVisible();
|
||
}
|
||
|
||
async function setTeamCatalogAccess(
|
||
page: import("@playwright/test").Page,
|
||
shared: boolean,
|
||
) {
|
||
const toggle = page.getByTestId("team-share-catalog-access");
|
||
const isChecked = await toggle.isChecked();
|
||
if (isChecked !== shared) {
|
||
await toggle.click();
|
||
}
|
||
}
|
||
|
||
async function listMockTeams(page: import("@playwright/test").Page) {
|
||
return page.evaluate(async () => {
|
||
const invoke = (
|
||
window as Window & {
|
||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||
command: string,
|
||
) => Promise<unknown>;
|
||
}
|
||
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||
if (!invoke) throw new Error("Mock invoke bridge is not installed.");
|
||
return (await invoke("list_teams")) as Array<{
|
||
name: string;
|
||
persona_ids: string[];
|
||
catalog_source: { owner_pubkey: string; team_d_tag: string } | null;
|
||
}>;
|
||
});
|
||
}
|
||
|
||
const ALICE_TEAM_D_TAG = "alice-review-crew";
|
||
const ALICE_TEAM_MEMBERS: CatalogMember[] = [
|
||
{
|
||
memberKey: "reviewer",
|
||
displayName: "Alice’s Reviewer",
|
||
systemPrompt: "Review changes for the whole community.",
|
||
},
|
||
{
|
||
memberKey: "scribe",
|
||
displayName: "Alice’s Scribe",
|
||
systemPrompt: "Write the summary.",
|
||
},
|
||
];
|
||
|
||
test("an unshared kind 30178 head from another member is not offered", async ({
|
||
page,
|
||
}) => {
|
||
await installMockBridge(page, {
|
||
teamCatalogEvents: [
|
||
createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice’s Private Crew",
|
||
members: ALICE_TEAM_MEMBERS,
|
||
shared: false,
|
||
}),
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
|
||
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
|
||
await expect(
|
||
page.locator('[data-testid^="community-catalog-team-"]'),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
test("adding another member's team records its catalog provenance", async ({
|
||
page,
|
||
}) => {
|
||
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
|
||
await installMockBridge(page, {
|
||
teamCatalogEvents: [
|
||
createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice’s Review Crew",
|
||
description: "Two agents that review and summarise.",
|
||
members: ALICE_TEAM_MEMBERS,
|
||
}),
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
|
||
const entry = page.getByTestId(`community-catalog-team-${entryKey}`);
|
||
await expect(entry).toContainText("Alice’s Review Crew");
|
||
await entry.click();
|
||
|
||
const detail = page.getByTestId("community-catalog-detail-pane");
|
||
await expect(detail).toContainText("Added by alice");
|
||
await expect(detail).toContainText("2 members");
|
||
await expect(
|
||
page.getByTestId("community-catalog-member-reviewer"),
|
||
).toContainText("Alice’s Reviewer");
|
||
await expect(
|
||
page.getByTestId("community-catalog-member-scribe"),
|
||
).toContainText("Alice’s Scribe");
|
||
|
||
await page.getByTestId("community-catalog-add-team").click();
|
||
await expect(
|
||
page.getByText("Added Alice’s Review Crew to your teams."),
|
||
).toBeVisible();
|
||
|
||
// The copy carries a fresh local id, so only the stored coordinate links it
|
||
// back to Alice's publication — that link is what stops a second copy.
|
||
const teams = await listMockTeams(page);
|
||
const added = teams.find((team) => team.name === "Alice’s Review Crew");
|
||
expect(added).toMatchObject({
|
||
catalog_source: {
|
||
owner_pubkey: TEST_IDENTITIES.alice.pubkey,
|
||
team_d_tag: ALICE_TEAM_D_TAG,
|
||
},
|
||
});
|
||
expect(added?.persona_ids).toHaveLength(2);
|
||
|
||
await openTeamCatalog(page);
|
||
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
|
||
const addButton = page.getByTestId("community-catalog-add-team");
|
||
await expect(addButton).toHaveText("Added to my teams");
|
||
await expect(addButton).toBeDisabled();
|
||
expect(
|
||
(await listMockTeams(page)).filter(
|
||
(team) => team.name === "Alice’s Review Crew",
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
test("a head that moved while the dialog was open is rejected", async ({
|
||
page,
|
||
}) => {
|
||
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
|
||
await installMockBridge(page, {
|
||
teamCatalogEvents: [
|
||
createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice’s Review Crew",
|
||
members: ALICE_TEAM_MEMBERS,
|
||
}),
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
|
||
|
||
// Republish the coordinate without notifying subscribers: the dialog keeps
|
||
// rendering — and keeps holding — the superseded event id.
|
||
await page.evaluate(
|
||
({ ownerPubkey, teamDTag }) => {
|
||
const replace = (
|
||
window as Window & {
|
||
__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (
|
||
event: unknown,
|
||
) => void;
|
||
}
|
||
).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__;
|
||
if (!replace) throw new Error("Team catalog head seam is not installed.");
|
||
replace({
|
||
id: "3".repeat(64),
|
||
pubkey: ownerPubkey,
|
||
created_at: 1_721_760_400,
|
||
kind: 30178,
|
||
tags: [
|
||
["d", teamDTag],
|
||
["shared", "true"],
|
||
],
|
||
content: JSON.stringify({
|
||
v: 1,
|
||
name: "Alice’s Review Crew",
|
||
description: null,
|
||
instructions: null,
|
||
members: [],
|
||
}),
|
||
sig: "2".repeat(128),
|
||
});
|
||
},
|
||
{ ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG },
|
||
);
|
||
|
||
await page.getByTestId("community-catalog-add-team").click();
|
||
await expect(
|
||
page.getByText(
|
||
"This team was updated since you opened the catalog. Reopen it and try again.",
|
||
),
|
||
).toBeVisible();
|
||
expect(
|
||
(await listMockTeams(page)).filter(
|
||
(team) =>
|
||
team.catalog_source !== null && team.catalog_source !== undefined,
|
||
),
|
||
).toHaveLength(0);
|
||
});
|
||
|
||
test("at an equal timestamp the lower-id head is canonical and a superseding head is rejected as stale", async ({
|
||
page,
|
||
}) => {
|
||
// Two signed events for the same coordinate at identical created_at. Both
|
||
// carry valid signatures (the read path verifies them), so the relay's
|
||
// tie-break decides: `created_at DESC, id ASC` makes the lower-id event
|
||
// canonical. Because the signed `id` is content-derived, we cannot pin it to
|
||
// a literal — we sign both, sort by id, and derive the expected canonical
|
||
// name from whichever id sorts first.
|
||
const SAME_TIMESTAMP = 1_721_760_000;
|
||
const crew = createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice's Review Crew",
|
||
members: ALICE_TEAM_MEMBERS,
|
||
createdAt: SAME_TIMESTAMP,
|
||
});
|
||
const crewV2 = createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice's Review Crew v2",
|
||
members: [],
|
||
createdAt: SAME_TIMESTAMP,
|
||
});
|
||
const [lower] = [crew, crewV2].sort((left, right) =>
|
||
left.id.localeCompare(right.id),
|
||
);
|
||
const canonicalName = JSON.parse(lower.content).name as string;
|
||
|
||
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
|
||
await installMockBridge(page, { teamCatalogEvents: [crew, crewV2] });
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
|
||
|
||
// The lower-id event was selected as canonical, so its name is what renders.
|
||
await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
|
||
canonicalName,
|
||
);
|
||
|
||
// Republish the coordinate with a strictly newer head, without notifying
|
||
// subscribers: the dialog keeps holding the superseded id. The add must be
|
||
// rejected as stale.
|
||
await page.evaluate(
|
||
({ ownerPubkey, teamDTag }) => {
|
||
const replace = (
|
||
window as Window & {
|
||
__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (
|
||
event: unknown,
|
||
) => void;
|
||
}
|
||
).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__;
|
||
if (!replace) throw new Error("Team catalog head seam is not installed.");
|
||
replace({
|
||
id: "9".repeat(64),
|
||
pubkey: ownerPubkey,
|
||
created_at: 1_721_760_400,
|
||
kind: 30178,
|
||
tags: [
|
||
["d", teamDTag],
|
||
["shared", "true"],
|
||
],
|
||
content: JSON.stringify({
|
||
v: 1,
|
||
name: "Alice's Review Crew v2",
|
||
description: null,
|
||
instructions: null,
|
||
members: [],
|
||
}),
|
||
sig: "2".repeat(128),
|
||
});
|
||
},
|
||
{ ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG },
|
||
);
|
||
|
||
await page.getByTestId("community-catalog-add-team").click();
|
||
await expect(
|
||
page.getByText(
|
||
"This team was updated since you opened the catalog. Reopen it and try again.",
|
||
),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("sharing a team publishes it to the catalog and unsharing retracts it", async ({
|
||
page,
|
||
}) => {
|
||
// The own head is published through the same signature-verified read path as
|
||
// foreign heads, so the viewer must hold a real key to sign it — seed one.
|
||
await seedActiveIdentity(page, TEST_IDENTITIES.tyler);
|
||
await installMockBridge(page, {
|
||
personas: [
|
||
{
|
||
id: "custom:release-analyst",
|
||
displayName: "Release Analyst",
|
||
systemPrompt: "Summarise the release.",
|
||
},
|
||
],
|
||
teams: [
|
||
{
|
||
id: "team-release-010",
|
||
name: "Release Crew",
|
||
description: "Ships the release notes.",
|
||
personaIds: ["custom:release-analyst"],
|
||
},
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
|
||
await openTeamCatalog(page);
|
||
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
|
||
await openTeamShareDialog(page, "Release Crew");
|
||
const catalogAccess = page.getByTestId("team-share-catalog-access");
|
||
await expect(catalogAccess).not.toBeChecked();
|
||
await setTeamCatalogAccess(page, true);
|
||
await expect(catalogAccess).toBeChecked();
|
||
await expect(
|
||
page.getByText("Published Release Crew to the community catalog."),
|
||
).toBeVisible();
|
||
await page
|
||
.getByTestId("team-share-dialog")
|
||
.getByRole("button", { name: "Close" })
|
||
.click();
|
||
|
||
await openTeamCatalog(page);
|
||
const ownEntry = page.locator('[data-testid^="community-catalog-team-"]');
|
||
await expect(ownEntry).toHaveCount(1);
|
||
await ownEntry.click();
|
||
await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
|
||
"Added by You",
|
||
);
|
||
// The publisher already has the team, so the catalog must not offer a copy.
|
||
await expect(page.getByTestId("community-catalog-add-team")).toBeDisabled();
|
||
await page.keyboard.press("Escape");
|
||
|
||
await openTeamShareDialog(page, "Release Crew");
|
||
await setTeamCatalogAccess(page, false);
|
||
await expect(
|
||
page.getByText(
|
||
"Release Crew is no longer discoverable in the community catalog.",
|
||
),
|
||
).toBeVisible();
|
||
await page
|
||
.getByTestId("team-share-dialog")
|
||
.getByRole("button", { name: "Close" })
|
||
.click();
|
||
|
||
await openTeamCatalog(page);
|
||
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
|
||
});
|
||
|
||
test("a queued team share is not presented as relay-published", async ({
|
||
page,
|
||
}) => {
|
||
await installMockBridge(page, {
|
||
personas: [
|
||
{
|
||
id: "custom:queued-analyst",
|
||
displayName: "Queued Analyst",
|
||
systemPrompt: "Wait for relay acceptance.",
|
||
},
|
||
],
|
||
teams: [
|
||
{
|
||
id: "team-queued-011",
|
||
name: "Queued Crew",
|
||
description: null,
|
||
personaIds: ["custom:queued-analyst"],
|
||
},
|
||
],
|
||
teamSharePublicationStatuses: ["queued"],
|
||
});
|
||
await gotoAgentsView(page);
|
||
|
||
await openTeamShareDialog(page, "Queued Crew");
|
||
await setTeamCatalogAccess(page, true);
|
||
await expect(
|
||
page.getByText(
|
||
"Sharing Queued Crew is queued. It will appear after the relay accepts the update.",
|
||
),
|
||
).toBeVisible();
|
||
await page
|
||
.getByTestId("team-share-dialog")
|
||
.getByRole("button", { name: "Close" })
|
||
.click();
|
||
|
||
await openTeamCatalog(page);
|
||
await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
|
||
});
|
||
|
||
test("expanding a member row reveals its metadata and instruction", async ({
|
||
page,
|
||
}) => {
|
||
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
|
||
await installMockBridge(page, {
|
||
teamCatalogEvents: [
|
||
createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice's Review Crew",
|
||
members: [
|
||
{
|
||
memberKey: "reviewer",
|
||
displayName: "Alice's Reviewer",
|
||
systemPrompt: "Review changes for the whole community.",
|
||
model: "claude-sonnet-4-5",
|
||
runtime: "claude-code",
|
||
provider: "anthropic",
|
||
},
|
||
],
|
||
}),
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
|
||
|
||
const memberRow = page.getByTestId("community-catalog-member-reviewer");
|
||
await expect(memberRow).toBeVisible();
|
||
|
||
// Metadata card and instruction are hidden before expansion.
|
||
await expect(memberRow.getByTestId("agent-definition-metadata")).toBeHidden();
|
||
|
||
// Expand the row.
|
||
await page.getByTestId("community-catalog-member-expand-reviewer").click();
|
||
|
||
// aria-expanded transitions to true.
|
||
await expect(
|
||
page.getByTestId("community-catalog-member-expand-reviewer"),
|
||
).toHaveAttribute("aria-expanded", "true");
|
||
|
||
// Metadata card is now visible and contains model/runtime/provider.
|
||
const metadata = memberRow.getByTestId("agent-definition-metadata");
|
||
await expect(metadata).toBeVisible();
|
||
await expect(metadata).toContainText("claude-sonnet-4-5");
|
||
await expect(metadata).toContainText("claude-code");
|
||
await expect(metadata).toContainText("anthropic");
|
||
|
||
// Instruction text is visible.
|
||
await expect(memberRow).toContainText(
|
||
"Review changes for the whole community.",
|
||
);
|
||
});
|
||
|
||
test("expanding a member with no system prompt shows the no-instructions placeholder", async ({
|
||
page,
|
||
}) => {
|
||
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
|
||
await installMockBridge(page, {
|
||
teamCatalogEvents: [
|
||
createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice's Review Crew",
|
||
members: [
|
||
{
|
||
memberKey: "reviewer",
|
||
displayName: "Alice's Reviewer",
|
||
systemPrompt: "",
|
||
},
|
||
],
|
||
}),
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
|
||
|
||
await page.getByTestId("community-catalog-member-expand-reviewer").click();
|
||
await expect(
|
||
page.getByTestId("community-catalog-member-reviewer"),
|
||
).toContainText("No instructions");
|
||
});
|
||
|
||
test("team instructions section is visible when the team has instructions set", async ({
|
||
page,
|
||
}) => {
|
||
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
|
||
await installMockBridge(page, {
|
||
teamCatalogEvents: [
|
||
createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice's Review Crew",
|
||
instructions: "Always check for security issues first.",
|
||
members: ALICE_TEAM_MEMBERS,
|
||
}),
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
|
||
|
||
const detail = page.getByTestId("community-catalog-detail-pane");
|
||
await expect(detail).toContainText("Team instructions");
|
||
await expect(detail).toContainText("Always check for security issues first.");
|
||
});
|
||
|
||
test("team instructions section is absent when instructions are not set", async ({
|
||
page,
|
||
}) => {
|
||
const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
|
||
await installMockBridge(page, {
|
||
teamCatalogEvents: [
|
||
createTeamCatalogEvent({
|
||
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
|
||
teamDTag: ALICE_TEAM_D_TAG,
|
||
name: "Alice's Review Crew",
|
||
members: ALICE_TEAM_MEMBERS,
|
||
}),
|
||
],
|
||
});
|
||
await gotoAgentsView(page);
|
||
await openTeamCatalog(page);
|
||
await page.getByTestId(`community-catalog-team-${entryKey}`).click();
|
||
|
||
const detail = page.getByTestId("community-catalog-detail-pane");
|
||
await expect(detail).not.toContainText("Team instructions");
|
||
});
|