mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(acp): include thread root kind in context
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
parent
f956e6fe06
commit
8929843196
@@ -222,7 +222,9 @@ Start with **N=2** for most deployments. Increase if queue depth grows under loa
|
||||
|
||||
## Forum Channels
|
||||
|
||||
By default, the ACP harness subscribes to stream message kinds (9, 46010, 40007). To receive forum events, opt in with `--kinds` and disable the mention filter (forum posts don't @mention agents):
|
||||
By default, the ACP harness subscribes to mentioned stream messages, workflow approval requests, reminders, forum posts, and forum comments (kinds 9, 46010, 40007, 45001, and 45003). The default mention filter still applies, so unmentioned forum events are ignored.
|
||||
|
||||
To receive every forum post, comment, and vote—including events that do not mention the agent—opt in to kind 45002 and disable the mention filter:
|
||||
|
||||
**CLI flags:**
|
||||
```bash
|
||||
@@ -246,7 +248,7 @@ Forum event kinds:
|
||||
- **45002** — Vote on a post or comment
|
||||
- **45003** — Comment reply on a forum post
|
||||
|
||||
> **Note:** Without `--no-mention-filter` (or `require_mention = false`), the default `subscribe=mentions` mode filters events that don't @mention the agent — forum posts will be invisible.
|
||||
> **Note:** `--no-mention-filter` (or `require_mention = false`) is only needed for unmentioned events. Without it, the default `subscribe=mentions` mode still receives forum posts and comments that mention the agent.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ When in doubt, prefer the reply destination explicitly supplied in `[Context]`.
|
||||
|
||||
All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it.
|
||||
|
||||
### Forum Channels
|
||||
|
||||
Forum channels are not stream channels, and the reply kind must match the thread root. Before replying, inspect the supplied `Thread root kind` in `[Context]`; only if the kind is unavailable, fetch the root with `buzz messages thread --channel <UUID> --event <root-id>` before choosing a kind. Use the stream default kind `9` for replies beneath kind-`9`, legacy kind-`40002`, reminder kind-`40007`, kind-`40008` diff, and workflow approval kind-`46010` stream roots, even if the channel also hosts forum posts. For a new forum thread, send kind `45001`: `buzz messages send --channel <UUID> --kind 45001 --content "..."`. Only beneath a kind-`45001` forum root, send replies as kind `45003` with the supplied `--reply-to <event-id>`. Never send kind `45003` beneath stream roots.
|
||||
|
||||
### General
|
||||
|
||||
- Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need.
|
||||
|
||||
@@ -1256,7 +1256,8 @@ pub fn resolve_channel_filters(
|
||||
rules: &[SubscriptionRule],
|
||||
) -> HashMap<Uuid, ChannelFilter> {
|
||||
use buzz_core::kind::{
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
};
|
||||
|
||||
let target_channels: Vec<Uuid> = if let Some(ref overrides) = config.channels_override {
|
||||
@@ -1276,6 +1277,8 @@ pub fn resolve_channel_filters(
|
||||
let kinds = config.kinds_override.clone().unwrap_or_else(|| {
|
||||
vec![
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_COMMENT,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
KIND_STREAM_REMINDER,
|
||||
]
|
||||
@@ -1358,7 +1361,8 @@ pub fn resolve_dynamic_channel_filter(
|
||||
rules: &[crate::filter::SubscriptionRule],
|
||||
) -> Option<ChannelFilter> {
|
||||
use buzz_core::kind::{
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
};
|
||||
|
||||
// In Mentions/All mode, if the operator explicitly constrained channels
|
||||
@@ -1381,6 +1385,8 @@ pub fn resolve_dynamic_channel_filter(
|
||||
kinds: Some(config.kinds_override.clone().unwrap_or_else(|| {
|
||||
vec![
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_COMMENT,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
KIND_STREAM_REMINDER,
|
||||
]
|
||||
@@ -1529,11 +1535,27 @@ mod tests {
|
||||
assert!(f.require_mention, "mentions mode requires mention");
|
||||
let kinds = f.kinds.as_ref().expect("should have kinds");
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_POST));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_COMMENT));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_mentions_mode_default_kinds_include_forum_events() {
|
||||
let config = test_config(SubscribeMode::Mentions);
|
||||
let filter = resolve_dynamic_channel_filter(&config, Uuid::new_v4(), &[])
|
||||
.expect("dynamic channel should be subscribed");
|
||||
let kinds = filter.kinds.expect("mentions mode should constrain kinds");
|
||||
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_POST));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_COMMENT));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED));
|
||||
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mentions_mode_custom_kinds() {
|
||||
let mut config = test_config(SubscribeMode::Mentions);
|
||||
|
||||
@@ -21,8 +21,9 @@ use std::time::Duration;
|
||||
use acp::{AcpClient, EnvVar, McpServer};
|
||||
use anyhow::Result;
|
||||
use buzz_core::kind::{
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
};
|
||||
use buzz_core::observer::{
|
||||
decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY,
|
||||
@@ -2077,6 +2078,8 @@ async fn tokio_main() -> Result<()> {
|
||||
kinds: config.kinds_override.clone().unwrap_or_else(|| {
|
||||
vec![
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_COMMENT,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
KIND_STREAM_REMINDER,
|
||||
]
|
||||
@@ -3822,15 +3825,29 @@ fn spawn_failure_notice(
|
||||
content: String,
|
||||
) {
|
||||
if let Some(rest) = rest_client {
|
||||
let thread_tags = batch
|
||||
let (thread_tags, triggering_kind, triggering_event_id) = batch
|
||||
.events
|
||||
.last()
|
||||
.map(|be| queue::parse_thread_tags(&be.event))
|
||||
.map(|be| {
|
||||
(
|
||||
queue::parse_thread_tags(&be.event),
|
||||
be.event.kind.as_u16() as u32,
|
||||
Some(be.event.id),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let rest = rest.clone();
|
||||
let channel_id = batch.channel_id;
|
||||
tokio::spawn(async move {
|
||||
pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await;
|
||||
pool::post_failure_notice(
|
||||
&rest,
|
||||
channel_id,
|
||||
&thread_tags,
|
||||
triggering_kind,
|
||||
triggering_event_id,
|
||||
&content,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4463,6 +4480,22 @@ mod agent_draft_prompt_tests {
|
||||
.contains("add them explicitly with `buzz channels add-member` only when authorized"));
|
||||
assert!(prompt.contains("never changes membership automatically"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_base_prompt_distinguishes_forum_kinds_from_stream_messages() {
|
||||
let prompt = include_str!("base_prompt.md");
|
||||
assert!(prompt.contains("Forum channels are not stream channels"));
|
||||
assert!(prompt.contains("kind `45001`"));
|
||||
assert!(prompt.contains("kind `45003`"));
|
||||
assert!(prompt.contains("stream default kind `9`"));
|
||||
assert!(prompt.contains(
|
||||
"legacy kind-`40002`, reminder kind-`40007`, kind-`40008` diff, and workflow approval kind-`46010` stream roots"
|
||||
));
|
||||
assert!(prompt.contains("inspect the supplied `Thread root kind` in `[Context]`"));
|
||||
assert!(prompt.contains("only if the kind is unavailable"));
|
||||
assert!(prompt.contains("buzz messages thread --channel <UUID> --event <root-id>"));
|
||||
assert!(prompt.contains("Never send kind `45003` beneath stream roots"));
|
||||
}
|
||||
}
|
||||
|
||||
fn default_heartbeat_prompt() -> String {
|
||||
|
||||
+279
-28
@@ -3255,12 +3255,9 @@ where
|
||||
|
||||
// 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([
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
|
||||
])
|
||||
let root_filter =
|
||||
root_message_filter(nostr::EventId::from_hex(root_event_id).ok()?, channel_id);
|
||||
let replies_filter = thread_reply_filter()
|
||||
.custom_tags(e_tag, [root_event_id])
|
||||
.custom_tags(h_tag, [ch_str.as_str()])
|
||||
.limit(limit.saturating_add(1) as usize);
|
||||
@@ -3450,6 +3447,7 @@ fn parse_thread_response(json: serde_json::Value) -> Option<ConversationContext>
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
root_kind: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3556,6 +3554,7 @@ fn parse_nostr_thread_response_with_meta(
|
||||
let events = json.as_array()?;
|
||||
let agent_pubkey_hex = agent_pubkey.to_hex();
|
||||
let mut root_msg = None;
|
||||
let mut root_kind = None;
|
||||
let mut reply_msgs = Vec::new();
|
||||
let mut seen_reply_ids = HashSet::new();
|
||||
|
||||
@@ -3563,6 +3562,10 @@ fn parse_nostr_thread_response_with_meta(
|
||||
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_kind = ev
|
||||
.get("kind")
|
||||
.and_then(|value| value.as_u64())
|
||||
.and_then(|kind| u32::try_from(kind).ok());
|
||||
root_msg = Some(msg);
|
||||
} else if seen_reply_ids.insert(ev_id.to_string()) {
|
||||
let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex);
|
||||
@@ -3628,6 +3631,7 @@ fn parse_nostr_thread_response_with_meta(
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
root_kind,
|
||||
},
|
||||
root_present,
|
||||
})
|
||||
@@ -4237,33 +4241,130 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: post a visible failure notice (kind:9) to a channel after a
|
||||
/// batch is dead-lettered. Replies into the thread of `thread_tags` when the
|
||||
/// triggering event was threaded. Errors are logged and swallowed — the
|
||||
/// notice must never take down the main loop.
|
||||
pub(crate) fn thread_reply_filter() -> nostr::Filter {
|
||||
nostr::Filter::new().kinds([
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_DIFF as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_FORUM_COMMENT as u16),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn root_message_filter(root_id: nostr::EventId, channel_id: Uuid) -> nostr::Filter {
|
||||
use nostr::{Alphabet, SingleLetterTag};
|
||||
|
||||
nostr::Filter::new()
|
||||
.id(root_id)
|
||||
.kinds([
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_REMINDER as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_DIFF as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_FORUM_POST as u16),
|
||||
nostr::Kind::Custom(buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED as u16),
|
||||
])
|
||||
.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::H),
|
||||
channel_id.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_root_kind(kind: u32) -> Option<u32> {
|
||||
matches!(
|
||||
kind,
|
||||
buzz_core::kind::KIND_STREAM_MESSAGE
|
||||
| buzz_core::kind::KIND_STREAM_MESSAGE_V2
|
||||
| buzz_core::kind::KIND_STREAM_REMINDER
|
||||
| buzz_core::kind::KIND_STREAM_MESSAGE_DIFF
|
||||
| buzz_core::kind::KIND_FORUM_POST
|
||||
| buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED
|
||||
)
|
||||
.then_some(kind)
|
||||
}
|
||||
|
||||
fn build_failure_notice(
|
||||
channel_id: Uuid,
|
||||
content: &str,
|
||||
thread_ref: Option<&buzz_sdk::ThreadRef>,
|
||||
root_kind: Option<u32>,
|
||||
) -> Option<nostr::EventBuilder> {
|
||||
let root_kind = root_kind?;
|
||||
if root_kind == buzz_core::kind::KIND_FORUM_POST {
|
||||
let thread_ref = thread_ref?;
|
||||
buzz_sdk::build_forum_comment(channel_id, content, thread_ref, &[], &[]).ok()
|
||||
} else {
|
||||
buzz_sdk::build_message(channel_id, content, thread_ref, &[], false, &[]).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: post a visible failure notice to a channel after a batch is
|
||||
/// dead-lettered. Replies into the triggering thread and preserves forum
|
||||
/// comment kind semantics. Errors are logged and swallowed — the notice must
|
||||
/// never take down the main loop.
|
||||
pub(crate) async fn post_failure_notice(
|
||||
rest: &crate::relay::RestClient,
|
||||
channel_id: Uuid,
|
||||
thread_tags: &ThreadTags,
|
||||
triggering_kind: u32,
|
||||
triggering_event_id: Option<nostr::EventId>,
|
||||
content: &str,
|
||||
) {
|
||||
let thread_ref = thread_tags.root_event_id.as_deref().and_then(|root| {
|
||||
let root_id = nostr::EventId::from_hex(root).ok()?;
|
||||
let parent_id = thread_tags
|
||||
.parent_event_id
|
||||
.as_deref()
|
||||
.and_then(|p| nostr::EventId::from_hex(p).ok())
|
||||
.unwrap_or(root_id);
|
||||
Some(buzz_sdk::ThreadRef {
|
||||
root_event_id: root_id,
|
||||
parent_event_id: parent_id,
|
||||
let explicit_root_id = thread_tags
|
||||
.root_event_id
|
||||
.as_deref()
|
||||
.and_then(|root| nostr::EventId::from_hex(root).ok());
|
||||
let thread_ref = explicit_root_id
|
||||
.map(|root_id| {
|
||||
let parent_id = thread_tags
|
||||
.parent_event_id
|
||||
.as_deref()
|
||||
.and_then(|p| nostr::EventId::from_hex(p).ok())
|
||||
.unwrap_or(root_id);
|
||||
buzz_sdk::ThreadRef {
|
||||
root_event_id: root_id,
|
||||
parent_event_id: parent_id,
|
||||
}
|
||||
})
|
||||
});
|
||||
.or_else(|| {
|
||||
triggering_event_id.map(|event_id| buzz_sdk::ThreadRef {
|
||||
root_event_id: event_id,
|
||||
parent_event_id: event_id,
|
||||
})
|
||||
});
|
||||
let root_kind = if let Some(root_id) = explicit_root_id {
|
||||
let filter = root_message_filter(root_id, channel_id);
|
||||
match tokio::time::timeout(Duration::from_secs(5), rest.query(&[filter])).await {
|
||||
Ok(Ok(json)) => json
|
||||
.as_array()
|
||||
.and_then(|events| events.first())
|
||||
.and_then(|event| event.get("kind"))
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|kind| u32::try_from(kind).ok()),
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(channel = %channel_id, "failure notice: root kind lookup failed: {e}");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(channel = %channel_id, "failure notice: root kind lookup timed out");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
supported_root_kind(triggering_kind)
|
||||
};
|
||||
let Some(root_kind) = root_kind else {
|
||||
tracing::warn!(
|
||||
channel = %channel_id,
|
||||
"failure notice: root kind is unknown; refusing to guess reply semantics"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let builder =
|
||||
match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}");
|
||||
match build_failure_notice(channel_id, content, thread_ref.as_ref(), Some(root_kind)) {
|
||||
Some(builder) => builder,
|
||||
None => {
|
||||
tracing::warn!(channel = %channel_id, "failure notice: build failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -4446,6 +4547,88 @@ mod tests {
|
||||
.any(|entry| entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_notice_builder_preserves_forum_kind_and_fails_closed() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let root_event_id = nostr::EventId::from_slice(&[1; 32]).unwrap();
|
||||
let parent_event_id = nostr::EventId::from_slice(&[2; 32]).unwrap();
|
||||
let thread_ref = buzz_sdk::ThreadRef {
|
||||
root_event_id,
|
||||
parent_event_id,
|
||||
};
|
||||
let keys = Keys::generate();
|
||||
|
||||
let forum = build_failure_notice(
|
||||
channel_id,
|
||||
"failed",
|
||||
Some(&thread_ref),
|
||||
Some(buzz_core::kind::KIND_FORUM_POST),
|
||||
)
|
||||
.unwrap()
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
forum.kind.as_u16(),
|
||||
buzz_core::kind::KIND_FORUM_COMMENT as u16
|
||||
);
|
||||
|
||||
let stream = build_failure_notice(
|
||||
channel_id,
|
||||
"failed",
|
||||
Some(&thread_ref),
|
||||
Some(buzz_core::kind::KIND_STREAM_REMINDER),
|
||||
)
|
||||
.unwrap()
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stream.kind.as_u16(),
|
||||
buzz_core::kind::KIND_STREAM_MESSAGE as u16
|
||||
);
|
||||
|
||||
assert!(build_failure_notice(channel_id, "failed", Some(&thread_ref), None).is_none());
|
||||
assert!(build_failure_notice(
|
||||
channel_id,
|
||||
"failed",
|
||||
None,
|
||||
Some(buzz_core::kind::KIND_FORUM_POST),
|
||||
)
|
||||
.is_none());
|
||||
|
||||
assert_eq!(
|
||||
supported_root_kind(buzz_core::kind::KIND_STREAM_MESSAGE),
|
||||
Some(buzz_core::kind::KIND_STREAM_MESSAGE)
|
||||
);
|
||||
assert_eq!(
|
||||
supported_root_kind(buzz_core::kind::KIND_FORUM_POST),
|
||||
Some(buzz_core::kind::KIND_FORUM_POST)
|
||||
);
|
||||
assert_eq!(
|
||||
supported_root_kind(buzz_core::kind::KIND_FORUM_COMMENT),
|
||||
None
|
||||
);
|
||||
assert_eq!(supported_root_kind(49_999), None);
|
||||
|
||||
let fallback_thread_ref = buzz_sdk::ThreadRef {
|
||||
root_event_id,
|
||||
parent_event_id: root_event_id,
|
||||
};
|
||||
assert!(build_failure_notice(
|
||||
channel_id,
|
||||
"failed",
|
||||
Some(&fallback_thread_ref),
|
||||
supported_root_kind(buzz_core::kind::KIND_FORUM_COMMENT),
|
||||
)
|
||||
.is_none());
|
||||
assert!(build_failure_notice(
|
||||
channel_id,
|
||||
"failed",
|
||||
Some(&fallback_thread_ref),
|
||||
supported_root_kind(49_999),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// These pin the initial_message dispatch path (run_prompt_task, ~line 855):
|
||||
// a legacy agent WITH a base_prompt must get [Base] prepended to the user
|
||||
// message. This is the exact regression that shipped in the round-2 bug.
|
||||
@@ -4719,6 +4902,62 @@ mod tests {
|
||||
assert!(with_core(None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_message_lookup_filter_specifies_supported_root_kinds() {
|
||||
let root_id = nostr::EventId::from_hex(
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
)
|
||||
.expect("valid event ID");
|
||||
|
||||
let channel_id =
|
||||
Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").expect("valid channel ID");
|
||||
let filter = serde_json::to_value(root_message_filter(root_id, channel_id))
|
||||
.expect("serialize filter");
|
||||
|
||||
assert_eq!(filter.get("ids"), Some(&json!([root_id.to_hex()])));
|
||||
assert_eq!(
|
||||
filter.get("kinds"),
|
||||
Some(&json!([9, 40002, 40007, 40008, 45001, 46010]))
|
||||
);
|
||||
assert_eq!(filter.get("#h"), Some(&json!([channel_id.to_string()])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nostr_thread_response_captures_root_kind() {
|
||||
let root_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let json = json!([
|
||||
{
|
||||
"id": root_id,
|
||||
"kind": 45001,
|
||||
"pubkey": "pub1",
|
||||
"content": "forum root",
|
||||
"created_at": 1710518400
|
||||
},
|
||||
{
|
||||
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"kind": 45003,
|
||||
"pubkey": "pub2",
|
||||
"content": "forum reply",
|
||||
"created_at": 1710518460
|
||||
}
|
||||
]);
|
||||
|
||||
let agent = Keys::generate();
|
||||
let ctx = parse_nostr_thread_response(json, root_id, 10, &agent.public_key())
|
||||
.expect("should parse");
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
root_kind,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(root_kind, Some(45_001));
|
||||
assert_eq!(messages[0].content, "forum root");
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_thread_response_basic() {
|
||||
let json = json!({
|
||||
@@ -4745,6 +4984,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(messages.len(), 2); // root + 1 reply
|
||||
assert_eq!(total, 2); // 1 reply + 1 root
|
||||
@@ -4782,6 +5022,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(total, 11); // 10 replies + 1 root
|
||||
@@ -4835,6 +5076,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
// Should be reversed to chronological order.
|
||||
assert_eq!(messages.len(), 2);
|
||||
@@ -4957,6 +5199,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(messages.len(), 3); // root + 2 displayed replies
|
||||
assert_eq!(total, 4); // root + displayed replies + sentinel
|
||||
@@ -4998,6 +5241,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(total, 2);
|
||||
@@ -5118,6 +5362,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
@@ -5169,6 +5414,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 2);
|
||||
@@ -5221,6 +5467,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
@@ -5273,6 +5520,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
@@ -5334,6 +5582,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(total, 4);
|
||||
@@ -5407,6 +5656,7 @@ mod tests {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
@@ -5449,14 +5699,14 @@ mod tests {
|
||||
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("kinds"), Some(&json!([9, 40002, 40008, 45003])));
|
||||
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("kinds"), Some(&json!([9, 40002, 40008, 45003])));
|
||||
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()])));
|
||||
@@ -5467,7 +5717,7 @@ mod tests {
|
||||
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("kinds"), Some(&json!([9, 40002, 40008, 45003])));
|
||||
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)));
|
||||
@@ -5546,6 +5796,7 @@ mod tests {
|
||||
}],
|
||||
total: 1,
|
||||
truncated: false,
|
||||
root_kind: None,
|
||||
};
|
||||
|
||||
let pubkeys = collect_prompt_pubkeys(&batch, Some(&context));
|
||||
|
||||
@@ -1011,6 +1011,8 @@ pub enum ConversationContext {
|
||||
messages: Vec<ContextMessage>,
|
||||
total: usize,
|
||||
truncated: bool,
|
||||
/// Kind of the root event when it was returned by the context lookup.
|
||||
root_kind: Option<u32>,
|
||||
},
|
||||
/// DM conversation history.
|
||||
Dm {
|
||||
@@ -1323,6 +1325,7 @@ fn format_context_hints(
|
||||
has_conversation_context: bool,
|
||||
conversation_context_had_delivered_events: bool,
|
||||
reply_anchor: Option<&str>,
|
||||
thread_root_kind: Option<u32>,
|
||||
) -> String {
|
||||
let channel_display = match channel_info {
|
||||
Some(ci) => format!("{} (#{channel_id})", ci.name),
|
||||
@@ -1357,6 +1360,9 @@ fn format_context_hints(
|
||||
// If this is a DM reply, include thread structural info as supplementary.
|
||||
if let Some(ref root) = thread_tags.root_event_id {
|
||||
s.push_str(&format!("\nThread root: {root}"));
|
||||
if let Some(kind) = thread_root_kind {
|
||||
s.push_str(&format!("\nThread root kind: {kind}"));
|
||||
}
|
||||
if let Some(ref parent) = thread_tags.parent_event_id {
|
||||
if parent != root {
|
||||
s.push_str(&format!("\nParent: {parent}"));
|
||||
@@ -1382,6 +1388,9 @@ fn format_context_hints(
|
||||
);
|
||||
append_channel_description(&mut s, channel_info);
|
||||
s.push_str(&format!("\nThread root: {root}"));
|
||||
if let Some(kind) = thread_root_kind {
|
||||
s.push_str(&format!("\nThread root kind: {kind}"));
|
||||
}
|
||||
if let Some(ref parent) = thread_tags.parent_event_id {
|
||||
if parent != root {
|
||||
s.push_str(&format!("\nParent: {parent}"));
|
||||
@@ -1419,6 +1428,7 @@ fn format_conversation_context(
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
..
|
||||
} => ("Thread Context", messages, total, truncated),
|
||||
ConversationContext::Dm {
|
||||
messages,
|
||||
@@ -1625,6 +1635,10 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
|
||||
args.profile_lookup,
|
||||
)
|
||||
};
|
||||
let thread_root_kind = match args.conversation_context {
|
||||
Some(ConversationContext::Thread { root_kind, .. }) => *root_kind,
|
||||
_ => None,
|
||||
};
|
||||
sections.push(format_context_hints(
|
||||
batch.channel_id,
|
||||
args.channel_info,
|
||||
@@ -1633,6 +1647,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
|
||||
args.conversation_context.is_some(),
|
||||
args.conversation_context_had_delivered_events,
|
||||
reply_anchor.as_deref(),
|
||||
thread_root_kind,
|
||||
));
|
||||
|
||||
// 3. Conversation context (thread or DM).
|
||||
@@ -2748,6 +2763,7 @@ mod tests {
|
||||
}],
|
||||
total: 1,
|
||||
truncated: false,
|
||||
root_kind: None,
|
||||
};
|
||||
|
||||
let core = "[Agent Memory — core]\nbe helpful";
|
||||
@@ -3372,6 +3388,7 @@ mod tests {
|
||||
],
|
||||
total: 5,
|
||||
truncated: true,
|
||||
root_kind: Some(45_001),
|
||||
};
|
||||
|
||||
let prompt = format_prompt(
|
||||
@@ -3385,6 +3402,7 @@ mod tests {
|
||||
assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]"));
|
||||
assert!(prompt.contains("Let's refactor auth"));
|
||||
assert!(prompt.contains("Thread context included below"));
|
||||
assert!(prompt.contains("Thread root kind: 45001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3461,6 +3479,7 @@ mod tests {
|
||||
}],
|
||||
total: 1,
|
||||
truncated: false,
|
||||
root_kind: None,
|
||||
};
|
||||
let profiles = HashMap::from([
|
||||
(
|
||||
@@ -3675,6 +3694,7 @@ mod tests {
|
||||
}],
|
||||
total: 1,
|
||||
truncated: false,
|
||||
root_kind: None,
|
||||
};
|
||||
|
||||
let prompt = format_prompt(
|
||||
|
||||
@@ -36,7 +36,8 @@ use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use buzz_core::kind::{
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
|
||||
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
};
|
||||
use nostr::EventId;
|
||||
@@ -410,7 +411,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) ->
|
||||
}
|
||||
|
||||
// Ignore non-message kinds (relay housekeeping, etc.).
|
||||
if kind_u32 != KIND_STREAM_MESSAGE && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED {
|
||||
if !is_setup_message_kind(kind_u32) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -463,6 +464,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) ->
|
||||
// Build and publish the setup nudge.
|
||||
if let Err(e) = publish_setup_nudge(
|
||||
&publisher,
|
||||
&rest_client,
|
||||
&config.keys,
|
||||
buzz_event.channel_id,
|
||||
&buzz_event.event,
|
||||
@@ -512,6 +514,17 @@ pub(crate) fn should_nudge_for_event(
|
||||
true
|
||||
}
|
||||
|
||||
fn is_setup_message_kind(kind: u32) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
KIND_STREAM_MESSAGE
|
||||
| KIND_STREAM_REMINDER
|
||||
| KIND_FORUM_POST
|
||||
| KIND_FORUM_COMMENT
|
||||
| KIND_WORKFLOW_APPROVAL_REQUESTED
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the subscription rules used in setup mode.
|
||||
///
|
||||
/// Always uses "mentions" mode: setup mode must not react to every event.
|
||||
@@ -521,10 +534,15 @@ pub(crate) fn should_nudge_for_event(
|
||||
fn build_setup_subscription_rules(config: &Config) -> Vec<filter::SubscriptionRule> {
|
||||
use crate::config::SubscribeMode;
|
||||
|
||||
let kinds = config
|
||||
.kinds_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec![KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED]);
|
||||
let kinds = config.kinds_override.clone().unwrap_or_else(|| {
|
||||
vec![
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_REMINDER,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_COMMENT,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
]
|
||||
});
|
||||
|
||||
match &config.subscribe_mode {
|
||||
// Config mode: load the actual rules, but they will be filtered by
|
||||
@@ -588,50 +606,104 @@ async fn handle_setup_membership(
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_nudge_root_kind(
|
||||
triggering_event: &nostr::Event,
|
||||
has_explicit_root: bool,
|
||||
looked_up_root_kind: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
if has_explicit_root {
|
||||
looked_up_root_kind
|
||||
} else if triggering_event.kind.as_u16() as u32 == KIND_FORUM_COMMENT {
|
||||
Some(KIND_FORUM_POST)
|
||||
} else {
|
||||
Some(triggering_event.kind.as_u16() as u32)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_setup_nudge_event(
|
||||
channel_id: Uuid,
|
||||
triggering_event: &nostr::Event,
|
||||
root_kind: u32,
|
||||
payload: &SetupPayload,
|
||||
) -> Result<nostr::EventBuilder> {
|
||||
use buzz_sdk::ThreadRef;
|
||||
|
||||
let thread_tags = crate::queue::parse_thread_tags(triggering_event);
|
||||
let root_id = if let Some(root_str) = &thread_tags.root_event_id {
|
||||
nostr::EventId::from_hex(root_str)
|
||||
.map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?
|
||||
} else {
|
||||
triggering_event.id
|
||||
};
|
||||
let thread_ref = ThreadRef {
|
||||
root_event_id: root_id,
|
||||
parent_event_id: root_id,
|
||||
};
|
||||
let body = payload.nudge_body();
|
||||
let author_hex = triggering_event.pubkey.to_hex();
|
||||
|
||||
let builder = if root_kind == KIND_FORUM_POST {
|
||||
buzz_sdk::build_forum_comment(channel_id, &body, &thread_ref, &[&author_hex], &[])
|
||||
} else {
|
||||
buzz_sdk::build_message(
|
||||
channel_id,
|
||||
&body,
|
||||
Some(&thread_ref),
|
||||
&[&author_hex],
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
.map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?;
|
||||
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
/// Build and publish a setup nudge reply to the triggering event.
|
||||
///
|
||||
/// Threading: flat reply to the thread root if one exists; otherwise reply
|
||||
/// to the triggering event itself. P-tags the asker.
|
||||
/// to the triggering event itself. P-tags the asker. Forum triggers produce
|
||||
/// forum-comment nudges so the reply kind matches the forum thread.
|
||||
async fn publish_setup_nudge(
|
||||
publisher: &RelayEventPublisher,
|
||||
rest: &crate::relay::RestClient,
|
||||
keys: &nostr::Keys,
|
||||
channel_id: Uuid,
|
||||
triggering_event: &nostr::Event,
|
||||
payload: &SetupPayload,
|
||||
) -> Result<()> {
|
||||
use buzz_sdk::ThreadRef;
|
||||
|
||||
// Parse NIP-10 thread tags to determine reply target.
|
||||
let thread_tags = crate::queue::parse_thread_tags(triggering_event);
|
||||
|
||||
let thread_ref = if let Some(root_str) = &thread_tags.root_event_id {
|
||||
// Threaded event: reply flat to the root.
|
||||
let root_id = nostr::EventId::from_hex(root_str)
|
||||
let looked_up_root_kind = if let Some(root) = thread_tags.root_event_id.as_deref() {
|
||||
let root_id = nostr::EventId::from_hex(root)
|
||||
.map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?;
|
||||
Some(ThreadRef {
|
||||
root_event_id: root_id,
|
||||
parent_event_id: root_id,
|
||||
})
|
||||
let filter = crate::pool::root_message_filter(root_id, channel_id);
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), rest.query(&[filter])).await {
|
||||
Ok(Ok(json)) => json
|
||||
.as_array()
|
||||
.and_then(|events| events.first())
|
||||
.and_then(|event| event.get("kind"))
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|kind| u32::try_from(kind).ok()),
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(channel = %channel_id, "setup nudge: root kind lookup failed: {e}");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(channel = %channel_id, "setup nudge: root kind lookup timed out");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Top-level event: reply to the triggering event.
|
||||
Some(ThreadRef {
|
||||
root_event_id: triggering_event.id,
|
||||
parent_event_id: triggering_event.id,
|
||||
})
|
||||
None
|
||||
};
|
||||
|
||||
let body = payload.nudge_body();
|
||||
let author_hex = triggering_event.pubkey.to_hex();
|
||||
|
||||
let event_builder = buzz_sdk::build_message(
|
||||
channel_id,
|
||||
&body,
|
||||
thread_ref.as_ref(),
|
||||
&[&author_hex], // p-tag the asker
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?;
|
||||
let Some(root_kind) = setup_nudge_root_kind(
|
||||
triggering_event,
|
||||
thread_tags.root_event_id.is_some(),
|
||||
looked_up_root_kind,
|
||||
) else {
|
||||
anyhow::bail!("root kind is unknown; refusing to guess setup nudge reply semantics");
|
||||
};
|
||||
let event_builder = build_setup_nudge_event(channel_id, triggering_event, root_kind, payload)?;
|
||||
|
||||
let signed = event_builder
|
||||
.sign_with_keys(keys)
|
||||
@@ -651,6 +723,88 @@ async fn publish_setup_nudge(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn setup_mode_accepts_stream_and_forum_message_kinds() {
|
||||
assert!(is_setup_message_kind(KIND_STREAM_MESSAGE));
|
||||
assert!(is_setup_message_kind(KIND_STREAM_REMINDER));
|
||||
assert!(is_setup_message_kind(KIND_FORUM_POST));
|
||||
assert!(is_setup_message_kind(KIND_FORUM_COMMENT));
|
||||
assert!(is_setup_message_kind(KIND_WORKFLOW_APPROVAL_REQUESTED));
|
||||
assert!(!is_setup_message_kind(KIND_MEMBER_ADDED_NOTIFICATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_nudge_prefers_looked_up_root_kind() {
|
||||
let trigger = nostr::EventBuilder::new(
|
||||
nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16),
|
||||
"legacy malformed forum reply",
|
||||
)
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
setup_nudge_root_kind(&trigger, true, Some(KIND_FORUM_POST)),
|
||||
Some(KIND_FORUM_POST)
|
||||
);
|
||||
assert_eq!(
|
||||
setup_nudge_root_kind(&trigger, false, None),
|
||||
Some(KIND_STREAM_MESSAGE)
|
||||
);
|
||||
assert_eq!(setup_nudge_root_kind(&trigger, true, None), None);
|
||||
|
||||
let forum_comment = nostr::EventBuilder::new(
|
||||
nostr::Kind::Custom(KIND_FORUM_COMMENT as u16),
|
||||
"forum reply",
|
||||
)
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
setup_nudge_root_kind(&forum_comment, false, None),
|
||||
Some(KIND_FORUM_POST)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_nudge_kind_matches_trigger_thread_kind() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let author = nostr::Keys::generate();
|
||||
let payload = SetupPayload {
|
||||
agent_name: "Fizz".to_string(),
|
||||
agent_pubkey: "test".to_string(),
|
||||
requirements: vec![],
|
||||
};
|
||||
|
||||
for (trigger_kind, expected_nudge_kind) in [
|
||||
(KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE),
|
||||
(KIND_FORUM_POST, KIND_FORUM_COMMENT),
|
||||
(KIND_FORUM_COMMENT, KIND_FORUM_COMMENT),
|
||||
] {
|
||||
let trigger = nostr::EventBuilder::new(
|
||||
nostr::Kind::Custom(trigger_kind as u16),
|
||||
"please wake up",
|
||||
)
|
||||
.sign_with_keys(&author)
|
||||
.unwrap();
|
||||
let root_kind = if trigger_kind == KIND_STREAM_MESSAGE {
|
||||
KIND_STREAM_MESSAGE
|
||||
} else {
|
||||
KIND_FORUM_POST
|
||||
};
|
||||
let nudge = build_setup_nudge_event(channel_id, &trigger, root_kind, &payload)
|
||||
.unwrap()
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(nudge.kind.as_u16() as u32, expected_nudge_kind);
|
||||
let thread_tags = crate::queue::parse_thread_tags(&nudge);
|
||||
let trigger_hex = trigger.id.to_hex();
|
||||
assert_eq!(
|
||||
thread_tags.root_event_id.as_deref(),
|
||||
Some(trigger_hex.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_payload_from_raw_returns_none_when_absent() {
|
||||
// None → Ok(None): normal startup, no setup payload.
|
||||
|
||||
@@ -567,7 +567,7 @@ pub enum ChannelsCmd {
|
||||
},
|
||||
/// Create a new channel
|
||||
#[command(
|
||||
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override"
|
||||
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override\n\nUse stream unless the owner explicitly requests a forum. Forum channels are a preview feature and may be hidden for owners who have not enabled them."
|
||||
)]
|
||||
Create {
|
||||
/// Channel name
|
||||
|
||||
Reference in New Issue
Block a user