diff --git a/crates/buzz-acp/src/contextual_conversation.rs b/crates/buzz-acp/src/contextual_conversation.rs new file mode 100644 index 0000000000..1325256b79 --- /dev/null +++ b/crates/buzz-acp/src/contextual_conversation.rs @@ -0,0 +1,463 @@ +//! Contextual agent conversation audience + reply-placement policy (ACP). +//! +//! Shared contract: `tests/fixtures/contextual-agent-conversation-cases.json`. +//! Pure resolver — harness wiring comes in later ACP leaves. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Device-local unaddressed-channel agent mode (mirrors Desktop/Flutter). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum UnaddressedChannelAgentMode { + AllChannelAgents, + MentionsOnly, +} + +/// Reply placement for agent responses on the wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum ReplyPlacement { + TopLevel, + #[serde(rename = "thread-root")] + ThreadRoot { + #[serde(rename = "eventId")] + event_id: String, + }, + Unconstrained, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextualAgentConversationInput { + pub conversation: String, + pub message_position: String, + pub sender_class: String, + pub unaddressed_mode: UnaddressedChannelAgentMode, + pub keep_addressed_agents_active: bool, + pub explicit_mention_pubkeys: Vec, + pub current_agent_pubkey: Option, + pub channel_member_pubkeys: Vec, + pub verified_channel_agent_pubkeys: Vec, + pub unverified_agent_pubkeys: Vec, + pub non_member_agent_pubkeys: Vec, + pub thread_root_event_id: Option, + pub replying_under_event_id: Option, + pub persistent_thread_audience: Vec, + pub manual_removed_pubkeys: Vec, + pub recipient_load_error: bool, + pub human_message_event_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextualAgentConversationDecision { + pub audience_pubkeys: Vec, + pub reply_placement: ReplyPlacement, + pub shared_thread: bool, + pub retain_draft: bool, + #[serde(default)] + pub nest_under_agent_reply: Option, +} + +fn normalize_pubkey(pubkey: &str) -> String { + pubkey.trim().to_ascii_lowercase() +} + +fn unique_sorted(pubkeys: impl IntoIterator) -> Vec { + let set: BTreeSet = pubkeys + .into_iter() + .map(|p| normalize_pubkey(&p)) + .filter(|p| !p.is_empty()) + .collect(); + set.into_iter().collect() +} + +fn eligible_channel_agents(input: &ContextualAgentConversationInput) -> BTreeSet { + let members: BTreeSet = input + .channel_member_pubkeys + .iter() + .map(|p| normalize_pubkey(p)) + .collect(); + input + .verified_channel_agent_pubkeys + .iter() + .map(|p| normalize_pubkey(p)) + .filter(|p| members.contains(p)) + .collect() +} + +fn filter_to_eligible(candidates: &[String], eligible: &BTreeSet) -> Vec { + unique_sorted( + candidates + .iter() + .map(|p| normalize_pubkey(p)) + .filter(|p| eligible.contains(p)), + ) +} + +fn placement_for( + input: &ContextualAgentConversationInput, + audience_count: usize, +) -> ReplyPlacement { + if input.message_position == "in-thread" { + if let Some(root) = input.thread_root_event_id.as_ref() { + return ReplyPlacement::ThreadRoot { + event_id: root.clone(), + }; + } + } + if audience_count >= 2 { + if let Some(event_id) = input + .human_message_event_id + .as_ref() + .or(input.thread_root_event_id.as_ref()) + { + return ReplyPlacement::ThreadRoot { + event_id: event_id.clone(), + }; + } + } + ReplyPlacement::TopLevel +} + +/// ACP turn context for reply placement (mirrors client policy without I/O). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpTurnPlacementInput { + /// True when the turn is 1:1 DM-scoped. + pub is_dm: bool, + /// Human-facing turns flatten; agent-only stays unconstrained. + pub is_human_facing: bool, + pub message_position: &'static str, + pub thread_root_event_id: Option, + pub triggering_event_id: String, + /// How many agents are addressed on the triggering human message (`p` tags). + /// Count of 0 is treated as 1 when the turn is human-facing (this agent alone). + pub addressed_agent_count: usize, +} + +/// Map placement to an optional `--reply-to` event id for the agent prompt. +pub fn reply_placement_anchor(placement: &ReplyPlacement) -> Option<&str> { + match placement { + ReplyPlacement::ThreadRoot { event_id } => Some(event_id.as_str()), + ReplyPlacement::TopLevel | ReplyPlacement::Unconstrained => None, + } +} + +/// Resolve reply placement for an ACP turn. +/// +/// - Agent-only (not human-facing): unconstrained (no forced anchor). +/// - Direct (DM) top-level: top-level flat. +/// - Direct (DM) in-thread: thread root (or triggering id if root missing). +/// - Channel top-level with ≥2 addressed agents: shared thread at human event. +/// - Channel top-level with one addressed agent: top-level flat. +/// - Channel in-thread: always thread root (never nest under an agent reply). +pub fn resolve_acp_turn_placement(input: &AcpTurnPlacementInput) -> ReplyPlacement { + if !input.is_human_facing { + return ReplyPlacement::Unconstrained; + } + + let agent_count = input.addressed_agent_count.max(1); + + if input.is_dm { + if input.message_position == "in-thread" { + let event_id = input + .thread_root_event_id + .clone() + .unwrap_or_else(|| input.triggering_event_id.clone()); + return ReplyPlacement::ThreadRoot { event_id }; + } + return ReplyPlacement::TopLevel; + } + + // Channel + if input.message_position == "in-thread" { + if let Some(root) = &input.thread_root_event_id { + return ReplyPlacement::ThreadRoot { + event_id: root.clone(), + }; + } + } + + if agent_count >= 2 { + return ReplyPlacement::ThreadRoot { + event_id: input.triggering_event_id.clone(), + }; + } + + ReplyPlacement::TopLevel +} + +/// Resolve audience and reply placement for a human/agent send path. +pub fn resolve_contextual_agent_conversation( + input: &ContextualAgentConversationInput, +) -> ContextualAgentConversationDecision { + if input.recipient_load_error { + return ContextualAgentConversationDecision { + audience_pubkeys: vec![], + reply_placement: ReplyPlacement::TopLevel, + shared_thread: false, + retain_draft: true, + nest_under_agent_reply: Some(false), + }; + } + + if input.sender_class == "agent" { + return ContextualAgentConversationDecision { + audience_pubkeys: vec![], + reply_placement: ReplyPlacement::Unconstrained, + shared_thread: false, + retain_draft: false, + nest_under_agent_reply: Some(false), + }; + } + + if input.conversation == "direct" { + let audience = input + .current_agent_pubkey + .as_ref() + .map(|p| vec![normalize_pubkey(p)]) + .unwrap_or_default(); + return ContextualAgentConversationDecision { + reply_placement: placement_for(input, audience.len()), + shared_thread: false, + retain_draft: false, + nest_under_agent_reply: Some(false), + audience_pubkeys: audience, + }; + } + + let eligible = eligible_channel_agents(input); + let removed: BTreeSet = input + .manual_removed_pubkeys + .iter() + .map(|p| normalize_pubkey(p)) + .collect(); + + let explicit: Vec = filter_to_eligible(&input.explicit_mention_pubkeys, &eligible) + .into_iter() + .filter(|p| !removed.contains(p)) + .collect(); + + let audience = if !explicit.is_empty() { + explicit + } else { + let persistent = if input.keep_addressed_agents_active { + filter_to_eligible(&input.persistent_thread_audience, &eligible) + .into_iter() + .filter(|p| !removed.contains(p)) + .collect::>() + } else { + vec![] + }; + + if !persistent.is_empty() { + persistent + } else if matches!( + input.unaddressed_mode, + UnaddressedChannelAgentMode::AllChannelAgents + ) { + eligible + .into_iter() + .filter(|p| !removed.contains(p)) + .collect() + } else { + vec![] + } + }; + + let shared_thread = audience.len() >= 2; + ContextualAgentConversationDecision { + reply_placement: placement_for(input, audience.len()), + shared_thread, + retain_draft: false, + nest_under_agent_reply: Some(false), + audience_pubkeys: audience, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + const FIXTURE: &str = + include_str!("../../../tests/fixtures/contextual-agent-conversation-cases.json"); + + #[derive(Debug, Deserialize)] + struct FixtureFile { + version: u32, + cases: Vec, + } + + #[derive(Debug, Deserialize)] + struct FixtureCase { + id: String, + input: Value, + expected: ContextualAgentConversationDecision, + } + + fn parse_input_manual(value: &Value) -> ContextualAgentConversationInput { + let mode = match value["unaddressedMode"].as_str().unwrap_or("") { + "mentions-only" => UnaddressedChannelAgentMode::MentionsOnly, + _ => UnaddressedChannelAgentMode::AllChannelAgents, + }; + let str_list = |key: &str| -> Vec { + value + .get(key) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() + }; + let opt_str = |key: &str| -> Option { + match value.get(key) { + Some(Value::Null) | None => None, + Some(v) => v.as_str().map(|s| s.to_string()), + } + }; + ContextualAgentConversationInput { + conversation: value["conversation"].as_str().unwrap_or("").to_string(), + message_position: value["messagePosition"].as_str().unwrap_or("").to_string(), + sender_class: value["senderClass"].as_str().unwrap_or("").to_string(), + unaddressed_mode: mode, + keep_addressed_agents_active: value["keepAddressedAgentsActive"] + .as_bool() + .unwrap_or(false), + explicit_mention_pubkeys: str_list("explicitMentionPubkeys"), + current_agent_pubkey: opt_str("currentAgentPubkey"), + channel_member_pubkeys: str_list("channelMemberPubkeys"), + verified_channel_agent_pubkeys: str_list("verifiedChannelAgentPubkeys"), + unverified_agent_pubkeys: str_list("unverifiedAgentPubkeys"), + non_member_agent_pubkeys: str_list("nonMemberAgentPubkeys"), + thread_root_event_id: opt_str("threadRootEventId"), + replying_under_event_id: opt_str("replyingUnderEventId"), + persistent_thread_audience: str_list("persistentThreadAudience"), + manual_removed_pubkeys: str_list("manualRemovedPubkeys"), + recipient_load_error: value["recipientLoadError"].as_bool().unwrap_or(false), + human_message_event_id: opt_str("humanMessageEventId"), + } + } + + #[test] + fn acp_turn_placement_single_agent_top_level_is_flat() { + let placement = resolve_acp_turn_placement(&AcpTurnPlacementInput { + is_dm: false, + is_human_facing: true, + message_position: "top-level", + thread_root_event_id: None, + triggering_event_id: "trig".into(), + addressed_agent_count: 1, + }); + assert_eq!(placement, ReplyPlacement::TopLevel); + assert_eq!(reply_placement_anchor(&placement), None); + } + + #[test] + fn acp_turn_placement_multi_agent_top_level_threads() { + let placement = resolve_acp_turn_placement(&AcpTurnPlacementInput { + is_dm: false, + is_human_facing: true, + message_position: "top-level", + thread_root_event_id: None, + triggering_event_id: "trig".into(), + addressed_agent_count: 2, + }); + assert_eq!( + placement, + ReplyPlacement::ThreadRoot { + event_id: "trig".into() + } + ); + assert_eq!(reply_placement_anchor(&placement), Some("trig")); + } + + #[test] + fn acp_turn_placement_in_thread_uses_root() { + let placement = resolve_acp_turn_placement(&AcpTurnPlacementInput { + is_dm: false, + is_human_facing: true, + message_position: "in-thread", + thread_root_event_id: Some("root".into()), + triggering_event_id: "trig".into(), + addressed_agent_count: 3, + }); + assert_eq!( + placement, + ReplyPlacement::ThreadRoot { + event_id: "root".into() + } + ); + } + + #[test] + fn acp_turn_placement_agent_only_unconstrained() { + let placement = resolve_acp_turn_placement(&AcpTurnPlacementInput { + is_dm: false, + is_human_facing: false, + message_position: "in-thread", + thread_root_event_id: Some("root".into()), + triggering_event_id: "trig".into(), + addressed_agent_count: 2, + }); + assert_eq!(placement, ReplyPlacement::Unconstrained); + assert_eq!(reply_placement_anchor(&placement), None); + } + + #[test] + fn fixture_loads_and_has_required_cases() { + let file: FixtureFile = serde_json::from_str(FIXTURE).expect("fixture json"); + assert_eq!(file.version, 1); + assert!( + file.cases.len() >= 12, + "expected >=12 cases, got {}", + file.cases.len() + ); + } + + #[test] + fn fixture_policy_decisions_match_expected() { + let file: FixtureFile = serde_json::from_str(FIXTURE).expect("fixture json"); + let mut failures = Vec::new(); + for case in &file.cases { + let mut input = parse_input_manual(&case.input); + if case.expected.reply_placement + == (ReplyPlacement::ThreadRoot { + event_id: "human-message-id".into(), + }) + { + input.human_message_event_id = Some("human-message-id".into()); + } + let decision = resolve_contextual_agent_conversation(&input); + let mut actual_audience = decision.audience_pubkeys.clone(); + let mut expected_audience = case.expected.audience_pubkeys.clone(); + actual_audience.sort(); + expected_audience.sort(); + if actual_audience != expected_audience + || decision.reply_placement != case.expected.reply_placement + || decision.shared_thread != case.expected.shared_thread + || decision.retain_draft != case.expected.retain_draft + { + failures.push(format!( + "{}: got audience={:?} placement={:?} shared={} retain={}; expected audience={:?} placement={:?} shared={} retain={}", + case.id, + decision.audience_pubkeys, + decision.reply_placement, + decision.shared_thread, + decision.retain_draft, + case.expected.audience_pubkeys, + case.expected.reply_placement, + case.expected.shared_thread, + case.expected.retain_draft, + )); + } + } + assert!( + failures.is_empty(), + "contextual fixture policy mismatches:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4ac..69a5c80161 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2,6 +2,7 @@ mod acp; mod config; +mod contextual_conversation; mod engram_fetch; mod filter; mod observer; @@ -12,6 +13,11 @@ mod relay; mod setup_mode; mod usage; +pub use contextual_conversation::{ + reply_placement_anchor, resolve_acp_turn_placement, resolve_contextual_agent_conversation, + AcpTurnPlacementInput, ContextualAgentConversationDecision, ContextualAgentConversationInput, + ReplyPlacement, UnaddressedChannelAgentMode, +}; pub use usage::TurnUsage; use std::collections::{HashMap, HashSet, VecDeque}; diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 5c960de202..fe919bd73d 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1203,29 +1203,57 @@ fn turn_is_human_facing( thread_tags.mentioned_pubkeys.iter().any(|pk| !is_agent(pk)) } -/// Resolve the `--reply-to` anchor for a non-DM turn. +/// Count agent recipients addressed on the triggering event (`p` tags). /// -/// Returns `Some(id)` only for human-facing turns (see [`turn_is_human_facing`]): -/// - in a thread → the thread ROOT, keeping the reply flat at layer 1 -/// - top-level → the triggering event id, which becomes the new thread root +/// Uses NIP-OA profile classification when available. Unknown identities are +/// not counted as agents (fail closed for multi-agent detection). +fn count_addressed_agents( + thread_tags: &ThreadTags, + profile_lookup: Option<&PromptProfileLookup>, +) -> usize { + let is_agent = |pubkey: &str| -> bool { + profile_lookup + .and_then(|m| m.get(&normalize_lookup_key(pubkey))) + .map(|p| p.is_agent) + .unwrap_or(false) + }; + thread_tags + .mentioned_pubkeys + .iter() + .filter(|pk| is_agent(pk)) + .count() +} + +/// Resolve the `--reply-to` anchor for a non-DM turn. /// -/// Returns `None` for agent↔agent turns, leaving the agent free to nest deeply -/// (intentional for agent coordination). +/// Uses the shared contextual-conversation placement policy: +/// - human-facing, in a thread → thread ROOT (flat at layer 1) +/// - human-facing, top-level, ≥2 addressed agents → triggering event (shared thread) +/// - human-facing, top-level, one agent → no forced anchor (flat top-level) +/// - agent↔agent → unconstrained (`None`) fn resolve_reply_anchor( sender_pubkey: &str, thread_tags: &ThreadTags, triggering_event_id: &str, profile_lookup: Option<&PromptProfileLookup>, ) -> Option { - if !turn_is_human_facing(sender_pubkey, thread_tags, profile_lookup) { - return None; - } - Some( - thread_tags - .root_event_id - .clone() - .unwrap_or_else(|| triggering_event_id.to_string()), - ) + let is_human_facing = turn_is_human_facing(sender_pubkey, thread_tags, profile_lookup); + let message_position = if thread_tags.root_event_id.is_some() { + "in-thread" + } else { + "top-level" + }; + let placement = crate::contextual_conversation::resolve_acp_turn_placement( + &crate::contextual_conversation::AcpTurnPlacementInput { + is_dm: false, + is_human_facing, + message_position, + thread_root_event_id: thread_tags.root_event_id.clone(), + triggering_event_id: triggering_event_id.to_string(), + addressed_agent_count: count_addressed_agents(thread_tags, profile_lookup), + }, + ); + crate::contextual_conversation::reply_placement_anchor(&placement).map(str::to_string) } /// Format a `[Context]` hints section based on event scope. @@ -1464,17 +1492,32 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec= 12); + +function sorted(list) { + return [...list].sort(); +} + +function assertDecision(actual, expected, caseId) { + assert.deepEqual( + sorted(actual.audiencePubkeys), + sorted(expected.audiencePubkeys), + `${caseId}: audiencePubkeys`, + ); + assert.deepEqual( + actual.replyPlacement, + expected.replyPlacement, + `${caseId}: replyPlacement`, + ); + assert.equal( + actual.sharedThread, + expected.sharedThread, + `${caseId}: sharedThread`, + ); + assert.equal( + actual.retainDraft, + expected.retainDraft, + `${caseId}: retainDraft`, + ); + if (expected.nestUnderAgentReply !== undefined) { + assert.equal( + actual.nestUnderAgentReply ?? false, + expected.nestUnderAgentReply, + `${caseId}: nestUnderAgentReply`, + ); + } +} + +for (const c of fixture.cases) { + test(`contextual fixture (desktop): ${c.id}`, () => { + const input = { + ...c.input, + humanMessageEventId: + c.expected.replyPlacement?.kind === "thread-root" && + c.expected.replyPlacement.eventId === "human-message-id" + ? "human-message-id" + : (c.input.humanMessageEventId ?? null), + }; + const decision = resolveContextualAgentConversation(input); + assertDecision(decision, c.expected, c.id); + }); +} diff --git a/desktop/src/features/channels/lib/contextualAgentConversationPolicy.ts b/desktop/src/features/channels/lib/contextualAgentConversationPolicy.ts new file mode 100644 index 0000000000..4174b110a2 --- /dev/null +++ b/desktop/src/features/channels/lib/contextualAgentConversationPolicy.ts @@ -0,0 +1,199 @@ +/** + * Contextual agent audience + reply-placement policy (Desktop). + * + * Contract: tests/fixtures/contextual-agent-conversation-cases.json + * + * Pure resolver — no I/O. Device-local mode persistence lives in + * `unaddressedChannelAgentMode.ts`. + */ + +import { normalizePubkey } from "@/shared/lib/pubkey.ts"; + +export type UnaddressedChannelAgentMode = + | "all-channel-agents" + | "mentions-only"; + +export type ReplyPlacement = + | { kind: "top-level" } + | { kind: "thread-root"; eventId: string } + | { kind: "unconstrained" }; + +export type ContextualAgentConversationInput = { + conversation: "direct" | "channel"; + messagePosition: "top-level" | "in-thread"; + senderClass: "human" | "agent"; + unaddressedMode: UnaddressedChannelAgentMode; + keepAddressedAgentsActive: boolean; + explicitMentionPubkeys: string[]; + currentAgentPubkey: string | null; + channelMemberPubkeys: string[]; + verifiedChannelAgentPubkeys: string[]; + unverifiedAgentPubkeys?: string[]; + nonMemberAgentPubkeys?: string[]; + threadRootEventId: string | null; + replyingUnderEventId?: string | null; + persistentThreadAudience: string[]; + manualRemovedPubkeys: string[]; + recipientLoadError: boolean; + /** Id of the human message that becomes a multi-agent thread root. */ + humanMessageEventId?: string | null; +}; + +export type ContextualAgentConversationDecision = { + audiencePubkeys: string[]; + replyPlacement: ReplyPlacement; + sharedThread: boolean; + retainDraft: boolean; + nestUnderAgentReply?: boolean; +}; + +function uniqueSorted(pubkeys: Iterable): string[] { + const set = new Set(); + for (const pk of pubkeys) { + const n = normalizePubkey(pk); + if (n) set.add(n); + } + return [...set].sort(); +} + +function asSet(pubkeys: readonly string[]): Set { + return new Set(pubkeys.map(normalizePubkey).filter(Boolean)); +} + +/** + * Verified current-channel agents only: intersection of membership and + * verified agent evidence. Never community/relay-wide fanout. + */ +function eligibleChannelAgents( + input: ContextualAgentConversationInput, +): Set { + const members = asSet(input.channelMemberPubkeys); + const verified = asSet(input.verifiedChannelAgentPubkeys); + const eligible = new Set(); + for (const pk of verified) { + if (members.has(pk)) eligible.add(pk); + } + return eligible; +} + +function filterToEligible( + candidates: readonly string[], + eligible: Set, +): string[] { + return uniqueSorted( + candidates.filter((pk) => eligible.has(normalizePubkey(pk))), + ); +} + +/** + * Resolve audience and reply placement for a human/agent send path. + * + * Precedence: + * 1. Recipient-load errors fail closed (retain draft). + * 2. Agent-authored traffic is unconstrained. + * 3. Explicit @mentions (eligible agents only). + * 4. Persistent audience when Keep addressed agents active (minus manual removals). + * 5. Unaddressed mode: all verified channel agents, or mentions-only (none). + * 6. Direct conversations always resolve to the current agent. + */ +export function resolveContextualAgentConversation( + input: ContextualAgentConversationInput, +): ContextualAgentConversationDecision { + if (input.recipientLoadError) { + return { + audiencePubkeys: [], + replyPlacement: { kind: "top-level" }, + sharedThread: false, + retainDraft: true, + nestUnderAgentReply: false, + }; + } + + if (input.senderClass === "agent") { + return { + audiencePubkeys: [], + replyPlacement: { kind: "unconstrained" }, + sharedThread: false, + retainDraft: false, + nestUnderAgentReply: false, + }; + } + + // Human path + if (input.conversation === "direct") { + const current = input.currentAgentPubkey + ? normalizePubkey(input.currentAgentPubkey) + : null; + const audience = current ? [current] : []; + return { + audiencePubkeys: audience, + replyPlacement: placementFor(input, audience.length), + sharedThread: false, + retainDraft: false, + nestUnderAgentReply: false, + }; + } + + // Channel path + const eligible = eligibleChannelAgents(input); + const removed = asSet(input.manualRemovedPubkeys); + const explicit = filterToEligible( + input.explicitMentionPubkeys, + eligible, + ).filter((pk) => !removed.has(pk)); + + let audience: string[]; + + if (explicit.length > 0) { + audience = explicit; + } else { + const persistent = input.keepAddressedAgentsActive + ? filterToEligible(input.persistentThreadAudience, eligible).filter( + (pk) => !removed.has(pk), + ) + : []; + + if (persistent.length > 0) { + audience = persistent; + } else if (input.unaddressedMode === "all-channel-agents") { + audience = uniqueSorted(eligible).filter((pk) => !removed.has(pk)); + } else { + // mentions-only, no explicit, no persistent + audience = []; + } + } + + const sharedThread = audience.length >= 2; + return { + audiencePubkeys: audience, + replyPlacement: placementFor(input, audience.length), + sharedThread, + retainDraft: false, + nestUnderAgentReply: false, + }; +} + +function placementFor( + input: ContextualAgentConversationInput, + audienceCount: number, +): ReplyPlacement { + // Already in a thread: always continue at the existing root (never nest under + // an agent reply). + if (input.messagePosition === "in-thread" && input.threadRootEventId) { + return { + kind: "thread-root", + eventId: input.threadRootEventId, + }; + } + + // Multi-agent top-level human message creates one shared thread at the human + // event. + if (audienceCount >= 2) { + const eventId = input.humanMessageEventId ?? input.threadRootEventId; + if (eventId) { + return { kind: "thread-root", eventId }; + } + } + + return { kind: "top-level" }; +} diff --git a/desktop/src/features/channels/lib/unaddressedChannelAgentMode.test.mjs b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.test.mjs new file mode 100644 index 0000000000..c097e7055b --- /dev/null +++ b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE, + UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY, + parseUnaddressedChannelAgentMode, + readUnaddressedChannelAgentMode, + writeUnaddressedChannelAgentMode, +} from "./unaddressedChannelAgentMode.ts"; + +test("storage key matches fixture contract", () => { + assert.equal( + UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY, + "buzz:unaddressed-channel-agent-mode:v1", + ); +}); + +test("default mode is all-channel-agents", () => { + assert.equal(DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE, "all-channel-agents"); + assert.equal(parseUnaddressedChannelAgentMode(null), "all-channel-agents"); + assert.equal( + parseUnaddressedChannelAgentMode("garbage"), + "all-channel-agents", + ); +}); + +test("parse accepts both modes", () => { + assert.equal( + parseUnaddressedChannelAgentMode("all-channel-agents"), + "all-channel-agents", + ); + assert.equal( + parseUnaddressedChannelAgentMode("mentions-only"), + "mentions-only", + ); +}); + +test("read/write round-trip via mock storage", () => { + const map = new Map(); + const storage = { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => { + map.set(k, v); + }, + }; + assert.equal(readUnaddressedChannelAgentMode(storage), "all-channel-agents"); + writeUnaddressedChannelAgentMode("mentions-only", storage); + assert.equal(readUnaddressedChannelAgentMode(storage), "mentions-only"); + assert.equal( + map.get(UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY), + "mentions-only", + ); +}); diff --git a/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts new file mode 100644 index 0000000000..205a87f9fe --- /dev/null +++ b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts @@ -0,0 +1,112 @@ +/** + * Device-local setting: how unaddressed channel messages reach agents. + * + * Label: "Unaddressed channel messages" + * - Notify all channel agents → "all-channel-agents" (default) + * - Mentions only → "mentions-only" + * + * Semantic storage key is versioned; not community/relay policy. + */ + +import * as React from "react"; + +import type { UnaddressedChannelAgentMode } from "./contextualAgentConversationPolicy.ts"; + +/** Versioned device-local storage key (do not change without a migration). */ +export const UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY = + "buzz:unaddressed-channel-agent-mode:v1"; + +export const DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE: UnaddressedChannelAgentMode = + "all-channel-agents"; + +const listeners = new Set<() => void>(); + +let mode: UnaddressedChannelAgentMode = readStoredMode(); + +export function parseUnaddressedChannelAgentMode( + value: string | null | undefined, +): UnaddressedChannelAgentMode { + return value === "mentions-only" || value === "all-channel-agents" + ? value + : DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE; +} + +function readStoredMode( + storage: + | Pick + | null + | undefined = globalThis.localStorage, +): UnaddressedChannelAgentMode { + try { + return parseUnaddressedChannelAgentMode( + storage?.getItem(UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY), + ); + } catch { + return DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE; + } +} + +export function readUnaddressedChannelAgentMode( + storage: + | Pick + | null + | undefined = globalThis.localStorage, +): UnaddressedChannelAgentMode { + return readStoredMode(storage); +} + +export function writeUnaddressedChannelAgentMode( + next: UnaddressedChannelAgentMode, + storage: + | Pick + | null + | undefined = globalThis.localStorage, +): void { + if (mode === next) { + // Still persist in case storage was cleared while in-memory mode matched. + try { + storage?.setItem(UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY, next); + } catch { + // Best-effort. + } + return; + } + mode = next; + try { + storage?.setItem(UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY, next); + } catch { + // Best-effort persistence. + } + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): UnaddressedChannelAgentMode { + return mode; +} + +function getServerSnapshot(): UnaddressedChannelAgentMode { + return DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE; +} + +/** Device-local unaddressed-channel agent mode for React consumers. */ +export function useUnaddressedChannelAgentMode(): { + mode: UnaddressedChannelAgentMode; + setMode: (mode: UnaddressedChannelAgentMode) => void; +} { + const current = React.useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); + return { + mode: current, + setMode: writeUnaddressedChannelAgentMode, + }; +} diff --git a/desktop/src/features/messages/lib/composerSendAudience.test.mjs b/desktop/src/features/messages/lib/composerSendAudience.test.mjs new file mode 100644 index 0000000000..e7a2af080a --- /dev/null +++ b/desktop/src/features/messages/lib/composerSendAudience.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + describeComposerAudienceHint, + resolveComposerSendAudience, +} from "./composerSendAudience.ts"; + +const human = "1".repeat(64); +const agentA = "a".repeat(64); +const agentB = "b".repeat(64); + +test("channel multi-agent unaddressed merges all verified agents into mentions", () => { + const result = resolveComposerSendAudience({ + conversation: "channel", + messagePosition: "top-level", + unaddressedMode: "all-channel-agents", + keepAddressedAgentsActive: false, + explicitMentionPubkeys: [], + explicitAgentPubkeys: [], + currentAgentPubkey: null, + channelMemberPubkeys: [human, agentA, agentB], + verifiedChannelAgentPubkeys: [agentA, agentB], + persistentThreadAudience: [], + }); + assert.deepEqual([...result.mentionPubkeys].sort(), [agentA, agentB].sort()); + assert.equal(result.sharedThread, true); + assert.equal(result.replyPlacement.kind, "top-level"); // no humanMessageEventId +}); + +test("explicit agent mention overrides implicit all-agents", () => { + const result = resolveComposerSendAudience({ + conversation: "channel", + messagePosition: "top-level", + unaddressedMode: "all-channel-agents", + keepAddressedAgentsActive: false, + explicitMentionPubkeys: [agentB, human], + explicitAgentPubkeys: [agentB], + currentAgentPubkey: null, + channelMemberPubkeys: [human, agentA, agentB], + verifiedChannelAgentPubkeys: [agentA, agentB], + persistentThreadAudience: [], + }); + assert.deepEqual([...result.mentionPubkeys].sort(), [agentB, human].sort()); + assert.deepEqual(result.agentAudiencePubkeys, [agentB]); +}); + +test("mentions-only with no explicit agents yields empty agent audience", () => { + const result = resolveComposerSendAudience({ + conversation: "channel", + messagePosition: "top-level", + unaddressedMode: "mentions-only", + keepAddressedAgentsActive: false, + explicitMentionPubkeys: [human], + explicitAgentPubkeys: [], + currentAgentPubkey: null, + channelMemberPubkeys: [human, agentA], + verifiedChannelAgentPubkeys: [agentA], + persistentThreadAudience: [], + }); + assert.deepEqual(result.agentAudiencePubkeys, []); + assert.deepEqual(result.mentionPubkeys, [human]); +}); + +test("describeComposerAudienceHint covers modes", () => { + assert.match( + describeComposerAudienceHint({ + conversation: "channel", + unaddressedMode: "all-channel-agents", + explicitAgentCount: 0, + implicitAgentCount: 3, + retainDraft: false, + }) ?? "", + /all 3 channel agents/, + ); + assert.match( + describeComposerAudienceHint({ + conversation: "channel", + unaddressedMode: "mentions-only", + explicitAgentCount: 0, + implicitAgentCount: 0, + retainDraft: false, + }) ?? "", + /Mentions only/, + ); + assert.equal( + describeComposerAudienceHint({ + conversation: "direct", + unaddressedMode: "all-channel-agents", + explicitAgentCount: 0, + implicitAgentCount: 1, + retainDraft: false, + }), + null, + ); +}); + +test("keep-addressed persistent audience applies under mentions-only", () => { + const result = resolveComposerSendAudience({ + conversation: "channel", + messagePosition: "top-level", + unaddressedMode: "mentions-only", + keepAddressedAgentsActive: true, + explicitMentionPubkeys: [], + explicitAgentPubkeys: [], + currentAgentPubkey: null, + channelMemberPubkeys: [human, agentA, agentB], + verifiedChannelAgentPubkeys: [agentA, agentB], + persistentThreadAudience: [agentA], + }); + assert.deepEqual(result.agentAudiencePubkeys, [agentA]); + assert.equal(result.sharedThread, false); +}); + +test("recipient load error retains draft and clears audience", () => { + const result = resolveComposerSendAudience({ + conversation: "channel", + messagePosition: "top-level", + unaddressedMode: "all-channel-agents", + keepAddressedAgentsActive: false, + explicitMentionPubkeys: [], + explicitAgentPubkeys: [], + currentAgentPubkey: null, + channelMemberPubkeys: [human, agentA], + verifiedChannelAgentPubkeys: [agentA], + persistentThreadAudience: [], + recipientLoadError: true, + }); + assert.deepEqual(result.mentionPubkeys, []); + assert.equal(result.retainDraft, true); +}); + +test("direct conversation addresses current agent only", () => { + const result = resolveComposerSendAudience({ + conversation: "direct", + messagePosition: "top-level", + unaddressedMode: "all-channel-agents", + keepAddressedAgentsActive: false, + explicitMentionPubkeys: [], + explicitAgentPubkeys: [], + currentAgentPubkey: agentA, + channelMemberPubkeys: [human, agentA], + verifiedChannelAgentPubkeys: [agentA], + persistentThreadAudience: [], + }); + assert.deepEqual(result.mentionPubkeys, [agentA]); + assert.equal(result.sharedThread, false); + assert.equal(result.replyPlacement.kind, "top-level"); +}); + +test("direct path keeps explicit agent mentions for DM expansion", () => { + const result = resolveComposerSendAudience({ + conversation: "direct", + messagePosition: "top-level", + unaddressedMode: "all-channel-agents", + keepAddressedAgentsActive: false, + explicitMentionPubkeys: [agentB], + explicitAgentPubkeys: [agentB], + currentAgentPubkey: agentA, + channelMemberPubkeys: [human, agentA], + verifiedChannelAgentPubkeys: [agentA, agentB], + persistentThreadAudience: [], + }); + // Both the DM peer agent and the newly @mentioned agent must remain. + assert.deepEqual([...result.mentionPubkeys].sort(), [agentA, agentB].sort()); +}); + +test("manual removal drops persistent agent from audience", () => { + const result = resolveComposerSendAudience({ + conversation: "channel", + messagePosition: "top-level", + unaddressedMode: "mentions-only", + keepAddressedAgentsActive: true, + explicitMentionPubkeys: [], + explicitAgentPubkeys: [], + currentAgentPubkey: null, + channelMemberPubkeys: [human, agentA, agentB], + verifiedChannelAgentPubkeys: [agentA, agentB], + persistentThreadAudience: [agentA, agentB], + manualRemovedPubkeys: [agentB], + }); + assert.deepEqual(result.agentAudiencePubkeys, [agentA]); +}); diff --git a/desktop/src/features/messages/lib/composerSendAudience.ts b/desktop/src/features/messages/lib/composerSendAudience.ts new file mode 100644 index 0000000000..5e4954a24c --- /dev/null +++ b/desktop/src/features/messages/lib/composerSendAudience.ts @@ -0,0 +1,133 @@ +/** + * Merge explicit composer mentions with implicit contextual-agent audience + * for the outgoing p-tag set. + */ + +import { + resolveContextualAgentConversation, + type ContextualAgentConversationInput, + type UnaddressedChannelAgentMode, +} from "@/features/channels/lib/contextualAgentConversationPolicy.ts"; + +export type ComposerSendAudienceInput = { + conversation: "direct" | "channel"; + messagePosition: "top-level" | "in-thread"; + unaddressedMode: UnaddressedChannelAgentMode; + keepAddressedAgentsActive: boolean; + /** Explicit @mentions (any pubkey) from the draft body. */ + explicitMentionPubkeys: readonly string[]; + /** Explicit mentions that are agents (for policy). */ + explicitAgentPubkeys: readonly string[]; + currentAgentPubkey: string | null; + channelMemberPubkeys: readonly string[]; + verifiedChannelAgentPubkeys: readonly string[]; + persistentThreadAudience: readonly string[]; + manualRemovedPubkeys?: readonly string[]; + threadRootEventId?: string | null; + humanMessageEventId?: string | null; + recipientLoadError?: boolean; +}; + +export type ComposerSendAudienceResult = { + /** Full p-tag pubkey list (explicit non-agents + resolved agent audience). */ + mentionPubkeys: string[]; + /** Resolved agent audience only. */ + agentAudiencePubkeys: string[]; + sharedThread: boolean; + retainDraft: boolean; + replyPlacement: ReturnType< + typeof resolveContextualAgentConversation + >["replyPlacement"]; +}; + +function uniqueNormalized(pubkeys: Iterable): string[] { + return [ + ...new Set( + [...pubkeys].map((pk) => pk.trim().toLowerCase()).filter(Boolean), + ), + ]; +} + +/** + * Build the effective send audience for a human message. + * Non-agent explicit mentions are always preserved; agent audience follows policy. + */ +export function resolveComposerSendAudience( + input: ComposerSendAudienceInput, +): ComposerSendAudienceResult { + const explicitAgentSet = new Set( + uniqueNormalized(input.explicitAgentPubkeys), + ); + const policyInput: ContextualAgentConversationInput = { + conversation: input.conversation, + messagePosition: input.messagePosition, + senderClass: "human", + unaddressedMode: input.unaddressedMode, + keepAddressedAgentsActive: input.keepAddressedAgentsActive, + explicitMentionPubkeys: [...explicitAgentSet], + currentAgentPubkey: input.currentAgentPubkey, + channelMemberPubkeys: [...input.channelMemberPubkeys], + verifiedChannelAgentPubkeys: [...input.verifiedChannelAgentPubkeys], + threadRootEventId: input.threadRootEventId ?? null, + persistentThreadAudience: [...input.persistentThreadAudience], + manualRemovedPubkeys: [...(input.manualRemovedPubkeys ?? [])], + recipientLoadError: input.recipientLoadError ?? false, + humanMessageEventId: input.humanMessageEventId ?? null, + }; + + const decision = resolveContextualAgentConversation(policyInput); + // Always retain authored agent @mentions (e.g. DM expansion to a new agent) + // while still applying implicit/persistent audience from policy. + const agentAudience = uniqueNormalized([ + ...decision.audiencePubkeys, + ...explicitAgentSet, + ]); + const humanMentions = uniqueNormalized(input.explicitMentionPubkeys).filter( + (pk) => !explicitAgentSet.has(pk), + ); + const mentionPubkeys = uniqueNormalized([...humanMentions, ...agentAudience]); + + return { + mentionPubkeys, + agentAudiencePubkeys: agentAudience, + sharedThread: decision.sharedThread || agentAudience.length >= 2, + retainDraft: decision.retainDraft, + replyPlacement: decision.replyPlacement, + }; +} + +/** Human-readable composer hint for the unaddressed mode + draft state. */ +export function describeComposerAudienceHint({ + conversation, + unaddressedMode, + explicitAgentCount, + implicitAgentCount, + retainDraft, +}: { + conversation: "direct" | "channel"; + unaddressedMode: UnaddressedChannelAgentMode; + explicitAgentCount: number; + implicitAgentCount: number; + retainDraft: boolean; +}): string | null { + if (retainDraft) { + return "Could not resolve recipients — draft kept"; + } + if (conversation === "direct") { + return null; + } + if (explicitAgentCount > 0) { + return explicitAgentCount === 1 + ? "Notifying 1 mentioned agent" + : `Notifying ${explicitAgentCount} mentioned agents`; + } + if (implicitAgentCount > 0 && unaddressedMode === "all-channel-agents") { + return implicitAgentCount === 1 + ? "Notifying 1 channel agent" + : `Notifying all ${implicitAgentCount} channel agents`; + } + if (unaddressedMode === "mentions-only") { + return "Mentions only — agents are not auto-notified"; + } + return null; +} diff --git a/desktop/src/features/messages/ui/ComposerAudienceHint.tsx b/desktop/src/features/messages/ui/ComposerAudienceHint.tsx new file mode 100644 index 0000000000..693a3ca4cc --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerAudienceHint.tsx @@ -0,0 +1,12 @@ +/** Compact device-local audience indicator above the message editor. */ +export function ComposerAudienceHint({ hint }: { hint: string | null }) { + if (!hint) return null; + return ( +

+ {hint} +

+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5..60befe510c 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -51,11 +51,12 @@ import { } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; +import { ComposerAudienceHint } from "./ComposerAudienceHint"; +import { useComposerAgentAudience } from "./useComposerAgentAudience"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ @@ -124,7 +125,6 @@ function MessageComposerImpl({ : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; - // Snapshot composer state before edit mode so cancel can restore it. const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; @@ -287,12 +287,28 @@ function MessageComposerImpl({ mentions, richText, }); - const persistentAudience = persistentMentionHydration.audience; const persistentMentionHydrationRef = React.useRef( persistentMentionHydration, ); persistentMentionHydrationRef.current = persistentMentionHydration; + const { + composerAudienceHint, + audienceGeneration, + audienceRevision, + resolveComposerAudience, + onSuccessfulExplicitAgentAudience, + resolvePostSendContent, + } = useComposerAgentAudience({ + audienceThreadRootId, + channelType, + editTarget, + mentions, + ownerPubkey, + persistentMentionHydration, + richText, + }); + const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -309,18 +325,11 @@ function MessageComposerImpl({ setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, setSpoileredAttachmentUrls, - onSuccessfulExplicitAgentAudience: - persistentAudience.enabled && audienceContext && ownerPubkey - ? ({ channelId: successfulChannelId, ...promotion }) => { - const scope = getPersistentAgentAudienceScope({ - ownerPubkey, - channelId: successfulChannelId, - threadRootId: audienceThreadRootId, - }); - persistentAudience.promotePubkeys({ ...promotion, scope }); - } - : undefined, - resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, + onSuccessfulExplicitAgentAudience: audienceContext + ? onSuccessfulExplicitAgentAudience + : undefined, + resolvePostSendContent, + resolveComposerAudience, }); // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger @@ -474,7 +483,6 @@ function MessageComposerImpl({ [richText.editor, mentions.clearMentions, customEmoji], ); - // ── @ mention picker (toolbar button) ─────────────────────────────── const openMentionPicker = React.useCallback(() => { if (!richText.editor) return; const { text, cursor } = richText.getPlainTextAndCursor(); @@ -505,11 +513,9 @@ function MessageComposerImpl({ mentions.updateMentionQuery, ]); - // ── Submit message ────────────────────────────────────────────────── const submitMessage = React.useCallback(async () => { const trimmed = syncComposerContentFromEditor().trim(); - // Edit mode if (editTargetRef.current && onEditSaveRef.current) { if (isSendingRef.current || isUploadingRef.current) return; const currentPendingImeta = media.pendingImetaRef.current; @@ -575,7 +581,6 @@ function MessageComposerImpl({ return; } - // Normal send const currentPendingImeta = media.pendingImetaRef.current; const hasMedia = currentPendingImeta.length > 0; if ( @@ -609,8 +614,8 @@ function MessageComposerImpl({ ), spoileredAttachmentUrls, trimmed, - audienceGeneration: persistentAudience.generation, - audienceRevision: audienceScope ? persistentAudience.revision : null, + audienceGeneration, + audienceRevision: audienceScope ? audienceRevision : null, }); } finally { persistentMentionHydration.endSubmit(); @@ -635,21 +640,15 @@ function MessageComposerImpl({ onCaptureSendContext, onPreparingMentionSendChange, audienceScope, + audienceGeneration, + audienceRevision, persistentMentionHydration, - persistentAudience.generation, - persistentAudience.revision, ]); submitMessageRef.current = submitMessage; - // ── Auto-submit on draft send ──────────────────────────────────────────── - // When `autoSubmitDraftKey` is set (the user clicked "Send message" in the - // Drafts panel and confirmed), fire `submitMessage` once after mount so the - // draft is sent through the real send path (mention resolution, media, etc.). - // - // Guard: only fire when the effective draft key matches the trigger so a - // stale URL param on a different channel never fires a spurious send. - // - // Fires at most once per mount (empty dep array after the key check) — the + // Auto-submit draft once when Drafts panel confirms send. + // Guard: effective draft key must match trigger (no cross-channel fire). + // Fires at most once per mount — the // `onAutoSubmitComplete` callback clears the trigger before `submitMessage` // runs, preventing re-fire on re-render or back-navigation. const onAutoSubmitCompleteRef = React.useRef(onAutoSubmitComplete); @@ -688,7 +687,6 @@ function MessageComposerImpl({ // ── Keyboard handling ─────────────────────────────────────────────── // Tiptap handles formatting shortcuts (⌘B, ⌘I, etc.) natively. - // Plain Enter → submit is now handled inside the Tiptap `submitOnEnter` // extension (fires before ProseMirror's splitBlock). This wrapper only // handles autocomplete arrow/enter keys and Escape for edit mode. const handleEditorKeyDown = React.useCallback( @@ -956,6 +954,8 @@ function MessageComposerImpl({ ) : null} + + {(media.pendingImeta.length > 0 || media.isUploading) && (
; + +export function useComposerAgentAudience({ + audienceThreadRootId, + channelType, + editTarget, + mentions, + ownerPubkey, + persistentMentionHydration, + richText, +}: { + audienceThreadRootId: string | null; + channelType: ChannelType | null; + editTarget: unknown; + mentions: UseMentionsResult; + ownerPubkey: string | null | undefined; + persistentMentionHydration: PersistentHydration; + richText: UseRichTextEditorResult; +}): { + composerAudienceHint: string | null; + audienceGeneration: number; + audienceRevision: number; + resolveComposerAudience: (input: { + explicitMentionPubkeys: string[]; + explicitAgentPubkeys: string[]; + messagePosition: "top-level" | "in-thread"; + threadRootEventId: string | null; + }) => ComposerSendAudienceResult; + onSuccessfulExplicitAgentAudience: + | ((audience: { + channelId: string; + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + }) => void) + | undefined; + resolvePostSendContent: PersistentHydration["resolvePostSendContent"]; +} { + const persistentAudience = persistentMentionHydration.audience; + const { mode: unaddressedMode } = useUnaddressedChannelAgentMode(); + const conversationKind = channelType === "dm" ? "direct" : "channel"; + + const channelMemberPubkeyList = React.useMemo( + () => [...mentions.memberPubkeys], + [mentions.memberPubkeys], + ); + const verifiedChannelAgentPubkeys = React.useMemo( + () => channelMemberPubkeyList.filter((pk) => mentions.isAgentPubkey(pk)), + [channelMemberPubkeyList, mentions.isAgentPubkey], + ); + const currentAgentPubkey = React.useMemo(() => { + if (conversationKind !== "direct") return null; + const agents = verifiedChannelAgentPubkeys.filter( + (pk) => pk !== normalizePubkey(ownerPubkey ?? ""), + ); + return agents[0] ?? null; + }, [conversationKind, ownerPubkey, verifiedChannelAgentPubkeys]); + + const resolveComposerAudience = React.useCallback( + ({ + explicitMentionPubkeys, + explicitAgentPubkeys, + messagePosition, + threadRootEventId, + }: { + explicitMentionPubkeys: string[]; + explicitAgentPubkeys: string[]; + messagePosition: "top-level" | "in-thread"; + threadRootEventId: string | null; + }) => + resolveComposerSendAudience({ + conversation: conversationKind, + messagePosition, + unaddressedMode, + keepAddressedAgentsActive: persistentAudience.enabled, + explicitMentionPubkeys, + explicitAgentPubkeys, + currentAgentPubkey, + channelMemberPubkeys: channelMemberPubkeyList, + verifiedChannelAgentPubkeys, + persistentThreadAudience: [...persistentAudience.pubkeys], + threadRootEventId, + recipientLoadError: + !mentions.hasResolvedMembers && conversationKind === "channel", + }), + [ + channelMemberPubkeyList, + conversationKind, + currentAgentPubkey, + mentions.hasResolvedMembers, + persistentAudience.enabled, + persistentAudience.pubkeys, + unaddressedMode, + verifiedChannelAgentPubkeys, + ], + ); + + const composerAudienceHint = React.useMemo(() => { + if (editTarget != null || conversationKind === "direct") return null; + const text = richText.getPlainTextAndCursor().text; + const explicitMentionPubkeys = mentions.extractMentionPubkeys(text); + const explicitAgentPubkeys = explicitMentionPubkeys.filter((pk) => + mentions.isAgentPubkey(pk), + ); + const decision = resolveComposerAudience({ + explicitMentionPubkeys, + explicitAgentPubkeys, + messagePosition: audienceThreadRootId ? "in-thread" : "top-level", + threadRootEventId: audienceThreadRootId, + }); + return describeComposerAudienceHint({ + conversation: conversationKind, + unaddressedMode, + explicitAgentCount: explicitAgentPubkeys.length, + implicitAgentCount: + explicitAgentPubkeys.length > 0 + ? 0 + : decision.agentAudiencePubkeys.length, + retainDraft: decision.retainDraft, + }); + }, [ + audienceThreadRootId, + conversationKind, + editTarget, + mentions, + resolveComposerAudience, + richText, + unaddressedMode, + ]); + + const onSuccessfulExplicitAgentAudience = + persistentAudience.enabled && ownerPubkey + ? ({ + channelId: successfulChannelId, + ...promotion + }: { + channelId: string; + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + }) => { + const scope = getPersistentAgentAudienceScope({ + ownerPubkey, + channelId: successfulChannelId, + threadRootId: audienceThreadRootId, + }); + persistentAudience.promotePubkeys({ ...promotion, scope }); + } + : undefined; + + return { + composerAudienceHint, + audienceGeneration: persistentAudience.generation, + audienceRevision: persistentAudience.revision, + resolveComposerAudience, + onSuccessfulExplicitAgentAudience, + resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, + }; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 5e9ef27925..81884ee3f1 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -13,6 +13,7 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; +import type { ComposerSendAudienceResult } from "@/features/messages/lib/composerSendAudience"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -109,6 +110,16 @@ type UseMentionSendFlowOptions = { explicitAgentPubkeys: string[]; }) => void; resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; + /** + * Optional contextual-agent audience resolver. When provided, merges + * implicit channel-agent audience into the outgoing p-tag set. + */ + resolveComposerAudience?: (input: { + explicitMentionPubkeys: string[]; + explicitAgentPubkeys: string[]; + messagePosition: "top-level" | "in-thread"; + threadRootEventId: string | null; + }) => ComposerSendAudienceResult; }; function mergeOutgoingTagsWithReferenceMentions( @@ -165,6 +176,7 @@ export function useMentionSendFlow({ setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, + resolveComposerAudience, }: UseMentionSendFlowOptions) { const [pendingNonMemberSend, setPendingNonMemberSend] = React.useState(null); @@ -720,7 +732,34 @@ export function useMentionSendFlow({ mentions.isAgentPubkey(pubkey) || createdPersonaAgentPubkeySet.has(pubkey), ); - const pubkeys = explicitMentionPubkeys; + const messagePosition = + capturedThreadContext?.parentEventId || + capturedThreadContext?.threadHeadId + ? "in-thread" + : "top-level"; + const threadRootEventId = + capturedThreadContext?.threadHeadId ?? + capturedThreadContext?.parentEventId ?? + null; + const audienceDecision = resolveComposerAudience?.({ + explicitMentionPubkeys, + explicitAgentPubkeys, + messagePosition, + threadRootEventId, + }); + if (audienceDecision?.retainDraft) { + setNonMemberPromptError( + "Could not resolve agent audience. Your draft was kept.", + ); + toast.error("Could not resolve agent audience. Your draft was kept."); + return; + } + const pubkeys = audienceDecision + ? uniqueNormalizedPubkeys([ + ...audienceDecision.mentionPubkeys, + ...createdPersonaAgentPubkeys, + ]) + : explicitMentionPubkeys; const { content: finalContent, mediaTags } = buildOutgoingMessage( trimmed, pendingImeta, @@ -794,6 +833,7 @@ export function useMentionSendFlow({ mentions.isAgentPubkey, mentions.isManagedAgentPubkey, onPrepareSendChannel, + resolveComposerAudience, ], ); diff --git a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx index 56e9c294bc..b421fd2db6 100644 --- a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx +++ b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx @@ -1,16 +1,28 @@ import { usePreventSleepContext } from "@/features/agents/usePreventSleep"; -import { Switch } from "@/shared/ui/switch"; -import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import type { UnaddressedChannelAgentMode } from "@/features/channels/lib/contextualAgentConversationPolicy"; +import { useUnaddressedChannelAgentMode } from "@/features/channels/lib/unaddressedChannelAgentMode"; import { setPersistentAgentAudienceEnabled, usePersistentAgentAudience, } from "@/features/messages/lib/persistentAgentAudience"; +import { Switch } from "@/shared/ui/switch"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; +const UNADDRESSED_MODE_OPTIONS: { + value: UnaddressedChannelAgentMode; + label: string; +}[] = [ + { value: "all-channel-agents", label: "Notify all channel agents" }, + { value: "mentions-only", label: "Mentions only" }, +]; + export function PreventSleepSettingsCard() { const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } = usePreventSleepContext(); const persistentAudience = usePersistentAgentAudience(null); + const { mode: unaddressedMode, setMode: setUnaddressedMode } = + useUnaddressedChannelAgentMode(); return (
@@ -20,6 +32,47 @@ export function PreventSleepSettingsCard() { /> + +
+

+ Unaddressed channel messages +

+

+ When you post in a channel without @mentioning anyone, choose who + is notified. Direct messages always address their current agent. +

+
+ {UNADDRESSED_MODE_OPTIONS.map((option) => ( + + ))} +
+
+
+