diff --git a/AGENTS.md b/AGENTS.md index 571871c3a4..baceb9ddb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -431,6 +431,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803). 5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly. 6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected. 7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing. +8. **Tauri command structs must serialize camelCase** — a `#[derive(Serialize)]` struct returned from a `#[tauri::command]` crosses into TypeScript with whatever casing serde emits, and the frontend types are camelCase. Without `#[serde(rename_all = "camelCase")]` every multi-word field arrives `undefined` — no error, no type failure (the TS type asserts a shape nothing verifies at runtime), just silently falsy logic. If the same struct also deserializes a snake_case wire format (Nostr event content), keep per-field `#[serde(alias = "...")]` so both directions work, and pin both with tests. See `RelayAgentInfo` in `desktop/src-tauri/src/managed_agents/types/relay_agent_info.rs`. --- diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index c5bb6173d1..d6aba099fe 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -193,21 +193,6 @@ impl ManagedAgentRecord { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RelayAgentInfo { - pub pubkey: String, - pub name: String, - pub agent_type: String, - pub channels: Vec, - #[serde(default)] - pub channel_ids: Vec, - pub capabilities: Vec, - pub status: String, - #[serde(default)] - pub respond_to: Option, - #[serde(default)] - pub respond_to_allowlist: Vec, -} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ManagedAgentRecord { pub pubkey: String, @@ -992,6 +977,8 @@ pub fn resolve_mint_behavioral_defaults( mod catalog_source; pub use catalog_source::CatalogSource; +mod relay_agent_info; +pub use relay_agent_info::RelayAgentInfo; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/relay_agent_info.rs b/desktop/src-tauri/src/managed_agents/types/relay_agent_info.rs new file mode 100644 index 0000000000..146df6178c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/relay_agent_info.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +use super::RespondTo; + +/// A relay-published agent directory entry (kind:10100), as handed to the +/// frontend by `list_relay_agents`. +/// +/// This type crosses two boundaries with opposite casing conventions, and +/// getting either wrong fails silently — the frontend just sees `undefined`, +/// and every relay-published agent quietly stops being mentionable. +/// +/// * **Serializes camelCase.** The TypeScript `RelayAgent` type reads +/// `agentType` / `channelIds` / `respondTo` / `respondToAllowlist`. Emitting +/// snake_case here left all four `undefined`, so `relayAgentIsSharedWithUser` +/// could never return true for any relay agent. +/// * **Deserializes either casing.** kind:10100 event content is snake_case +/// (see `agents_from_events`), so every renamed field keeps a snake_case +/// `alias`. Dropping those would break directory parsing. +/// +/// Both directions are pinned by tests in `types/tests.rs`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayAgentInfo { + pub pubkey: String, + pub name: String, + #[serde(alias = "agent_type")] + pub agent_type: String, + pub channels: Vec, + #[serde(default, alias = "channel_ids")] + pub channel_ids: Vec, + pub capabilities: Vec, + pub status: String, + #[serde(default, alias = "respond_to")] + pub respond_to: Option, + #[serde(default, alias = "respond_to_allowlist")] + pub respond_to_allowlist: Vec, +} diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b524..2a3d2ead7b 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -1,4 +1,4 @@ -use super::{AgentDefinition, CatalogSource, ManagedAgentRecord}; +use super::{AgentDefinition, CatalogSource, ManagedAgentRecord, RelayAgentInfo, RespondTo}; use std::path::PathBuf; #[test] @@ -784,3 +784,95 @@ fn summary_with_drift_serializes_restart_diff_entries() { }])) ); } + +// --- RelayAgentInfo wire format ------------------------------------------- +// +// This struct crosses two boundaries with different casing conventions, and +// getting either wrong is silent: the frontend simply sees `undefined` and +// every relay agent becomes un-mentionable. + +#[test] +fn relay_agent_info_serializes_camel_case_for_the_frontend() { + // The TS `RelayAgent` type reads agentType/channelIds/respondTo/ + // respondToAllowlist. Emitting snake_case here made those fields + // undefined, so `relayAgentIsSharedWithUser` always returned false. + let info = RelayAgentInfo { + pubkey: "aa".repeat(32), + name: "Scout".to_string(), + agent_type: "agent".to_string(), + channels: vec!["general".to_string()], + channel_ids: vec!["c1".to_string()], + capabilities: Vec::new(), + status: "online".to_string(), + respond_to: Some(RespondTo::Anyone), + respond_to_allowlist: vec!["bb".repeat(32)], + }; + + let json = serde_json::to_value(&info).expect("serialize"); + + assert!(json.get("agentType").is_some(), "agentType missing: {json}"); + assert!( + json.get("channelIds").is_some(), + "channelIds missing: {json}" + ); + assert!(json.get("respondTo").is_some(), "respondTo missing: {json}"); + assert!( + json.get("respondToAllowlist").is_some(), + "respondToAllowlist missing: {json}" + ); + + // The snake_case spellings must be gone, not merely duplicated — a + // frontend reading either name should not silently keep working. + assert!(json.get("agent_type").is_none(), "stale agent_type: {json}"); + assert!( + json.get("channel_ids").is_none(), + "stale channel_ids: {json}" + ); + assert!(json.get("respond_to").is_none(), "stale respond_to: {json}"); +} + +#[test] +fn relay_agent_info_still_parses_snake_case_directory_content() { + // kind:10100 event content is snake_case. The camelCase rename must not + // break directory parsing, hence the per-field aliases. + let parsed: RelayAgentInfo = serde_json::from_str( + r#"{"pubkey":"aa","name":"Scout","agent_type":"agent","channels":[], + "channel_ids":["c1"],"capabilities":[],"status":"online", + "respond_to":"anyone","respond_to_allowlist":["bb"]}"#, + ) + .expect("snake_case directory content must still parse"); + + assert_eq!(parsed.agent_type, "agent"); + assert_eq!(parsed.channel_ids, vec!["c1".to_string()]); + assert_eq!(parsed.respond_to, Some(RespondTo::Anyone)); + assert_eq!(parsed.respond_to_allowlist, vec!["bb".to_string()]); +} + +#[test] +fn relay_agent_info_round_trips_through_its_own_camel_case_output() { + // What the frontend receives must be re-readable by the same type; + // otherwise any future write-back path breaks. + let info = RelayAgentInfo { + pubkey: "aa".repeat(32), + name: "Scout".to_string(), + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: vec!["c1".to_string()], + capabilities: Vec::new(), + status: "online".to_string(), + respond_to: Some(RespondTo::Allowlist), + respond_to_allowlist: vec!["bb".repeat(32)], + }; + + let round_tripped: RelayAgentInfo = + serde_json::from_value(serde_json::to_value(&info).expect("serialize")) + .expect("deserialize"); + + assert_eq!(round_tripped.agent_type, info.agent_type); + assert_eq!(round_tripped.channel_ids, info.channel_ids); + assert_eq!(round_tripped.respond_to, info.respond_to); + assert_eq!( + round_tripped.respond_to_allowlist, + info.respond_to_allowlist + ); +} diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..03f91481c7 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -306,3 +306,144 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { assert.deepEqual(coalesce([first, second]), [first, second]); }); + +// ── Gate composition (mirrors useMentions.addCandidate) ──────────────── +// +// `useMentions` runs two gates in order: `isAgentIdentityInManagedList`, then +// `shouldHideAgentFromMentions`. The first was originally passed the +// locally-managed set, which dropped every relay-published (headless/BYO) +// agent before the directory-aware second gate could admit it — so such an +// agent was never mentionable regardless of its kind:10100 entry. The call +// site now passes the invocable set; these tests pin that composition. + +function survivesMentionGates({ + candidate, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, + currentPubkey = CURRENT_PUBKEY, +}) { + const mentionableAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, + }); + const directoryAgentPubkeys = new Set( + relayAgents.map((agent) => agent.pubkey), + ); + + // Gate 1 — must use the invocable set, not the locally-managed one. + if (!isAgentIdentityInManagedList(candidate, mentionableAgentPubkeys)) { + return false; + } + // Gate 2 — the directory-aware policy. + return !shouldHideAgentFromMentions({ + isAgent: candidate.isAgent === true, + isMember: candidate.isMember === true, + pubkey: candidate.pubkey, + mentionableAgentPubkeys, + directoryAgentPubkeys, + }); +} + +test("mention gates: a shared relay agent survives without being locally managed", () => { + const relayAgents = [ + { + pubkey: PUB_B, + channelIds: ["chan-1"], + respondTo: "anyone", + respondToAllowlist: [], + }, + ]; + + assert.equal( + survivesMentionGates({ + candidate: { isAgent: true, isMember: true, pubkey: PUB_B }, + managedAgentPubkeys: new Set(), + relayAgents, + sharedChannelIds: new Set(["chan-1"]), + }), + true, + "a relay agent advertising respond_to=anyone in a shared channel must be mentionable", + ); +}); + +test("mention gates: an allowlisted relay agent survives for the listed user", () => { + const relayAgents = [ + { + pubkey: PUB_B, + channelIds: ["chan-1"], + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + }, + ]; + + assert.equal( + survivesMentionGates({ + candidate: { isAgent: true, isMember: true, pubkey: PUB_B }, + managedAgentPubkeys: new Set(), + relayAgents, + sharedChannelIds: new Set(["chan-1"]), + }), + true, + ); +}); + +test("mention gates: a non-invocable relay agent is still dropped", () => { + const relayAgents = [ + { + pubkey: PUB_B, + channelIds: ["chan-other"], + respondTo: "anyone", + respondToAllowlist: [], + }, + ]; + + assert.equal( + survivesMentionGates({ + candidate: { isAgent: true, isMember: true, pubkey: PUB_B }, + managedAgentPubkeys: new Set(), + relayAgents, + sharedChannelIds: new Set(["chan-1"]), + }), + false, + "widening gate 1 must not admit agents that share no channel with us", + ); +}); + +test("mention gates: locally managed agents keep working", () => { + assert.equal( + survivesMentionGates({ + candidate: { isAgent: true, isMember: true, pubkey: PUB_A }, + managedAgentPubkeys: new Set([PUB_A]), + relayAgents: [], + sharedChannelIds: new Set(), + }), + true, + ); +}); + +// The composition tests above pin the *policy*, but they call the gates +// directly — they cannot catch the call site in `useMentions` narrowing gate 1 +// back to the locally-managed set, which is exactly the regression that made +// every relay-published agent un-mentionable. Guard the call site itself, in +// the spirit of desktop/scripts/check-px-text.mjs. +test("useMentions gates agent identities on the invocable set, not the managed set", async () => { + const { readFile } = await import("node:fs/promises"); + const source = await readFile( + new URL("../../messages/lib/useMentions.ts", import.meta.url), + "utf8", + ); + + const call = source.match( + /isAgentIdentityInManagedList\(\s*candidate,\s*(\w+)/, + ); + assert.ok(call, "expected an isAgentIdentityInManagedList call site"); + assert.equal( + call[1], + "mentionableAgentPubkeys", + "gate 1 must receive the invocable set; passing managedAgentPubkeys drops " + + "every relay-published agent before the directory-aware gate runs", + ); +}); diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index adfb8182a8..3b1826dcd5 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -23,7 +23,11 @@ import { Button } from "@/shared/ui/button"; 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 { + setManagedAgentAutoRestart, + startManagedAgent, + stopManagedAgent, +} from "@/shared/api/tauriManagedAgents"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { ADVANCED_FIELDS_MOTION_TRANSITION, @@ -723,6 +727,8 @@ export function AgentInstanceEditDialog({ : undefined, }; + const accessPolicyChanged = + input.respondTo !== undefined || input.respondToAllowlist !== undefined; const result = await updateMutation.mutateAsync(input); if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { // Standalone setter (mirrors start-on-app-launch) — not part of @@ -732,20 +738,45 @@ export function AgentInstanceEditDialog({ autoRestartOnConfigChange, ); } - showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); + let savedAgent = result.agent; + if ( + accessPolicyChanged && + isManagedAgentActive(result.agent) && + result.agent.needsRestart + ) { + toast.loading(`Applying ${result.agent.name}'s access policy...`, { + id: `agent-policy-restart-${result.agent.pubkey}`, + }); + try { + await stopManagedAgent(result.agent.pubkey); + savedAgent = await startManagedAgent(result.agent.pubkey); + toast.success(`${savedAgent.name}'s access policy is live.`, { + id: `agent-policy-restart-${savedAgent.pubkey}`, + }); + } catch (error) { + toast.error( + error instanceof Error + ? `Access policy saved, but ${result.agent.name} failed to restart: ${error.message}` + : `Access policy saved, but ${result.agent.name} failed to restart.`, + { id: `agent-policy-restart-${result.agent.pubkey}` }, + ); + throw error; + } + } + showAgentProfileSyncWarning(savedAgent.name, result.profileSyncError); handleOpenChange(false); - onUpdated?.(result.agent); + onUpdated?.(savedAgent); // The auto-restart policy deliberately never fires for a stopped or // failing agent (a broken agent must not auto-loop), so an edit meant // to FIX one silently waits for a manual start. Offer that start // explicitly instead of relying on the user to know the policy. - if (!isManagedAgentActive(result.agent)) { - const startedName = result.agent.name; + if (!isManagedAgentActive(savedAgent)) { + const startedName = savedAgent.name; toast(`${startedName} saved while stopped.`, { action: { label: "Start now", onClick: () => { - startMutation.mutate(result.agent.pubkey, { + startMutation.mutate(savedAgent.pubkey, { onSuccess: () => toast.success(`${startedName} started.`), onError: (error) => toast.error( diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 0498bef9ae..337d1a9675 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -25,6 +25,7 @@ export type MentionCandidate = { isAgent: boolean; isManagedAgent?: boolean; isGlobalSearchResult?: boolean; + unavailableReason?: string | null; }; export function mentionCandidateLabel(candidate: MentionCandidate) { diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index c710cf613b..329d4f1a7c 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -16,6 +16,7 @@ export type MentionSuggestionCandidate = { isMember: boolean; role?: ChannelRole | null; ownerPubkey?: string | null; + unavailableReason?: string | null; }; export function mapMentionCandidateToSuggestion(opts: { @@ -58,5 +59,6 @@ export function mapMentionCandidateToSuggestion(opts: { candidate.isMember === false, ownerLabel, role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, + unavailableReason: candidate.unavailableReason ?? null, }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..c6e046afd1 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -179,6 +179,16 @@ export function useMentions( ), [relayAgentsQuery.data], ); + const relayAgentsByPubkey = React.useMemo( + () => + new Map( + (relayAgentsQuery.data ?? []).map((agent) => [ + normalizePubkey(agent.pubkey), + agent, + ]), + ), + [relayAgentsQuery.data], + ); const directoryAgentPubkeys = React.useMemo( () => new Set( @@ -246,7 +256,8 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + // Invocable set, not the managed one — see the gate tests for why. + if (!isAgentIdentityInManagedList(candidate, mentionableAgentPubkeys)) { return; } if ( @@ -290,6 +301,8 @@ export function useMentions( : null) ?? null, isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, + unavailableReason: + current.unavailableReason ?? candidate.unavailableReason ?? null, }); }; for (const member of members ?? []) { @@ -302,6 +315,7 @@ export function useMentions( relayAgentNamesByPubkey.get(pubkey) ?? null; const profile = profiles?.[pubkey] ?? null; + const relayAgent = relayAgentsByPubkey.get(pubkey); addCandidate({ kind: "identity", pubkey, @@ -328,6 +342,10 @@ export function useMentions( profile?.displayName?.trim() && profile?.nip05Handle?.trim() ? profile.nip05Handle : null, + unavailableReason: + relayAgent?.status === "offline" + ? `${agentName ?? "Agent"} is offline and cannot be invoked.` + : null, }); } @@ -343,6 +361,11 @@ export function useMentions( (activePersonaById.has(pubkey) ? pubkey : undefined), ownerPubkey: null, isAgent: true, + unavailableReason: memberPubkeys.has(pubkey) + ? agent.status === "offline" + ? `${agent.name} is offline and cannot be invoked.` + : null + : `${agent.name} is not a member of this channel.`, }); } @@ -420,7 +443,6 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, - managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members, @@ -428,6 +450,7 @@ export function useMentions( personaNameByPubkey, profiles, relayAgentNamesByPubkey, + relayAgentsByPubkey, relayAgentsQuery.data, ]); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 508e35f402..a5c08d016f 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -12,6 +12,7 @@ import { import { UserAvatar } from "@/shared/ui/UserAvatar"; import { safeNpub } from "@/shared/lib/nostrUtils"; import { truncatePubkey } from "@/shared/lib/pubkey"; +import { toast } from "sonner"; export type MentionSuggestion = { pubkey?: string; @@ -25,6 +26,7 @@ export type MentionSuggestion = { notInChannel?: boolean; ownerLabel?: string | null; role?: string | null; + unavailableReason?: string | null; }; type MentionAutocompleteProps = { @@ -120,6 +122,10 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ key={suggestionKey} onMouseDown={(event) => { event.preventDefault(); + if (suggestion.unavailableReason) { + toast.error(suggestion.unavailableReason); + return; + } onSelect(suggestion); }} tabIndex={-1} @@ -169,7 +175,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ className="h-3.5 w-3.5" data-testid="mention-agent-icon" /> - {agentLabel} + {suggestion.unavailableReason ?? agentLabel} ) : suggestion.role ? ( member.pubkey === seed.pubkey)) { + continue; + } + channel.members.push({ + pubkey: seed.pubkey, + role: "bot", + is_agent: true, + joined_at: new Date().toISOString(), + display_name: seed.name, + }); + syncMockChannel(channel); + touchMockChannel(channel); + } } } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index d9dcffa2e4..bc7165fc4f 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -826,9 +826,10 @@ test("managed relay agents are visible in channel mentions regardless of relay p await expect(dropdown.getByText("agent")).toBeVisible(); }); -test("relay-only agents stay hidden from channel mentions even when allowlisted", async ({ +test("allowlisted foreign channel agents are visible and publish their exact pubkey", async ({ page, }) => { + const sameNameOwnerAgent = "abababab".repeat(8); await installMockBridge(page, { relayAgents: [ { @@ -836,6 +837,14 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" name: "quinn", respondTo: "allowlist", respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + { + pubkey: sameNameOwnerAgent, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], }, ], }); @@ -846,7 +855,59 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" const input = page.getByTestId("message-input"); await input.fill("@quinn"); - await expect(autocomplete(page)).toHaveCount(0); + const dropdown = autocomplete(page); + await expect(dropdown.getByText("quinn", { exact: true })).toHaveCount(2); + await expect(dropdown.getByTestId("mention-collision-npub")).toHaveCount(2); + await dropdown + .getByTestId(`mention-suggestion-${ALLOWLIST_RELAY_AGENT_PUBKEY}`) + .click(); + await page.keyboard.type(" please investigate"); + await page.getByTestId("send-message").click(); + + await expect + .poll(async () => { + return page.evaluate(() => window.__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)); + }) + .toEqual( + expect.objectContaining({ + tags: expect.arrayContaining([["p", ALLOWLIST_RELAY_AGENT_PUBKEY]]), + }), + ); + const published = await page.evaluate(() => + window.__BUZZ_E2E_SIGNED_EVENTS__?.at(-1), + ); + expect(published?.tags).not.toContainEqual(["p", sameNameOwnerAgent]); +}); + +test("authorized offline agents report a clear error and do not send", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + status: "offline", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + + await autocomplete(page).getByText("quinn", { exact: true }).click(); + await expect( + page.getByText("quinn is offline and cannot be invoked."), + ).toBeVisible(); + expect( + (await readCommandPayloadLog(page)).filter( + (entry) => entry.command === "send_channel_message", + ), + ).toHaveLength(0); }); test("mentioning an in-channel stopped managed agent starts it before sending", async ({