Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,38 @@ async fn list_relay_agents_for_selection(
if let Some(channel_id) = channel_id {
membership_filter["#d"] = serde_json::json!([channel_id]);
}
let membership_events = query_all_relay_pages(state, membership_filter)
.await
.map_err(|error| format!("relay agent channel-membership query failed: {error}"))?;
let mut dm_metadata_filter = serde_json::json!({
"kinds": [39000],
"authors": [&relay_pubkey],
"#p": [&viewer_pubkey],
});
if let Some(channel_id) = channel_id {
dm_metadata_filter["#d"] = serde_json::json!([channel_id]);
}
let (membership_events, dm_metadata_events) = tokio::try_join!(
async {
query_all_relay_pages(state, membership_filter)
.await
.map_err(|error| format!("relay agent channel-membership query failed: {error}"))
},
async {
query_all_relay_pages(state, dm_metadata_filter)
.await
.map_err(|error| format!("relay agent DM-participant query failed: {error}"))
},
)?;
let mut member_agent_channel_ids =
nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey);
for (pubkey, channel_ids) in nostr_convert::dm_participant_channel_ids_from_events(
&dm_metadata_events,
&relay_pubkey,
&viewer_pubkey,
) {
let existing = member_agent_channel_ids.entry(pubkey).or_default();
existing.extend(channel_ids);
existing.sort();
existing.dedup();
}
if let Some(requested_pubkeys) = requested_pubkeys {
member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey));
}
Expand Down
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,9 +499,9 @@ pub fn agents_from_events(events: &[Event]) -> Value {

mod agent_directory;
pub use agent_directory::{
managed_agent_pubkeys_from_events, member_agent_channel_ids_from_events,
relay_agents_from_directory_events, relay_agents_from_managed_agent_events,
verified_agent_owners_from_profiles,
dm_participant_channel_ids_from_events, managed_agent_pubkeys_from_events,
member_agent_channel_ids_from_events, relay_agents_from_directory_events,
relay_agents_from_managed_agent_events, verified_agent_owners_from_profiles,
};

// ── kind:13534 (relay membership list) ──────────────────────────────────────
Expand Down
71 changes: 70 additions & 1 deletion desktop/src-tauri/src/nostr_convert/agent_directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use nostr::Event;

use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo};

use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, tags_named};
use super::{
agents_from_events, first_tag_value, has_tag, profile_valid_oa_owner_pubkey, tags_named,
};

/// Collect valid agent pubkeys from kind:30177 `d` tags for follow-up relay
/// queries. Malformed tags are ignored so one hostile event cannot invalidate
Expand Down Expand Up @@ -189,3 +191,70 @@ pub fn member_agent_channel_ids_from_events(
.map(|(pubkey, ids)| (pubkey, ids.into_iter().collect()))
.collect()
}

/// Build a pubkey-to-channel-id candidate map from relay-signed DM metadata.
///
/// Stream and forum agent membership carries an explicit `bot` role in
/// kind:39002. DM creation deliberately records every participant as an
/// ordinary member instead, while the current relay-signed kind:39000 head
/// carries the authoritative participant list in `p` tags. Only a current DM
/// head that includes the viewer can admit its other participants as agent
/// candidates; downstream signed runtime-directory and managed-policy checks
/// still decide which candidates are actually agents and whether they may
/// respond to that viewer.
pub fn dm_participant_channel_ids_from_events(
events: &[Event],
relay_pubkey: &str,
viewer_pubkey: &str,
) -> HashMap<String, Vec<String>> {
let Ok(viewer_pubkey) = nostr::PublicKey::from_hex(viewer_pubkey) else {
return HashMap::new();
};
let viewer_pubkey = viewer_pubkey.to_hex();

// Fail closed on stale replacement heads: choose exactly one current
// relay-signed metadata event per channel before reading participants.
let mut latest_by_channel: HashMap<String, &Event> = HashMap::new();
for event in events {
if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) {
continue;
}
if first_tag_value(event, "t") != Some("dm") && !has_tag(event, "hidden") {
continue;
}
let Some(channel_id) = first_tag_value(event, "d") else {
continue;
};
if latest_by_channel
.get(channel_id)
.is_none_or(|previous| event_is_newer(event, previous))
{
latest_by_channel.insert(channel_id.to_string(), event);
}
}

let mut channel_ids: HashMap<String, BTreeSet<String>> = HashMap::new();
for (channel_id, event) in latest_by_channel {
let participants: Vec<String> = tags_named(event, "p")
.filter_map(|tag| tag.get(1))
.filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok())
.map(|pubkey| pubkey.to_hex())
.collect();
if !participants.contains(&viewer_pubkey) {
continue;
}
for pubkey in participants {
if pubkey != viewer_pubkey {
channel_ids
.entry(pubkey)
.or_default()
.insert(channel_id.clone());
}
}
}

channel_ids
.into_iter()
.map(|(pubkey, ids)| (pubkey, ids.into_iter().collect()))
.collect()
}
83 changes: 83 additions & 0 deletions desktop/src-tauri/src/nostr_convert/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,89 @@ fn managed_agent_candidates_use_only_relay_signed_bot_membership() {
assert!(!channel_ids.contains_key(&stranger));
}

#[test]
fn managed_agent_candidates_include_relay_signed_dm_participants() {
let relay_keys = Keys::generate();
let viewer_pubkey = Keys::generate().public_key().to_hex();
let agent_pubkey = Keys::generate().public_key().to_hex();
let stranger = Keys::generate().public_key().to_hex();
let dm = EventBuilder::new(Kind::Custom(39000), "")
.tags([
Tag::parse(["d", "pm-dm"]).expect("parse d tag"),
Tag::parse(["t", "dm"]).expect("parse type tag"),
Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"),
Tag::parse(["p", &agent_pubkey]).expect("parse agent tag"),
])
.sign_with_keys(&relay_keys)
.expect("sign DM metadata");
let forged = ev(
39000,
"",
vec![
vec!["d", "forged-dm"],
vec!["t", "dm"],
vec!["p", &viewer_pubkey],
vec!["p", &stranger],
],
);
let stream = EventBuilder::new(Kind::Custom(39000), "")
.tags([
Tag::parse(["d", "general"]).expect("parse d tag"),
Tag::parse(["t", "stream"]).expect("parse type tag"),
Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"),
Tag::parse(["p", &stranger]).expect("parse stranger tag"),
])
.sign_with_keys(&relay_keys)
.expect("sign stream metadata");

let channel_ids = dm_participant_channel_ids_from_events(
&[forged, stream, dm],
&relay_keys.public_key().to_hex(),
&viewer_pubkey,
);

assert_eq!(
channel_ids.get(&agent_pubkey),
Some(&vec!["pm-dm".to_string()])
);
assert!(!channel_ids.contains_key(&viewer_pubkey));
assert!(!channel_ids.contains_key(&stranger));
}

#[test]
fn managed_agent_dm_candidates_follow_only_the_latest_metadata_head() {
let relay_keys = Keys::generate();
let viewer_pubkey = Keys::generate().public_key().to_hex();
let removed_agent_pubkey = Keys::generate().public_key().to_hex();
let old_head = EventBuilder::new(Kind::Custom(39000), "")
.tags([
Tag::parse(["d", "pm-dm"]).expect("parse d tag"),
Tag::parse(["hidden"]).expect("parse hidden tag"),
Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"),
Tag::parse(["p", &removed_agent_pubkey]).expect("parse agent tag"),
])
.custom_created_at(nostr::Timestamp::from(10))
.sign_with_keys(&relay_keys)
.expect("sign old DM metadata");
let new_head = EventBuilder::new(Kind::Custom(39000), "")
.tags([
Tag::parse(["d", "pm-dm"]).expect("parse d tag"),
Tag::parse(["t", "dm"]).expect("parse type tag"),
Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"),
])
.custom_created_at(nostr::Timestamp::from(20))
.sign_with_keys(&relay_keys)
.expect("sign new DM metadata");

let channel_ids = dm_participant_channel_ids_from_events(
&[old_head, new_head],
&relay_keys.public_key().to_hex(),
&viewer_pubkey,
);

assert!(channel_ids.is_empty());
}

#[test]
fn managed_agent_directory_query_pubkeys_reject_malformed_d_tags() {
let valid_pubkey = Keys::generate().public_key().to_hex();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ test("getMentionableAgentPubkeys: scopes channel composers and fails closed with
test("autocomplete helper extraction preserves safe filtering and labels", () => {
assert.equal(isAgentMentionChannelType("stream"), true);
assert.equal(isAgentMentionChannelType("forum"), true);
assert.equal(isAgentMentionChannelType("dm"), false);
assert.equal(isAgentMentionChannelType("dm"), true);
assert.equal(isAgentMentionChannelType(null), false);

assert.deepEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function filterAdmittedMentionPubkeys(
}

export function isAgentMentionChannelType(type?: string | null) {
return type === "stream" || type === "forum";
return type === "stream" || type === "forum" || type === "dm";
}

export function uniqueAutocompleteLabels(
Expand Down
15 changes: 14 additions & 1 deletion desktop/src/features/channels/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,20 @@ export function useAddChannelMembersMutation(channelId: string | null) {
// Invalidate the effective channel (the one actually mutated) not the
// live hook-closure channel, which may have changed mid-send.
const effectiveChannelId = variables?.channelId ?? channelId;
await invalidateChannelState(queryClient, effectiveChannelId);
await Promise.all([
invalidateChannelState(queryClient, effectiveChannelId),
// Relay-agent discovery is membership-backed. Without this refresh,
// adding an existing agent through the members picker leaves the
// autocomplete directory stale and the same-named local persona can
// be offered as a new agent until the five-minute focus refresh.
...(variables?.role === "bot"
? [
queryClient.invalidateQueries({
queryKey: ["relay-agents"],
}),
]
: []),
]);
},
});
}
Expand Down
55 changes: 55 additions & 0 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,61 @@ test("relay-only shared agents stay hidden from DM mentions", async ({
await expect(autocomplete(page)).toHaveCount(0);
});

test("an existing remote agent in a DM is mentioned without creating a local runtime", async ({
page,
}) => {
const remoteAgentPubkey = "7".repeat(64);
await installMockBridge(page, {
acpRuntimesCatalog: [],
personas: [
{
id: "pm-persona",
displayName: "PM Bot",
systemPrompt: "Own product management.",
isActive: true,
},
],
relayAgents: [
{
pubkey: remoteAgentPubkey,
ownerPubkey: MOCK_VIEWER_PUBKEY,
name: "PM Bot",
channelNames: ["alice-tyler"],
respondTo: "owner-only",
},
],
searchProfiles: [
{
pubkey: remoteAgentPubkey,
displayName: "PM Bot",
ownerPubkey: MOCK_VIEWER_PUBKEY,
isAgent: true,
},
],
});
await page.goto("/");
await expect(page.getByTestId("channel-alice-tyler")).toBeVisible();
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");

const input = page.getByTestId("message-input");
await input.fill("@PM Bot");
await autocomplete(page)
.getByTestId(`mention-suggestion-${remoteAgentPubkey}`)
.click();
await input.press("End");
await input.type("keep going");
await input.press("Enter");

await expect
.poll(() => readOutgoingMentionPubkeys(page, "@PM Bot keep going"))
.toContain(remoteAgentPubkey);
expect(await readCommandLog(page)).not.toContain("create_managed_agent");
await expect(
page.getByText(/No agent runtime available/, { exact: false }),
).toHaveCount(0);
});

test("cached relay-agent suggestions are removed when channel authorization disappears", async ({
page,
}) => {
Expand Down