diff --git a/AGENTS.md b/AGENTS.md index 4f03b312bc..dc29874efa 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 fcd8b13fc9..5728a8ac6b 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 96ed556068..5274a92cbf 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] @@ -694,3 +694,95 @@ fn mint_rejects_out_of_range_input_parallelism() { "input-branch error must not blame the definition: {err}" ); } + +// --- 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/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..a5eff1de07 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -246,7 +246,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 ( @@ -420,7 +421,6 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, - managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members,