Merge remote-tracking branch 'origin/main' into hayt/usage-first-turn-baseline

* origin/main:
  Render mobile agent mention chips (#3702)
  fix(catalog): update Amp description (#3758)
  fix(acp): preserve truncated thread context (#3340)

Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw
2026-07-30 13:11:43 -04:00
28 changed files with 1922 additions and 213 deletions

View File

@@ -19,6 +19,7 @@
//!
//! `AcpClient` is NOT Clone — ownership moves out on claim and back on return.
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -800,6 +801,9 @@ pub enum IdleSwitchResult {
/// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s.
const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000);
/// Short, single-attempt timeout for best-effort exact truncated-thread counts.
const CONTEXT_COUNT_TIMEOUT: Duration = Duration::from_millis(500);
/// Delay between the first failed context fetch and the single retry.
const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500);
@@ -2605,7 +2609,14 @@ async fn fetch_conversation_context(
let last_event = batch.events.last()?;
let tags = crate::queue::parse_thread_tags(&last_event.event);
if let Some(root_id) = tags.root_event_id {
return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await;
return fetch_thread_context(
batch.channel_id,
&root_id,
limit,
ctx.agent_keys.public_key(),
&ctx.rest_client,
)
.await;
}
// DM non-reply: fetch recent conversation history.
@@ -2767,12 +2778,48 @@ async fn fetch_prompt_profile_lookup(
}
/// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag.
///
/// The reply query intentionally requests one more reply than the configured
/// display window. That sentinel event lets the prompt say `N of M, truncated`
/// when the relay has more thread history, instead of reporting the capped page
/// as the total. When the window is full, a best-effort `/count` attempts to
/// improve that lower-bound total; because it is a separate racy request, the
/// result is clamped to the sentinel-proven minimum. The query also asks for the
/// agent's newest reply separately so the next prompt can include the agent's
/// own prior turn even in busy threads where the recent-message window would
/// otherwise push it out.
async fn fetch_thread_context(
channel_id: Uuid,
root_event_id: &str,
limit: u32,
agent_pubkey: nostr::PublicKey,
rest: &RestClient,
) -> Option<ConversationContext> {
fetch_thread_context_with(
channel_id,
root_event_id,
limit,
agent_pubkey,
|filters| async move { rest.query(&filters).await },
|filters| async move { rest.count(&filters).await },
)
.await
}
async fn fetch_thread_context_with<Query, QueryFut, Count, CountFut>(
channel_id: Uuid,
root_event_id: &str,
limit: u32,
agent_pubkey: nostr::PublicKey,
query: Query,
count: Count,
) -> Option<ConversationContext>
where
Query: Fn(Vec<nostr::Filter>) -> QueryFut,
QueryFut: std::future::Future<Output = Result<serde_json::Value, crate::relay::RelayError>>,
Count: Fn(Vec<nostr::Filter>) -> CountFut,
CountFut: std::future::Future<Output = Result<serde_json::Value, crate::relay::RelayError>>,
{
use nostr::{Alphabet, SingleLetterTag};
// Defense-in-depth: validate hex event ID.
@@ -2791,7 +2838,8 @@ async fn fetch_thread_context(
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
let ch_str = channel_id.to_string();
// Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel.
// Three filters: (1) root event by ID, (2) recent replies with #e=root +
// #h=channel plus a sentinel, and (3) the agent's newest reply for pinning.
let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?);
let replies_filter = nostr::Filter::new()
.kinds([
@@ -2800,16 +2848,23 @@ async fn fetch_thread_context(
])
.custom_tags(e_tag, [root_event_id])
.custom_tags(h_tag, [ch_str.as_str()])
.limit(limit as usize);
.limit(limit.saturating_add(1) as usize);
let agent_reply_filter = replies_filter.clone().author(agent_pubkey).limit(1);
fetch_with_retry(|| async {
let context = fetch_with_retry(|| async {
match timeout(
CONTEXT_FETCH_TIMEOUT,
rest.query(&[root_filter.clone(), replies_filter.clone()]),
query(vec![
root_filter.clone(),
replies_filter.clone(),
agent_reply_filter.clone(),
]),
)
.await
{
Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id),
Ok(Ok(json)) => {
parse_nostr_thread_response_with_meta(json, root_event_id, limit, &agent_pubkey)
}
Ok(Err(e)) => {
tracing::warn!(
channel_id = %channel_id,
@@ -2828,7 +2883,75 @@ async fn fetch_thread_context(
}
}
})
.await
.await;
let mut parsed = context?;
if matches!(
parsed.context,
ConversationContext::Thread {
truncated: true,
..
}
) {
let replies_count_filter = replies_filter.clone().limit(0);
if let Some(total) = fetch_thread_total(
channel_id,
&replies_count_filter,
parsed.root_present,
&count,
)
.await
{
if let ConversationContext::Thread {
total: context_total,
..
} = &mut parsed.context
{
let sentinel_minimum = *context_total;
// `/count` is a separate best-effort request after the message
// query. If replies are deleted between the two, the exact count
// can fall below the already-proven sentinel minimum; never
// render impossible labels like `13 of 12 messages, truncated`.
*context_total = total.max(sentinel_minimum);
}
}
}
Some(parsed.context)
}
/// Best-effort exact thread size for truncated context labels.
async fn fetch_thread_total<Count, CountFut>(
channel_id: Uuid,
replies_filter: &nostr::Filter,
root_present: bool,
count: &Count,
) -> Option<usize>
where
Count: Fn(Vec<nostr::Filter>) -> CountFut,
CountFut: std::future::Future<Output = Result<serde_json::Value, crate::relay::RelayError>>,
{
let replies_count =
match timeout(CONTEXT_COUNT_TIMEOUT, count(vec![replies_filter.clone()])).await {
Ok(Ok(json)) => json.get("count").and_then(|v| v.as_u64())?,
Ok(Err(e)) => {
tracing::debug!(
channel_id = %channel_id,
"thread context count failed; using sentinel minimum: {e}"
);
return None;
}
Err(_) => {
tracing::debug!(
channel_id = %channel_id,
"thread context count timed out; using sentinel minimum"
);
return None;
}
};
Some(replies_count as usize + usize::from(root_present))
}
/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag.
@@ -2981,48 +3104,110 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option<ContextMessage> {
/// Parse a Nostr query response (array of events) into thread context.
///
/// Separates the root event (matching `root_event_id`) from replies, sorts
/// chronologically by `created_at`.
/// Separates the root event (matching `root_event_id`) from replies, keeps the
/// newest `limit` replies returned by the sentinel query, then sorts the
/// displayed window chronologically for the prompt. If the agent's newest reply
/// is outside that window, keep it instead of the oldest displayed reply so the
/// next prompt always includes the agent's most recent prior turn.
#[cfg(test)]
fn parse_nostr_thread_response(
json: serde_json::Value,
root_event_id: &str,
limit: u32,
agent_pubkey: &nostr::PublicKey,
) -> Option<ConversationContext> {
parse_nostr_thread_response_with_meta(json, root_event_id, limit, agent_pubkey)
.map(|parsed| parsed.context)
}
struct ParsedThreadContext {
context: ConversationContext,
root_present: bool,
}
fn parse_nostr_thread_response_with_meta(
json: serde_json::Value,
root_event_id: &str,
limit: u32,
agent_pubkey: &nostr::PublicKey,
) -> Option<ParsedThreadContext> {
let events = json.as_array()?;
let agent_pubkey_hex = agent_pubkey.to_hex();
let mut root_msg = None;
let mut reply_msgs = Vec::new();
let mut seen_reply_ids = HashSet::new();
for ev in events {
let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(msg) = json_to_context_message(ev) {
if ev_id == root_event_id {
root_msg = Some(msg);
} else {
} else if seen_reply_ids.insert(ev_id.to_string()) {
let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex);
reply_msgs.push((
ev_id.to_string(),
ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
is_agent,
msg,
));
}
}
}
// Sort replies chronologically.
reply_msgs.sort_by_key(|(ts, _)| *ts);
let root_present = root_msg.is_some();
let fetched_total = reply_msgs.len() + usize::from(root_present);
let newest_agent_reply = reply_msgs
.iter()
.filter(|(_, _, is_agent, _)| *is_agent)
.max_by_key(|(_, ts, _, _)| *ts)
.cloned();
let truncated = reply_msgs.len() > limit as usize;
if truncated {
// The relay returns limited REQ results newest-first. Sort explicitly so
// the sentinel we drop is the oldest reply in the fetched window, not an
// arbitrary last element if the HTTP bridge ever changes iteration order.
reply_msgs.sort_by_key(|(_, ts, _, _)| Reverse(*ts));
reply_msgs.truncate(limit as usize);
}
if let Some(agent_reply) = newest_agent_reply {
let agent_reply_already_displayed =
reply_msgs.iter().any(|(id, _, _, _)| *id == agent_reply.0);
if !agent_reply_already_displayed {
reply_msgs.sort_by_key(|(_, ts, _, _)| *ts);
if let Some(oldest) = reply_msgs.first_mut() {
*oldest = agent_reply;
}
}
}
// Sort displayed replies chronologically.
reply_msgs.sort_by_key(|(_, ts, _, _)| *ts);
let mut messages = Vec::new();
if let Some(root) = root_msg {
messages.push(root);
}
messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg));
messages.extend(reply_msgs.into_iter().map(|(_, _, _, msg)| msg));
let total = messages.len();
if messages.is_empty() {
return None;
}
Some(ConversationContext::Thread {
messages,
total,
truncated: false, // query returns all within limit
let total = if truncated {
fetched_total // all distinct fetched replies plus the root are proven visible history
} else {
messages.len()
};
Some(ParsedThreadContext {
context: ConversationContext::Thread {
messages,
total,
truncated,
},
root_present,
})
}
@@ -4209,6 +4394,572 @@ mod tests {
assert!(parse_dm_response(json, 12).is_none());
}
#[test]
fn test_parse_nostr_thread_response_marks_query_window_truncated() {
let agent = Keys::generate();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let agent_hex = agent.public_key().to_hex();
let json = json!([
{
"id": root_id,
"pubkey": "rootpub",
"content": "root",
"created_at": 1000
},
{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pubkey": agent_hex,
"content": "newest agent reply",
"created_at": 4000
},
{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"pubkey": "humanpub",
"content": "middle reply",
"created_at": 3000
},
{
"id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"pubkey": "oldpub",
"content": "sentinel omitted reply",
"created_at": 2000
}
]);
let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
.expect("should parse");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert_eq!(messages.len(), 3); // root + 2 displayed replies
assert_eq!(total, 4); // root + displayed replies + sentinel
assert!(truncated);
assert_eq!(messages[0].content, "root");
assert_eq!(messages[1].content, "middle reply");
assert_eq!(messages[2].content, "newest agent reply");
assert!(messages
.iter()
.all(|msg| msg.content != "sentinel omitted reply"));
}
_ => panic!("expected Thread context"),
}
}
#[test]
fn test_parse_nostr_thread_response_not_truncated_below_limit() {
let agent = Keys::generate();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let json = json!([
{
"id": root_id,
"pubkey": "rootpub",
"content": "root",
"created_at": 1000
},
{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pubkey": "replypub",
"content": "reply",
"created_at": 2000
}
]);
let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
.expect("should parse");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert_eq!(messages.len(), 2);
assert_eq!(total, 2);
assert!(!truncated);
}
_ => panic!("expected Thread context"),
}
}
#[test]
fn test_parse_nostr_thread_response_keeps_agent_reply_outside_recent_window() {
let agent = Keys::generate();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let agent_hex = agent.public_key().to_hex();
let json = json!([
{
"id": root_id,
"pubkey": "rootpub",
"content": "root",
"created_at": 1000
},
{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pubkey": "humanpub",
"content": "newer human reply",
"created_at": 5000
},
{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"pubkey": "humanpub",
"content": "middle human reply",
"created_at": 4000
},
{
"id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"pubkey": "humanpub",
"content": "oldest displayed reply without agent pin",
"created_at": 3000
},
{
"id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"pubkey": agent_hex,
"content": "agent reply outside recent window",
"created_at": 2000
}
]);
let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
.expect("should parse");
match ctx {
ConversationContext::Thread { messages, .. } => {
assert_eq!(messages.len(), 3); // root + 2 displayed replies
assert_eq!(messages[0].content, "root");
assert!(messages
.iter()
.any(|msg| msg.content == "agent reply outside recent window"));
assert!(messages
.iter()
.any(|msg| msg.content == "newer human reply"));
assert!(messages
.iter()
.all(|msg| msg.content != "middle human reply"));
assert!(messages
.iter()
.all(|msg| msg.content != "oldest displayed reply without agent pin"));
}
_ => panic!("expected Thread context"),
}
}
#[tokio::test]
async fn test_fetch_thread_context_uses_exact_count_when_above_sentinel_minimum() {
let agent = Keys::generate();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let channel_id = Uuid::new_v4();
let agent_pubkey = agent.public_key();
let json = json!([
thread_event(root_id, "rootpub", "root", 1000),
thread_event(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"humanpub",
"newest reply",
4000
),
thread_event(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"humanpub",
"middle reply",
3000
),
thread_event(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"humanpub",
"sentinel reply",
2000
)
]);
let ctx = fetch_thread_context_with(
channel_id,
root_id,
2,
agent_pubkey,
move |filters| {
assert_thread_query_filters(&filters, channel_id, root_id, agent_pubkey, 3);
std::future::ready(Ok(json.clone()))
},
move |filters| {
assert_thread_count_filter(&filters, channel_id, root_id);
std::future::ready(Ok(json!({ "count": 6 })))
},
)
.await
.expect("thread context");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert!(truncated);
assert_eq!(messages.len(), 3);
assert_eq!(total, 7); // 6 replies + root
}
_ => panic!("expected Thread context"),
}
}
#[tokio::test]
async fn test_fetch_thread_context_does_not_add_missing_root_to_exact_count() {
let agent = Keys::generate();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let channel_id = Uuid::new_v4();
let json = json!([
thread_event(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"humanpub",
"newest reply",
4000
),
thread_event(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"humanpub",
"middle reply",
3000
),
thread_event(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"humanpub",
"sentinel reply",
2000
)
]);
let ctx = fetch_thread_context_with(
channel_id,
root_id,
2,
agent.public_key(),
move |_filters| std::future::ready(Ok(json.clone())),
|_filters| std::future::ready(Ok(json!({ "count": 6 }))),
)
.await
.expect("thread context");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert!(truncated);
assert_eq!(messages.len(), 2);
assert_eq!(total, 6);
}
_ => panic!("expected Thread context"),
}
}
#[tokio::test]
async fn test_fetch_thread_context_clamps_count_below_sentinel_minimum() {
let agent = Keys::generate();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let channel_id = Uuid::new_v4();
let json = json!([
thread_event(root_id, "rootpub", "root", 1000),
thread_event(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"humanpub",
"newest reply",
4000
),
thread_event(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"humanpub",
"middle reply",
3000
),
thread_event(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"humanpub",
"sentinel reply",
2000
)
]);
let ctx = fetch_thread_context_with(
channel_id,
root_id,
2,
agent.public_key(),
move |_filters| std::future::ready(Ok(json.clone())),
|_filters| std::future::ready(Ok(json!({ "count": 1 }))),
)
.await
.expect("thread context");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert!(truncated);
assert_eq!(messages.len(), 3);
assert_eq!(total, 4); // root + displayed replies + sentinel minimum
}
_ => panic!("expected Thread context"),
}
}
#[tokio::test]
async fn test_fetch_thread_context_preserves_sentinel_minimum_when_count_fails() {
let agent = Keys::generate();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let channel_id = Uuid::new_v4();
let json = json!([
thread_event(root_id, "rootpub", "root", 1000),
thread_event(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"humanpub",
"newest reply",
4000
),
thread_event(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"humanpub",
"middle reply",
3000
),
thread_event(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"humanpub",
"sentinel reply",
2000
)
]);
let ctx = fetch_thread_context_with(
channel_id,
root_id,
2,
agent.public_key(),
move |_filters| std::future::ready(Ok(json.clone())),
|_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))),
)
.await
.expect("thread context");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert!(truncated);
assert_eq!(messages.len(), 3);
assert_eq!(total, 4); // count failure leaves parser's sentinel minimum intact
}
_ => panic!("expected Thread context"),
}
}
#[tokio::test]
async fn test_fetch_thread_context_deduplicates_and_pins_agent_reply() {
let agent = Keys::generate();
let agent_hex = agent.public_key().to_hex();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let channel_id = Uuid::new_v4();
let json = json!([
thread_event(root_id, "rootpub", "root", 1000),
thread_event(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"humanpub",
"newer human reply",
5000
),
thread_event(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"humanpub",
"middle human reply",
4000
),
thread_event(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
&agent_hex,
"agent reply outside recent window",
2000
),
// Same event as the separately fetched author-filtered result; the
// parser should deduplicate it before pinning.
thread_event(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
&agent_hex,
"agent reply outside recent window",
2000
)
]);
let ctx = fetch_thread_context_with(
channel_id,
root_id,
2,
agent.public_key(),
move |_filters| std::future::ready(Ok(json.clone())),
|_filters| std::future::ready(Ok(json!({ "count": 3 }))),
)
.await
.expect("thread context");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert!(truncated);
assert_eq!(total, 4);
assert_eq!(messages.len(), 3);
assert_eq!(
messages
.iter()
.filter(|msg| msg.content == "agent reply outside recent window")
.count(),
1,
"separate agent-reply query must not duplicate the same event"
);
assert!(messages
.iter()
.any(|msg| msg.content == "newer human reply"));
assert!(messages
.iter()
.all(|msg| msg.content != "middle human reply"));
}
_ => panic!("expected Thread context"),
}
}
#[tokio::test]
async fn test_fetch_thread_context_uses_distinct_fetched_replies_as_minimum() {
let agent = Keys::generate();
let agent_hex = agent.public_key().to_hex();
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
let channel_id = Uuid::new_v4();
let json = json!([
thread_event(root_id, "rootpub", "root", 1000),
thread_event(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"humanpub",
"newest human reply",
5000
),
thread_event(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"humanpub",
"middle human reply",
4000
),
thread_event(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"humanpub",
"sentinel human reply",
3000
),
thread_event(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
&agent_hex,
"older distinct agent reply",
2000
)
]);
let ctx = fetch_thread_context_with(
channel_id,
root_id,
2,
agent.public_key(),
move |_filters| std::future::ready(Ok(json.clone())),
|_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))),
)
.await
.expect("thread context");
match ctx {
ConversationContext::Thread {
messages,
total,
truncated,
} => {
assert!(truncated);
assert_eq!(messages.len(), 3);
assert_eq!(
total, 5,
"root plus all four distinct fetched replies prove the lower bound"
);
assert!(messages
.iter()
.any(|msg| msg.content == "older distinct agent reply"));
assert!(messages
.iter()
.any(|msg| msg.content == "newest human reply"));
assert!(messages
.iter()
.all(|msg| msg.content != "middle human reply"));
assert!(messages
.iter()
.all(|msg| msg.content != "sentinel human reply"));
}
_ => panic!("expected Thread context"),
}
}
fn assert_thread_query_filters(
filters: &[nostr::Filter],
channel_id: Uuid,
root_id: &str,
agent_pubkey: nostr::PublicKey,
reply_limit: u64,
) {
assert_eq!(
filters.len(),
3,
"root, recent replies, and agent reply filters"
);
let root = serde_json::to_value(&filters[0]).expect("serialize root filter");
assert_eq!(root.get("ids"), Some(&json!([root_id])));
assert!(root.get("limit").is_none());
let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter");
assert_eq!(replies.get("kinds"), Some(&json!([9, 40002])));
assert_eq!(replies.get("#e"), Some(&json!([root_id])));
assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()])));
assert_eq!(replies.get("limit"), Some(&json!(reply_limit)));
assert!(replies.get("authors").is_none());
let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter");
assert_eq!(agent.get("kinds"), Some(&json!([9, 40002])));
assert_eq!(agent.get("#e"), Some(&json!([root_id])));
assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()])));
assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()])));
assert_eq!(agent.get("limit"), Some(&json!(1)));
}
fn assert_thread_count_filter(filters: &[nostr::Filter], channel_id: Uuid, root_id: &str) {
assert_eq!(filters.len(), 1, "count should query only matching replies");
let count = serde_json::to_value(&filters[0]).expect("serialize count filter");
assert_eq!(count.get("kinds"), Some(&json!([9, 40002])));
assert_eq!(count.get("#e"), Some(&json!([root_id])));
assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()])));
assert_eq!(count.get("limit"), Some(&json!(0)));
assert!(count.get("ids").is_none());
assert!(count.get("authors").is_none());
}
fn thread_event(id: &str, pubkey: &str, content: &str, created_at: u64) -> serde_json::Value {
json!({
"id": id,
"pubkey": pubkey,
"content": content,
"created_at": created_at
})
}
#[test]
fn test_json_to_context_message_integer_timestamp() {
let obj = json!({

View File

@@ -405,6 +405,19 @@ impl RestClient {
.map_err(|e| RelayError::Http(e.to_string()))
}
/// Count events via the HTTP bridge: `POST /count` with NIP-98 auth.
///
/// Accepts a slice of `nostr::Filter` (serialized as JSON array).
/// Returns the bridge response as a `serde_json::Value` (usually `{ "count": n }`).
pub async fn count(&self, filters: &[nostr::Filter]) -> Result<Value, RelayError> {
let body_bytes = serde_json::to_vec(filters)
.map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?;
let resp = self.bridge_post("/count", &body_bytes).await?;
resp.json()
.await
.map_err(|e| RelayError::Http(e.to_string()))
}
/// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth.
///
/// The event must already be signed. Returns the relay response JSON.

View File

@@ -36,7 +36,7 @@ const HARNESS_DESCRIPTIONS: Record<string, string> = {
// https://moonshotai.github.io/kimi-cli/en/
kimi: "A terminal coding agent for software development and command-line tasks.",
// Sources: https://ampcode.com, https://ampcode.com/manual
amp: "A coding agent from Sourcegraph.",
amp: "A coding agent for your terminal and editor.",
// Sources: https://github.com/NousResearch/hermes-agent,
// https://hermes-agent.nousresearch.com/docs/
hermes: "A general-purpose AI agent from Nous Research.",

View File

@@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
@@ -87,8 +89,16 @@ class ActivityPage extends HookConsumerWidget {
];
// Preload sender profiles for visible rows.
final pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList();
ref.read(userCacheProvider.notifier).preload(pubkeys);
final preloadPubkeys = {
for (final item in visibleItems) item.item.pubkey.toLowerCase(),
for (final item in visibleItems)
...mentionedPubkeysFromTags(item.item.tags),
}.toList()..sort();
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
useEffect(() {
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
return null;
}, [preloadPubkeysKey]);
final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length;

View File

@@ -61,6 +61,28 @@ class _InboxRow extends ConsumerWidget {
final userCache = ref.watch(userCacheProvider);
final profile = userCache[item.item.pubkey.toLowerCase()];
final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey);
final profileMentionNames = {
for (final pubkey in mentionedPubkeysFromTags(item.item.tags))
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
pubkey: userCache[pubkey]!.displayName!.trim(),
};
final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags);
final knownAgentPubkeys = channel == null
? ref.watch(knownAgentPubkeysProvider)
: ref.watch(agentMentionPubkeysProvider(channel!.id));
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: knownAgentPubkeys,
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: mentionPubkeys,
profileMentionNames: profileMentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
final isDm = channel?.isDm ?? false;
final channelName = channel != null && !isDm
@@ -172,6 +194,8 @@ class _InboxRow extends ConsumerWidget {
// Message preview.
MessageContent(
content: item.item.displayContent,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: item.item.tags,
maxLines: 2,
baseStyle: activityPreviewTextStyle.copyWith(

View File

@@ -8,21 +8,20 @@ import '../channel_typing_provider.dart';
///
/// Used by both the members button badge and the members sheet to avoid
/// duplicating the bot-typing cross-reference logic.
final workingBotPubkeysProvider = Provider.family<Set<String>, String>((
ref,
channelId,
) {
final typingEntries = ref.watch(channelTypingProvider(channelId));
final membersAsync = ref.watch(channelMembersProvider(channelId));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final workingBotPubkeysProvider = Provider.autoDispose
.family<Set<String>, String>((ref, channelId) {
final typingEntries = ref.watch(channelTypingProvider(channelId));
final membersAsync = ref.watch(channelMembersProvider(channelId));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final botPubkeys = <String>{
for (final m in allMembers)
if (m.isBot) m.pubkey.toLowerCase(),
};
final botPubkeys = <String>{
for (final m in allMembers)
if (m.isBot) m.pubkey.toLowerCase(),
};
return <String>{
for (final e in typingEntries)
if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(),
};
});
return <String>{
for (final e in typingEntries)
if (botPubkeys.contains(e.pubkey.toLowerCase()))
e.pubkey.toLowerCase(),
};
});

View File

@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
@@ -39,7 +40,6 @@ import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_content.dart';
import 'mentions/mention_candidates_provider.dart';
import 'read_state/deferred_read_state_update.dart';
import 'read_state/read_state_provider.dart';
import 'read_state/read_state_time.dart';

View File

@@ -36,8 +36,14 @@ class _MessageBubble extends ConsumerWidget {
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = ref.watch(
mentionAgentPubkeysProvider(currentChannelId),
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(
agentMentionPubkeysProvider(currentChannelId),
),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
@@ -51,6 +57,12 @@ class _MessageBubble extends ConsumerWidget {
agentMentionPubkeys.add(normalizedPubkey);
}
}
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: message.mentionPubkeys,
profileMentionNames: mentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
@@ -166,7 +178,7 @@ class _MessageBubble extends ConsumerWidget {
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,

View File

@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/auth/auth.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../profile/profile_provider.dart';
import 'channel.dart';
@@ -392,8 +393,9 @@ final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
});
/// Channel members from kind:39002 NIP-29 members event.
final channelMembersProvider =
FutureProvider.family<List<ChannelMember>, String>((ref, channelId) async {
final channelMembersProvider = FutureProvider.autoDispose
.family<List<ChannelMember>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
final session = ref.watch(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.channelMembers(channelId),
@@ -557,6 +559,7 @@ class ChannelActions {
);
}
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> joinChannel(String channelId) async {
@@ -626,6 +629,7 @@ class ChannelActions {
await _ref.read(channelsProvider.notifier).refresh();
_ref.invalidate(channelDetailsProvider(channelId));
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
_ref.invalidate(channelCanvasProvider(channelId));
}
@@ -659,6 +663,7 @@ class ChannelActions {
],
);
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> removeMember({
@@ -674,6 +679,7 @@ class ChannelActions {
],
);
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> addReaction(String eventId, String emoji) async {

View File

@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'channel_management_provider.dart';
import 'pending_local_messages_provider.dart';
import 'channel_window.dart';
import 'thread_replies_provider.dart';
@@ -222,11 +221,6 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
_lastKnownMessages = merged;
state = AsyncData(merged);
}
if (event.kind == EventKind.systemMessage &&
_isMembershipEvent(event.content)) {
ref.invalidate(channelMembersProvider(channelId));
}
}
void _handleWindowLiveEvent(NostrEvent event) {
@@ -288,12 +282,6 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
.confirm(eventIds);
}
static bool _isMembershipEvent(String content) {
return content.contains('member_joined') ||
content.contains('member_left') ||
content.contains('member_removed');
}
/// Adds a just-signed outgoing message before the relay acknowledges it.
/// The live relay echo is deduplicated by event id.
void addLocalMessage(NostrEvent event) {

View File

@@ -15,6 +15,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
@@ -36,6 +37,7 @@ import 'mentions/mention_ranking.dart';
import 'photo_library.dart';
part 'compose_bar/helpers.dart';
part 'compose_bar/agent_mention_labels.dart';
part 'compose_bar/markdown_editing_controller.dart';
part 'compose_bar/suggestions.dart';
part 'compose_bar/formatting_toolbar.dart';
@@ -231,6 +233,16 @@ class ComposeBar extends HookConsumerWidget {
// owners so @mention suggestions show names ("managed by …" included).
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
final agentMentionLabels = _agentMentionLabels(
candidates: mentionMap.value.values,
);
final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join(
'\u0000',
);
useEffect(() {
controller.setAgentMentionNames(agentMentionLabels);
return null;
}, [controller, agentMentionLabelsKey]);
useEffect(
() {
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];

View File

@@ -0,0 +1,10 @@
part of '../compose_bar.dart';
Set<String> _agentMentionLabels({
required Iterable<MentionCandidate> candidates,
}) {
return <String>{
for (final candidate in candidates)
if (candidate.isAgent) candidate.label,
};
}

View File

@@ -15,6 +15,8 @@ class _MarkdownRule {
}
class _MarkdownEditingController extends TextEditingController {
final Set<String> _agentMentionNames = <String>{};
static final _rules = [
_MarkdownRule(
r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```',
@@ -31,6 +33,21 @@ class _MarkdownEditingController extends TextEditingController {
_MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic),
];
/// Updates the known agent labels which should render as agent mention
/// chips. The editor still stores the literal `@Name` text, matching the
/// markdown sent to the relay.
void setAgentMentionNames(Iterable<String> names) {
final next = {
for (final name in names)
if (name.trim().isNotEmpty) name.trim().toLowerCase(),
};
if (setEquals(_agentMentionNames, next)) return;
_agentMentionNames
..clear()
..addAll(next);
notifyListeners();
}
@override
TextSpan buildTextSpan({
required BuildContext context,
@@ -81,6 +98,7 @@ class _MarkdownEditingController extends TextEditingController {
if (nextRule == null || nextMatch == null) {
spans.addAll(
_buildTextSpans(
context,
source.substring(offset),
inheritedStyle,
sourceOffset + offset,
@@ -93,6 +111,7 @@ class _MarkdownEditingController extends TextEditingController {
if (nextMatch.start > 0) {
spans.addAll(
_buildTextSpans(
context,
tail.substring(0, nextMatch.start),
inheritedStyle,
sourceOffset + offset,
@@ -129,10 +148,12 @@ class _MarkdownEditingController extends TextEditingController {
} else {
spans.addAll(
_buildTextSpans(
context,
content,
contentStyle,
matchOffset + contentStart,
composingRange,
renderAgentMentions: false,
),
);
}
@@ -150,11 +171,18 @@ class _MarkdownEditingController extends TextEditingController {
}
List<InlineSpan> _buildTextSpans(
BuildContext context,
String source,
TextStyle style,
int sourceOffset,
TextRange composingRange,
) {
TextRange composingRange, {
bool renderAgentMentions = true,
}) {
List<InlineSpan> buildTextSegment(String text, TextStyle segmentStyle) =>
renderAgentMentions
? _buildAgentMentionSpans(context, text, segmentStyle)
: [TextSpan(text: text, style: segmentStyle)];
if (source.isEmpty) return const [];
final localStart = (composingRange.start - sourceOffset)
.clamp(0, source.length)
@@ -165,7 +193,7 @@ class _MarkdownEditingController extends TextEditingController {
if (!composingRange.isValid ||
composingRange.isCollapsed ||
localStart >= localEnd) {
return [TextSpan(text: source, style: style)];
return buildTextSegment(source, style);
}
final composingDecorations = [
@@ -178,16 +206,80 @@ class _MarkdownEditingController extends TextEditingController {
);
return [
if (localStart > 0)
TextSpan(text: source.substring(0, localStart), style: style),
...buildTextSegment(source.substring(0, localStart), style),
TextSpan(
text: source.substring(localStart, localEnd),
style: composingStyle,
),
if (localEnd < source.length)
TextSpan(text: source.substring(localEnd), style: style),
...buildTextSegment(source.substring(localEnd), style),
];
}
List<InlineSpan> _buildAgentMentionSpans(
BuildContext context,
String source,
TextStyle style,
) {
if (_agentMentionNames.isEmpty) {
return [TextSpan(text: source, style: style)];
}
final escapedNames = _agentMentionNames.toList()
..sort((a, b) => b.length.compareTo(a.length));
final expression = RegExp(
r'(^|\s)@(' +
escapedNames.map(RegExp.escape).join('|') +
r')(?=\s|[,.!?:;)\]}*_]|$)',
caseSensitive: false,
multiLine: true,
);
final spans = <InlineSpan>[];
var offset = 0;
for (final match in expression.allMatches(source)) {
final prefix = match.group(1)!;
if (match.start > offset) {
spans.add(
TextSpan(text: source.substring(offset, match.start), style: style),
);
}
if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style));
final label = match.group(2)!;
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: _ComposerAgentMentionChip(label: label, textStyle: style),
),
);
// The visual chip replaces the `@` placeholder. Keep the label as
// invisible source text so the text span still has one character per
// source character, preserving native cursor and deletion behavior.
spans.add(
TextSpan(
text: label,
semanticsLabel: '',
style: _hiddenMentionTextStyle(style),
),
);
offset = match.end;
}
if (offset < source.length) {
spans.add(TextSpan(text: source.substring(offset), style: style));
}
return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans;
}
TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) =>
inheritedStyle.copyWith(
color: Colors.transparent,
fontSize: 0.01,
height: 0.01,
letterSpacing: 0,
decoration: TextDecoration.none,
);
(int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) {
final delimiterLength = switch (markdownStyle) {
_MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2,
@@ -247,3 +339,60 @@ class _MarkdownEditingController extends TextEditingController {
);
}
}
class _ComposerAgentMentionChip extends StatelessWidget {
final String label;
final TextStyle textStyle;
const _ComposerAgentMentionChip({
required this.label,
required this.textStyle,
});
@override
Widget build(BuildContext context) {
final style = textStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
);
final fontSize = style.fontSize ?? 16;
return Semantics(
label: 'Agent mention: $label',
excludeSemantics: true,
child: Container(
key: const ValueKey('composer-agent-mention-chip'),
padding: const EdgeInsets.fromLTRB(
Grid.half,
Grid.quarter + 1,
Grid.half,
Grid.quarter,
),
decoration: BoxDecoration(
// The composer surface is already tinted, so the body chip's
// low-opacity fill disappears here. Keep the same chip geometry
// while giving this editable token enough contrast to read as one.
color: context.colors.primary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: context.colors.primary.withValues(alpha: 0.12),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
LucideIcons.bot,
size: fontSize * 0.95,
color: context.colors.primary,
),
const SizedBox(width: Grid.quarter),
Text(label, style: style),
],
),
),
);
}
}

View File

@@ -1,59 +1,8 @@
import 'dart:convert';
import '../../../shared/relay/nostr_models.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../profile/user_profile.dart';
import '../channel_management_provider.dart';
import 'mention_ranking.dart';
/// A relay agent parsed from its kind:10100 agent-profile event.
///
/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility
/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which
/// channels it sits in.
class AgentDirectoryEntry {
final String pubkey;
final String? displayName;
final String? respondTo;
final List<String> respondToAllowlist;
final List<String> channelIds;
const AgentDirectoryEntry({
required this.pubkey,
this.displayName,
this.respondTo,
this.respondToAllowlist = const [],
this.channelIds = const [],
});
factory AgentDirectoryEntry.fromEvent(NostrEvent event) {
final content = _tryDecodeJsonMap(event.content);
return AgentDirectoryEntry(
pubkey: event.pubkey.toLowerCase(),
displayName:
(content?['display_name'] as String?) ??
(content?['name'] as String?),
respondTo: content?['respond_to'] as String?,
respondToAllowlist: [
for (final value in (content?['respond_to_allowlist'] as List?) ?? [])
if (value is String) value.toLowerCase(),
],
channelIds: [
for (final value in (content?['channel_ids'] as List?) ?? [])
if (value is String) value,
],
);
}
}
Map<String, dynamic>? _tryDecodeJsonMap(String content) {
try {
final decoded = jsonDecode(content);
return decoded is Map<String, dynamic> ? decoded : null;
} catch (_) {
return null;
}
}
/// Whether a non-member relay agent should be mentionable by the current
/// user. Mirrors desktop's `relayAgentIsSharedWithUser`:
/// - allowlist mode: user must be on the allowlist

View File

@@ -1,6 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/crypto/nip_oa.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../../shared/relay/relay.dart';
import '../../profile/user_cache_provider.dart';
import '../../profile/user_profile.dart';
@@ -10,64 +11,6 @@ import '../channels_provider.dart';
import 'mention_candidates.dart';
import 'mention_ranking.dart';
/// Relay agent directory from kind:10100 agent-profile events.
///
/// Watches the session and only fetches after the WebSocket connects.
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
ref,
) async {
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const [];
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(NostrFilters.agentProfiles());
return [for (final event in events) AgentDirectoryEntry.fromEvent(event)];
});
/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
/// desktop's `profile_valid_oa_owner_pubkey`.
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
final agents = await ref.watch(agentDirectoryProvider.future);
if (agents.isEmpty) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
);
final owners = <String, String>{};
for (final event in events) {
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
}
return owners;
});
/// Pubkeys currently known to represent agents for rendered mention chips.
///
/// Uses the same three identity sources as mention autocomplete: channel bot
/// roles, relay agent-directory entries, and verified NIP-OA ownership.
final mentionAgentPubkeysProvider = Provider.family<Set<String>, String>((
ref,
channelId,
) {
final members =
ref.watch(channelMembersProvider(channelId)).asData?.value ??
const <ChannelMember>[];
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final userCache = ref.watch(userCacheProvider);
return {
for (final member in members)
if (member.isBot) member.pubkey.toLowerCase(),
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey.toLowerCase(),
};
});
/// Debounce before a mention query hits the relay search endpoint.
const _mentionSearchDebounce = Duration(milliseconds: 250);

View File

@@ -145,6 +145,10 @@ class MessageContent extends HookConsumerWidget {
final baseTextStyle =
baseStyle ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final resolvedMentionNames = mentionNames;
final resolvedAgentMentionPubkeys = {
...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
final imetaByUrl = parseImetaTags(tags);
final trailingGallery = maxLines == null
? _extractTrailingImageGallery(content, imetaByUrl)
@@ -154,6 +158,13 @@ class MessageContent extends HookConsumerWidget {
customEmojiFromTags(tags),
ref.watch(customEmojiListProvider),
);
final mentionPresentationKey = [
for (final entry
in (resolvedMentionNames.entries.toList()
..sort((a, b) => a.key.compareTo(b.key))))
'${entry.key}\u0000${entry.value}',
...(resolvedAgentMentionPubkeys.toList()..sort()),
].join('\u0001');
// Decided here rather than by the caller: this is where the event's own
// emoji tags and the community palette have already been merged, and a
@@ -220,7 +231,7 @@ class MessageContent extends HookConsumerWidget {
mentionBuf.write('`${mentionParts[i]}`');
} else {
var segment = mentionParts[i];
for (final name in mentionNames.values) {
for (final name in resolvedMentionNames.values) {
if (name.contains(' ')) {
final normalizedName = _markdownMentionName(name);
segment = segment.replaceAllMapped(
@@ -241,29 +252,35 @@ class MessageContent extends HookConsumerWidget {
result = '\u200B$result';
}
return result;
}, [markdownContent, mentionNames]);
}, [markdownContent, resolvedMentionNames]);
final markdown = GptMarkdown(
finalContent,
style: style,
followLinkColor: false,
codeBuilder: (context, name, code, closed) =>
_MessageCodeBlock(name: name, code: code),
linkBuilder: (context, linkText, url, linkStyle) =>
_buildLink(context, ref, linkText, url, linkStyle, style),
imageBuilder: (context, imageUrl) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
maxLines: maxLines,
inlineComponents: [
_MentionMd(
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
onMentionTap: onMentionTap,
),
CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize),
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
...MarkdownComponent.inlineComponents,
],
final markdown = KeyedSubtree(
key: ValueKey('$finalContent\u0000$mentionPresentationKey'),
child: GptMarkdown(
finalContent,
style: style,
followLinkColor: false,
codeBuilder: (context, name, code, closed) =>
_MessageCodeBlock(name: name, code: code),
linkBuilder: (context, linkText, url, linkStyle) =>
_buildLink(context, ref, linkText, url, linkStyle, style),
imageBuilder: (context, imageUrl) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
maxLines: maxLines,
inlineComponents: [
_MentionMd(
mentionNames: resolvedMentionNames,
agentMentionPubkeys: resolvedAgentMentionPubkeys,
onMentionTap: onMentionTap,
),
CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize),
_ChannelLinkMd(
channelNames: channelNames,
onChannelTap: onChannelTap,
),
...MarkdownComponent.inlineComponents,
],
),
);
if (trailingGallery == null) return markdown;

View File

@@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
@@ -24,7 +25,6 @@ import 'day_divider.dart';
import '../profile/user_profile_sheet.dart';
import 'message_actions.dart';
import 'message_content.dart';
import 'mentions/mention_candidates_provider.dart';
import 'reaction_row.dart';
import 'read_state/read_state_format.dart';
import 'read_state/read_state_provider.dart';
@@ -607,7 +607,13 @@ class _ThreadMessage extends ConsumerWidget {
profile?.ownerPubkey == currentPubkey?.toLowerCase());
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId));
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
for (final mpk in message.mentionPubkeys) {
@@ -620,6 +626,12 @@ class _ThreadMessage extends ConsumerWidget {
agentMentionPubkeys.add(normalizedPubkey);
}
}
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: message.mentionPubkeys,
profileMentionNames: mentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
@@ -725,7 +737,7 @@ class _ThreadMessage extends ConsumerWidget {
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../channels/message_content.dart';
@@ -15,7 +17,7 @@ import 'forum_models.dart';
///
/// Long-press opens an action sheet (copy, delete) matching the stream
/// message pattern from channel_detail_page.dart.
class ForumPostCard extends ConsumerWidget {
class ForumPostCard extends HookConsumerWidget {
final ForumPost post;
final String? currentPubkey;
final VoidCallback onTap;
@@ -31,16 +33,57 @@ class ForumPostCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mentionPubkeys = useMemoized(
() =>
post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()).toSet()
..remove(post.pubkey.toLowerCase()),
[post],
);
final mentionPubkeysKey = (mentionPubkeys.toList()..sort()).join('\u0000');
useEffect(() {
if (mentionPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(mentionPubkeys.toList());
}
return null;
}, [mentionPubkeysKey]);
final pk = post.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? _shortPubkey(post.pubkey);
final mentionNames = ref.watch(
final profileMentionNames = ref.watch(
userCacheProvider.select(
(cache) => _buildMentionNames(post.mentionPubkeys, cache),
),
);
final profileOwnedMentionPubkeys = ref.watch(
userCacheProvider.select(
(cache) =>
(post.mentionPubkeys
.where(
(pubkey) =>
cache[pubkey.toLowerCase()]?.ownerPubkey != null,
)
.map((pubkey) => pubkey.toLowerCase())
.toList()
..sort())
.join('\u0000'),
),
);
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)),
profileOwnedAgentPubkeys: profileOwnedMentionPubkeys.isEmpty
? const <String>[]
: profileOwnedMentionPubkeys.split('\u0000'),
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: post.mentionPubkeys,
profileMentionNames: profileMentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
final preview = post.content.length > 200
? '${post.content.substring(0, 200)}...'
: post.content;
@@ -128,6 +171,7 @@ class ForumPostCard extends ConsumerWidget {
child: MessageContent(
content: preview,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: post.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,

View File

@@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
@@ -209,21 +210,27 @@ class _ThreadContent extends HookConsumerWidget {
final post = thread.post;
final replies = thread.replies;
// Preload profiles for all participants.
// Preload profiles for all participants and tagged mentions.
final allPubkeys = useMemoized(() {
final pks = <String>{post.pubkey};
final pks = <String>{
post.pubkey.toLowerCase(),
...post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
for (final reply in replies) {
pks.add(reply.pubkey);
pks
..add(reply.pubkey.toLowerCase())
..addAll(reply.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()));
}
return pks.toList();
return pks.toList()..sort();
}, [post, replies]);
final allPubkeysKey = allPubkeys.join('\u0000');
useEffect(() {
if (allPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(allPubkeys);
}
return null;
}, [allPubkeys]);
}, [allPubkeysKey]);
return Column(
children: [
@@ -322,7 +329,19 @@ class _OriginalPost extends ConsumerWidget {
final displayName = profile?.label ?? _shortPubkey(post.pubkey);
final userCache = ref.watch(userCacheProvider);
final mentionNames = _buildMentionNames(post.mentionPubkeys, userCache);
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: post.mentionPubkeys,
profileMentionNames: _buildMentionNames(post.mentionPubkeys, userCache),
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return Padding(
padding: const EdgeInsets.all(Grid.xs),
@@ -375,6 +394,7 @@ class _OriginalPost extends ConsumerWidget {
MessageContent(
content: post.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: post.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
@@ -409,7 +429,19 @@ class _ReplyRow extends ConsumerWidget {
final displayName = profile?.label ?? _shortPubkey(reply.pubkey);
final userCache = ref.watch(userCacheProvider);
final mentionNames = _buildMentionNames(reply.mentionPubkeys, userCache);
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: reply.mentionPubkeys,
profileMentionNames: _buildMentionNames(reply.mentionPubkeys, userCache),
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return Padding(
padding: const EdgeInsets.symmetric(
@@ -481,6 +513,7 @@ class _ReplyRow extends ConsumerWidget {
child: MessageContent(
content: reply.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: reply.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,

View File

@@ -3,6 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
@@ -597,7 +599,7 @@ class _PeopleSection extends ConsumerWidget {
}
}
class _MessagesSection extends ConsumerWidget {
class _MessagesSection extends HookConsumerWidget {
final List<SearchHit> hits;
final String? currentPubkey;
final VoidCallback onResultSelected;
@@ -614,8 +616,15 @@ class _MessagesSection extends ConsumerWidget {
final channels = ref.watch(channelsProvider).value ?? [];
// Preload author profiles.
final pubkeys = hits.map((h) => h.pubkey.toLowerCase()).toSet().toList();
ref.read(userCacheProvider.notifier).preload(pubkeys);
final preloadPubkeys = {
for (final hit in hits) hit.pubkey.toLowerCase(),
for (final hit in hits) ...mentionedPubkeysFromTags(hit.tags),
}.toList()..sort();
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
useEffect(() {
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
return null;
}, [preloadPubkeysKey]);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -635,7 +644,7 @@ class _MessagesSection extends ConsumerWidget {
}
}
class _MessageTile extends StatelessWidget {
class _MessageTile extends ConsumerWidget {
final SearchHit hit;
final UserProfile? authorProfile;
final Map<String, UserProfile> userCache;
@@ -653,12 +662,34 @@ class _MessageTile extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey);
final timeAgo = relativeTime(hit.createdAt);
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
final hasChannelName = channelName != null && channelName.isNotEmpty;
final isDm = channel?.isDm ?? false;
final profileMentionNames = {
for (final pubkey in mentionedPubkeysFromTags(hit.tags))
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
pubkey: userCache[pubkey]!.displayName!.trim(),
};
final mentionPubkeys = mentionedPubkeysFromTags(hit.tags);
final knownAgentPubkeys = channel == null
? ref.watch(knownAgentPubkeysProvider)
: ref.watch(agentMentionPubkeysProvider(channel!.id));
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: knownAgentPubkeys,
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: mentionPubkeys,
profileMentionNames: profileMentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return ListTile(
key: ValueKey('search-message-row-${hit.eventId}'),
@@ -730,6 +761,8 @@ class _MessageTile extends StatelessWidget {
MessageContent(
key: ValueKey('search-message-body-${hit.eventId}'),
content: hit.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: hit.tags,
maxLines: 2,
baseStyle: activityPreviewTextStyle.copyWith(

View File

@@ -0,0 +1,274 @@
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/relay/relay.dart';
/// A relay agent parsed from its kind:10100 agent-profile event.
///
/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility
/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which
/// channels it sits in.
class AgentDirectoryEntry {
final String pubkey;
final String? displayName;
final String? respondTo;
final List<String> respondToAllowlist;
final List<String> channelIds;
const AgentDirectoryEntry({
required this.pubkey,
this.displayName,
this.respondTo,
this.respondToAllowlist = const [],
this.channelIds = const [],
});
factory AgentDirectoryEntry.fromEvent(NostrEvent event) {
final content = _tryDecodeJsonMap(event.content);
return AgentDirectoryEntry(
pubkey: event.pubkey.toLowerCase(),
displayName:
(content?['display_name'] as String?) ??
(content?['name'] as String?),
respondTo: content?['respond_to'] as String?,
respondToAllowlist: [
for (final value in (content?['respond_to_allowlist'] as List?) ?? [])
if (value is String) value.toLowerCase(),
],
channelIds: [
for (final value in (content?['channel_ids'] as List?) ?? [])
if (value is String) value,
],
);
}
}
Map<String, dynamic>? _tryDecodeJsonMap(String content) {
try {
final decoded = jsonDecode(content);
return decoded is Map<String, dynamic> ? decoded : null;
} catch (_) {
return null;
}
}
/// Relay agent directory from kind:10100 agent-profile events.
///
/// Watches the session and only fetches after the WebSocket connects.
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
ref,
) async {
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const [];
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(NostrFilters.agentProfiles());
return [for (final event in events) AgentDirectoryEntry.fromEvent(event)];
});
/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
/// desktop's `profile_valid_oa_owner_pubkey`.
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
final agents = await ref.watch(agentDirectoryProvider.future);
if (agents.isEmpty) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
);
final owners = <String, String>{};
for (final event in events) {
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
}
return owners;
});
/// Pubkeys currently known to represent agents across the active relay.
///
/// Message surfaces that do not own channel membership can use this shared
/// identity source; channel features add their bot roles separately.
final knownAgentPubkeysProvider = Provider<Set<String>>((ref) {
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
return _AgentPubkeySet({
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
});
});
/// Directory display names keyed by agent pubkey for mention presentation.
final agentDirectoryDisplayNamesProvider = Provider<Map<String, String>>((ref) {
final agents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
return Map.unmodifiable({
for (final agent in agents)
if (agent.displayName?.trim().isNotEmpty == true)
agent.pubkey.toLowerCase(): agent.displayName!.trim(),
});
});
/// Adds channel bot roles to relay-wide agent identities.
Set<String> agentPubkeysWithChannelBots({
required Set<String> knownAgentPubkeys,
required Iterable<String> channelBotPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Adds agent identities derived from locally cached verified profiles.
Set<String> agentPubkeysWithProfileOwners({
required Set<String> knownAgentPubkeys,
required Iterable<String> profileOwnedAgentPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Preserves profile labels while filling missing agent mentions from the
/// relay's agent directory.
Map<String, String> mentionNamesWithDirectoryLabels({
required Iterable<String> mentionPubkeys,
required Map<String, String> profileMentionNames,
required Map<String, String> directoryDisplayNames,
required Set<String> agentMentionPubkeys,
}) {
final names = Map<String, String>.from(profileMentionNames);
for (final pubkey in mentionPubkeys) {
final normalizedPubkey = pubkey.toLowerCase();
if (names[normalizedPubkey]?.trim().isEmpty == true) {
names.remove(normalizedPubkey);
}
final directoryName = directoryDisplayNames[normalizedPubkey];
if (!names.containsKey(normalizedPubkey) && directoryName != null) {
names[normalizedPubkey] = directoryName;
}
if (!names.containsKey(normalizedPubkey) &&
agentMentionPubkeys.contains(normalizedPubkey)) {
names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey);
}
}
return names;
}
String _agentFallbackLabel(String pubkey) =>
pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;
/// Keeps the role feed alive for consumers that render mentions outside the
/// channel timeline, such as search results. A membership change refreshes the
/// shared bot-role lookup below, regardless of which surface owns the channel.
class _ChannelBotRoleSubscription extends Notifier<int> {
final String channelId;
void Function()? _unsubscribe;
int _subscriptionVersion = 0;
_ChannelBotRoleSubscription(this.channelId);
@override
int build() {
final sessionState = ref.watch(relaySessionProvider);
final subscriptionVersion = ++_subscriptionVersion;
_clearSubscription();
ref.onDispose(() {
_subscriptionVersion++;
_clearSubscription();
});
if (sessionState.status != SessionStatus.connected) return 0;
Future.microtask(() => _subscribe(channelId, subscriptionVersion));
return 0;
}
Future<void> _subscribe(String channelId, int subscriptionVersion) async {
final session = ref.read(relaySessionProvider.notifier);
try {
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: const [39002],
tags: {
'#h': [channelId],
},
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
(_) {
if (_isCurrent(subscriptionVersion)) {
state++;
}
},
);
if (!_isCurrent(subscriptionVersion)) {
unsubscribe();
return;
}
_unsubscribe = unsubscribe;
} catch (error) {
if (_isCurrent(subscriptionVersion)) {
debugPrint(
'[ChannelBotRoleSubscription] failed for $channelId: $error',
);
}
}
}
bool _isCurrent(int subscriptionVersion) =>
subscriptionVersion == _subscriptionVersion;
void _clearSubscription() {
_unsubscribe?.call();
_unsubscribe = null;
}
}
/// Monotonically increments when the channel's kind:39002 membership snapshot
/// changes. Channel-member and agent-role views share this source so remote
/// membership updates refresh both snapshots together.
final channelMembershipUpdateProvider = NotifierProvider.autoDispose
.family<_ChannelBotRoleSubscription, int, String>(
_ChannelBotRoleSubscription.new,
);
/// Bot pubkeys currently assigned a channel bot role.
final channelBotPubkeysProvider = FutureProvider.autoDispose
.family<Set<String>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.channelMembers(channelId),
);
if (events.isEmpty) return const {};
return _AgentPubkeySet({
for (final member in membersFromEvent(events.first))
if (member.role == 'bot') member.pubkey.toLowerCase(),
});
});
/// Pubkeys currently known to represent agents in a channel.
final agentMentionPubkeysProvider = Provider.autoDispose
.family<Set<String>, String>((ref, channelId) {
final channelBotPubkeys =
ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ??
const <String>{};
return agentPubkeysWithChannelBots(
knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider),
channelBotPubkeys: channelBotPubkeys,
);
});
class _AgentPubkeySet extends UnmodifiableSetView<String> {
_AgentPubkeySet(Iterable<String> pubkeys) : super(Set.unmodifiable(pubkeys));
@override
bool operator ==(Object other) =>
other is Set && length == other.length && every(other.contains);
@override
int get hashCode => Object.hashAllUnordered(this);
}

View File

@@ -0,0 +1,6 @@
/// Pubkeys tagged as message mentions, normalized for profile lookups.
Set<String> mentionedPubkeysFromTags(Iterable<List<String>> tags) => {
for (final tag in tags)
if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention'))
tag[1].toLowerCase(),
};

View File

@@ -26,6 +26,7 @@ import 'package:buzz/features/channels/small_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
@@ -188,6 +189,9 @@ Widget _buildTestable({
channelMembersProvider(_channelId).overrideWith(
(ref) async => loadMembers != null ? loadMembers() : members,
),
channelBotPubkeysProvider(
_channelId,
).overrideWith((ref) async => const <String>{}),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
if (readStateNotifier != null)

View File

@@ -17,11 +17,10 @@ import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/compose_bar.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
import 'package:buzz/features/channels/photo_library.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -2269,6 +2268,11 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Helper Bot'));
await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
expect(
find.byKey(const ValueKey('composer-agent-mention-chip')),
findsOneWidget,
);
await tester.enterText(find.byType(TextField), 'hello @Helper Bot');
await tester.tap(find.byIcon(LucideIcons.arrowUp));
await tester.pumpAndSettle();
@@ -2285,6 +2289,70 @@ void main() {
]);
});
testWidgets(
'renders chips only for selected agents outside code and composition',
(tester) async {
final semantics = tester.ensureSemantics();
final signer = nostr.Keys.generate();
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(signer.nsec),
currentPubkey: signer.public,
relayAgents: [_testAgent('f' * 64)],
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@Helper Bot');
await tester.pump();
expect(
find.byKey(const ValueKey('composer-agent-mention-chip')),
findsNothing,
);
await tester.enterText(find.byType(TextField), '@hel');
await tester.pumpAndSettle();
await tester.tap(find.text('Helper Bot'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('composer-agent-mention-chip')),
findsOneWidget,
);
expect(
find.bySemanticsLabel('Agent mention: Helper Bot'),
findsOneWidget,
);
expect(find.bySemanticsLabel('Helper Bot'), findsNothing);
await tester.enterText(find.byType(TextField), '`@Helper Bot`');
await tester.pump();
expect(
find.byKey(const ValueKey('composer-agent-mention-chip')),
findsNothing,
);
await tester.enterText(find.byType(TextField), '@Helper Bot typing');
final textField = tester.widget<TextField>(find.byType(TextField));
textField.controller!.value = textField.controller!.value.copyWith(
composing: const TextRange(start: 12, end: 18),
);
await tester.pump();
expect(
find.byKey(const ValueKey('composer-agent-mention-chip')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 250));
semantics.dispose();
},
);
testWidgets('does not mutate a DM when mentioning a non-member agent', (
tester,
) async {

View File

@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
final userPubkey = 'a' * 64;
final memberPubkey = 'b' * 64;
@@ -18,6 +19,20 @@ ChannelMember member(String pubkey, {String role = 'member'}) {
}
void main() {
test('role-only agent mentions fall back to a pubkey prefix label', () {
const pubkey = 'deadbeef0123456789';
expect(
mentionNamesWithDirectoryLabels(
mentionPubkeys: const [pubkey],
profileMentionNames: const {},
directoryDisplayNames: const {},
agentMentionPubkeys: const {pubkey},
),
const {pubkey: 'deadbeef'},
);
});
group('agentIsSharedWithUser', () {
test('anyone-mode agent is shared when a channel overlaps', () {
final agent = AgentDirectoryEntry(

View File

@@ -1204,6 +1204,43 @@ Photos
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('renders a known agent mention with the bot chip', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Ask @Helper Bot to investigate',
mentionNames: {'agent-pubkey': 'Helper Bot'},
agentMentionPubkeys: {'agent-pubkey'},
maxLines: 2,
),
),
);
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
expect(find.text('@'), findsNothing);
expect(find.text('Helper Bot'), findsOneWidget);
});
testWidgets('normalizes passed multi-word agent mentions', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Ask @Helper Bot to investigate',
mentionNames: {'agent-pubkey': 'Helper Bot'},
agentMentionPubkeys: {'agent-pubkey'},
),
),
);
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
expect(find.text('Helper Bot'), findsOneWidget);
expect(_allRichText(tester), isNot(contains('Bot Bot')));
});
testWidgets('highlights an entire multi-word display name', (
tester,
) async {

View File

@@ -10,8 +10,10 @@ import 'package:buzz/features/search/recent_searches_provider.dart';
import 'package:buzz/features/search/search_page.dart';
import 'package:buzz/features/search/search_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
@@ -593,6 +595,72 @@ void main() {
await tester.pump();
expect(recentSearches.searches, const ['design']);
});
testWidgets('renders channel-role bots in message previews', (tester) async {
final channel = Channel(
id: 'channel-1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'test',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
);
const agentPubkey = 'agent-pubkey';
const cachedProfile = UserProfile(pubkey: 'author-pubkey');
final state = SearchState(
query: 'helper',
messageResults: [
SearchHit(
eventId: 'message-1',
content: 'Ask @Helper Bot to investigate',
kind: 9,
pubkey: 'author-pubkey',
channelId: channel.id,
channelName: channel.name,
createdAt: 1,
score: 1,
tags: [
['p', agentPubkey],
],
),
],
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
channelsProvider.overrideWith(() => _FakeChannelsNotifier([channel])),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(cachedProfile),
),
knownAgentPubkeysProvider.overrideWith((ref) => const {}),
channelBotPubkeysProvider(
channel.id,
).overrideWith((ref) async => {agentPubkey}),
agentDirectoryDisplayNamesProvider.overrideWith(
(ref) => const {agentPubkey: 'Helper Bot'},
),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final content = tester.widget<MessageContent>(
find.byKey(const ValueKey('search-message-body-message-1')),
);
expect(content.mentionNames, const {agentPubkey: 'Helper Bot'});
expect(content.agentMentionPubkeys, contains(agentPubkey));
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
});
}
class _FakeSearchNotifier extends SearchNotifier {
@@ -646,8 +714,12 @@ class _FakeProfileNotifier extends ProfileNotifier {
}
class _FakeChannelsNotifier extends ChannelsNotifier {
_FakeChannelsNotifier([this.channels = const []]);
final List<Channel> channels;
@override
Future<List<Channel>> build() async => const [];
Future<List<Channel>> build() async => channels;
}
class _FakeUserCacheNotifier extends UserCacheNotifier {

View File

@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('refreshes channel bot roles from live membership updates', () async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
_membershipEvent(role: 'member'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
channelBotPubkeysProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
addTearDown(keepAlive.close);
expect(await container.read(channelBotPubkeysProvider(_channelId).future), {
_agentPubkey,
});
await relaySession.subscribed;
expect(relaySession.liveFilters.single.kinds, const [39002]);
expect(relaySession.liveFilters.single.tags['#h'], [_channelId]);
relaySession.emit(_membershipEvent(role: 'member'));
await _pumpEventQueue();
expect(
await container.read(channelBotPubkeysProvider(_channelId).future),
isEmpty,
);
});
test('refreshes channel members from live membership updates', () async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
_membershipEvent(role: 'member'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
addTearDown(keepAlive.close);
expect(
(await container.read(
channelMembersProvider(_channelId).future,
)).single.role,
'bot',
);
await relaySession.subscribed;
relaySession.emit(_membershipEvent(role: 'member'));
await _pumpEventQueue();
expect(
(await container.read(
channelMembersProvider(_channelId).future,
)).single.role,
'member',
);
});
test('disposes the live role subscription without consumers', () async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
channelBotPubkeysProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
await container.read(channelBotPubkeysProvider(_channelId).future);
await relaySession.subscribed;
keepAlive.close();
await container.pump();
expect(relaySession.unsubscribeCount, 1);
});
test(
'does not retain a live role subscription through working bots',
() async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
workingBotPubkeysProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
await relaySession.subscribed;
keepAlive.close();
await container.pump();
expect(relaySession.unsubscribeCount, 1);
},
);
test('blank profile labels defer to the directory label', () {
const pubkey = 'deadbeef0123456789';
expect(
mentionNamesWithDirectoryLabels(
mentionPubkeys: const [pubkey],
profileMentionNames: const {pubkey: ' '},
directoryDisplayNames: const {pubkey: 'Directory bot'},
agentMentionPubkeys: const {pubkey},
),
const {pubkey: 'Directory bot'},
);
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
const _agentPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
NostrEvent _membershipEvent({required String role}) => NostrEvent(
id: 'membership-$role',
pubkey: 'owner',
createdAt: 1,
kind: 39002,
tags: [
['d', _channelId],
['h', _channelId],
['p', _agentPubkey, 'wss://relay.example', role],
],
content: '',
sig: 'sig',
);
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
final List<NostrEvent> _memberships;
final List<NostrFilter> liveFilters = [];
final List<_LiveSubscription> _subscriptions = [];
final Completer<void> _subscribed = Completer<void>();
var unsubscribeCount = 0;
var _membershipIndex = 0;
_MembershipRelaySessionNotifier(this._memberships);
Future<void> get subscribed => _subscribed.future;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
return [_memberships[_membershipIndex++]];
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
liveFilters.add(filter);
final subscription = _LiveSubscription(filter, onEvent);
_subscriptions.add(subscription);
if (!_subscribed.isCompleted) _subscribed.complete();
return () {
unsubscribeCount++;
_subscriptions.remove(subscription);
};
}
void emit(NostrEvent event) {
for (final subscription in List.of(_subscriptions)) {
if (_matches(subscription.filter, event)) {
subscription.onEvent(event);
}
}
}
}
class _LiveSubscription {
final NostrFilter filter;
final void Function(NostrEvent) onEvent;
const _LiveSubscription(this.filter, this.onEvent);
}
bool _matches(NostrFilter filter, NostrEvent event) {
if (!filter.kinds.contains(event.kind)) return false;
return filter.tags.entries.every((entry) {
final tagName = entry.key.substring(1);
return event.tags.any(
(tag) =>
tag.isNotEmpty &&
tag.first == tagName &&
tag.skip(1).any(entry.value.contains),
);
});
}