Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down
17 changes: 2 additions & 15 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default)]
pub channel_ids: Vec<String>,
pub capabilities: Vec<String>,
pub status: String,
#[serde(default)]
pub respond_to: Option<RespondTo>,
#[serde(default)]
pub respond_to_allowlist: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ManagedAgentRecord {
pub pubkey: String,
Expand Down Expand Up @@ -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::*;

Expand Down
37 changes: 37 additions & 0 deletions desktop/src-tauri/src/managed_agents/types/relay_agent_info.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(default, alias = "channel_ids")]
pub channel_ids: Vec<String>,
pub capabilities: Vec<String>,
pub status: String,
#[serde(default, alias = "respond_to")]
pub respond_to: Option<RespondTo>,
#[serde(default, alias = "respond_to_allowlist")]
pub respond_to_allowlist: Vec<String>,
}
94 changes: 93 additions & 1 deletion desktop/src-tauri/src/managed_agents/types/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use super::{AgentDefinition, CatalogSource, ManagedAgentRecord};
use super::{AgentDefinition, CatalogSource, ManagedAgentRecord, RelayAgentInfo, RespondTo};
use std::path::PathBuf;

#[test]
Expand Down Expand Up @@ -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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
4 changes: 2 additions & 2 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -420,7 +421,6 @@ export function useMentions(
managedAgentNamesByPubkey,
managedAgentPersonaIds,
managedAgentPersonaIdsByPubkey,
managedAgentPubkeys,
managedAgentsQuery.data,
memberPubkeys,
members,
Expand Down