From 61df9362dcd05311ff66ceb134a7604185cdc27b Mon Sep 17 00:00:00 2001 From: Brian Charbonneau Date: Mon, 3 Aug 2026 16:50:18 -0700 Subject: [PATCH 1/9] feat(agents): add contextual conversation policy fixture and resolvers Shared test-only fixture for audience selection and reply placement, with pure Desktop/Rust/Dart resolvers that implement the contract and device-local unaddressed-channel mode storage key. Composer/ACP/UI wiring remains later. Signed-off-by: Brian Charbonneau (cherry picked from commit 78458eb84a0faeb0c51f07d74b02563439db7e52) Signed-off-by: Brian Charbonneau --- .../buzz-acp/src/contextual_conversation.rs | 331 +++++++++++++ crates/buzz-acp/src/lib.rs | 5 + ...contextualAgentConversationPolicy.test.mjs | 65 +++ .../lib/contextualAgentConversationPolicy.ts | 197 ++++++++ .../lib/unaddressedChannelAgentMode.test.mjs | 45 ++ .../lib/unaddressedChannelAgentMode.ts | 47 ++ .../contextual_agent_conversation_policy.dart | 197 ++++++++ ...extual_agent_conversation_policy_test.dart | 151 ++++++ .../contextual-agent-conversation-cases.json | 446 ++++++++++++++++++ 9 files changed, 1484 insertions(+) create mode 100644 crates/buzz-acp/src/contextual_conversation.rs create mode 100644 desktop/src/features/channels/lib/contextualAgentConversationPolicy.test.mjs create mode 100644 desktop/src/features/channels/lib/contextualAgentConversationPolicy.ts create mode 100644 desktop/src/features/channels/lib/unaddressedChannelAgentMode.test.mjs create mode 100644 desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts create mode 100644 mobile/lib/shared/contextual_agent/contextual_agent_conversation_policy.dart create mode 100644 mobile/test/shared/contextual_agent/contextual_agent_conversation_policy_test.dart create mode 100644 tests/fixtures/contextual-agent-conversation-cases.json diff --git a/crates/buzz-acp/src/contextual_conversation.rs b/crates/buzz-acp/src/contextual_conversation.rs new file mode 100644 index 0000000000..93c3a0f04c --- /dev/null +++ b/crates/buzz-acp/src/contextual_conversation.rs @@ -0,0 +1,331 @@ +//! 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 +} + +/// 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 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..2d4a9ba4a5 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,10 @@ mod relay; mod setup_mode; mod usage; +pub use contextual_conversation::{ + resolve_contextual_agent_conversation, ContextualAgentConversationDecision, + ContextualAgentConversationInput, ReplyPlacement, UnaddressedChannelAgentMode, +}; pub use usage::TurnUsage; use std::collections::{HashMap, HashSet, VecDeque}; diff --git a/desktop/src/features/channels/lib/contextualAgentConversationPolicy.test.mjs b/desktop/src/features/channels/lib/contextualAgentConversationPolicy.test.mjs new file mode 100644 index 0000000000..362c3b0f98 --- /dev/null +++ b/desktop/src/features/channels/lib/contextualAgentConversationPolicy.test.mjs @@ -0,0 +1,65 @@ +/** + * Red: shared contextual-agent fixture must match Desktop policy decisions. + * Expected: every case fails until resolveContextualAgentConversation is + * implemented (Green leaf). + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { resolveContextualAgentConversation } from "./contextualAgentConversationPolicy.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturePath = path.resolve( + __dirname, + "../../../../../tests/fixtures/contextual-agent-conversation-cases.json", +); +const fixture = JSON.parse(readFileSync(fixturePath, "utf8")); + +assert.equal(fixture.version, 1); +assert.ok(Array.isArray(fixture.cases)); +assert.ok(fixture.cases.length >= 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..d268c42398 --- /dev/null +++ b/desktop/src/features/channels/lib/contextualAgentConversationPolicy.ts @@ -0,0 +1,197 @@ +/** + * 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`. + */ + +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 normalizePubkey(pubkey: string): string { + return pubkey.trim().toLowerCase(); +} + +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..db20ecc870 --- /dev/null +++ b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.test.mjs @@ -0,0 +1,45 @@ +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..cb261ed6ef --- /dev/null +++ b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts @@ -0,0 +1,47 @@ +/** + * 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 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"; + +export function parseUnaddressedChannelAgentMode( + value: string | null | undefined, +): UnaddressedChannelAgentMode { + return value === "mentions-only" || value === "all-channel-agents" + ? value + : DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE; +} + +export function readUnaddressedChannelAgentMode( + 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 writeUnaddressedChannelAgentMode( + mode: UnaddressedChannelAgentMode, + storage: Pick | null | undefined = globalThis.localStorage, +): void { + try { + storage?.setItem(UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY, mode); + } catch { + // Best-effort persistence. + } +} diff --git a/mobile/lib/shared/contextual_agent/contextual_agent_conversation_policy.dart b/mobile/lib/shared/contextual_agent/contextual_agent_conversation_policy.dart new file mode 100644 index 0000000000..9486b6cc59 --- /dev/null +++ b/mobile/lib/shared/contextual_agent/contextual_agent_conversation_policy.dart @@ -0,0 +1,197 @@ +/// Contextual agent audience + reply-placement policy (Flutter). +/// +/// Contract: tests/fixtures/contextual-agent-conversation-cases.json +/// Pure resolver — composer/settings wiring comes in later Flutter leaves. + +enum UnaddressedChannelAgentMode { + allChannelAgents, + mentionsOnly, +} + +sealed class ReplyPlacement { + const ReplyPlacement(); +} + +class TopLevelPlacement extends ReplyPlacement { + const TopLevelPlacement(); +} + +class ThreadRootPlacement extends ReplyPlacement { + const ThreadRootPlacement(this.eventId); + final String eventId; +} + +class UnconstrainedPlacement extends ReplyPlacement { + const UnconstrainedPlacement(); +} + +class ContextualAgentConversationInput { + const ContextualAgentConversationInput({ + required this.conversation, + required this.messagePosition, + required this.senderClass, + required this.unaddressedMode, + required this.keepAddressedAgentsActive, + required this.explicitMentionPubkeys, + required this.currentAgentPubkey, + required this.channelMemberPubkeys, + required this.verifiedChannelAgentPubkeys, + this.unverifiedAgentPubkeys = const [], + this.nonMemberAgentPubkeys = const [], + required this.threadRootEventId, + this.replyingUnderEventId, + required this.persistentThreadAudience, + required this.manualRemovedPubkeys, + required this.recipientLoadError, + this.humanMessageEventId, + }); + + final String conversation; + final String messagePosition; + final String senderClass; + final UnaddressedChannelAgentMode unaddressedMode; + final bool keepAddressedAgentsActive; + final List explicitMentionPubkeys; + final String? currentAgentPubkey; + final List channelMemberPubkeys; + final List verifiedChannelAgentPubkeys; + final List unverifiedAgentPubkeys; + final List nonMemberAgentPubkeys; + final String? threadRootEventId; + final String? replyingUnderEventId; + final List persistentThreadAudience; + final List manualRemovedPubkeys; + final bool recipientLoadError; + final String? humanMessageEventId; +} + +class ContextualAgentConversationDecision { + const ContextualAgentConversationDecision({ + required this.audiencePubkeys, + required this.replyPlacement, + required this.sharedThread, + required this.retainDraft, + this.nestUnderAgentReply = false, + }); + + final List audiencePubkeys; + final ReplyPlacement replyPlacement; + final bool sharedThread; + final bool retainDraft; + final bool nestUnderAgentReply; +} + +String _normalizePubkey(String pubkey) => pubkey.trim().toLowerCase(); + +List _uniqueSorted(Iterable pubkeys) { + final set = {}; + for (final pk in pubkeys) { + final n = _normalizePubkey(pk); + if (n.isNotEmpty) set.add(n); + } + final list = set.toList()..sort(); + return list; +} + +Set _eligibleChannelAgents(ContextualAgentConversationInput input) { + final members = input.channelMemberPubkeys.map(_normalizePubkey).toSet(); + return input.verifiedChannelAgentPubkeys + .map(_normalizePubkey) + .where(members.contains) + .toSet(); +} + +List _filterToEligible( + List candidates, + Set eligible, +) { + return _uniqueSorted( + candidates.map(_normalizePubkey).where(eligible.contains), + ); +} + +ReplyPlacement _placementFor( + ContextualAgentConversationInput input, + int audienceCount, +) { + if (input.messagePosition == 'in-thread' && + input.threadRootEventId != null && + input.threadRootEventId!.isNotEmpty) { + return ThreadRootPlacement(input.threadRootEventId!); + } + if (audienceCount >= 2) { + final eventId = input.humanMessageEventId ?? input.threadRootEventId; + if (eventId != null && eventId.isNotEmpty) { + return ThreadRootPlacement(eventId); + } + } + return const TopLevelPlacement(); +} + +/// Resolve audience and reply placement for a human/agent send path. +ContextualAgentConversationDecision resolveContextualAgentConversation( + ContextualAgentConversationInput input, +) { + if (input.recipientLoadError) { + return const ContextualAgentConversationDecision( + audiencePubkeys: [], + replyPlacement: TopLevelPlacement(), + sharedThread: false, + retainDraft: true, + ); + } + + if (input.senderClass == 'agent') { + return const ContextualAgentConversationDecision( + audiencePubkeys: [], + replyPlacement: UnconstrainedPlacement(), + sharedThread: false, + retainDraft: false, + ); + } + + if (input.conversation == 'direct') { + final current = input.currentAgentPubkey; + final audience = + current == null || current.isEmpty ? [] : [_normalizePubkey(current)]; + return ContextualAgentConversationDecision( + audiencePubkeys: audience, + replyPlacement: _placementFor(input, audience.length), + sharedThread: false, + retainDraft: false, + ); + } + + final eligible = _eligibleChannelAgents(input); + final removed = input.manualRemovedPubkeys.map(_normalizePubkey).toSet(); + + final explicit = _filterToEligible(input.explicitMentionPubkeys, eligible) + .where((pk) => !removed.contains(pk)) + .toList(); + + late final List audience; + if (explicit.isNotEmpty) { + audience = explicit; + } else { + final persistent = input.keepAddressedAgentsActive + ? _filterToEligible(input.persistentThreadAudience, eligible) + .where((pk) => !removed.contains(pk)) + .toList() + : []; + if (persistent.isNotEmpty) { + audience = persistent; + } else if (input.unaddressedMode == + UnaddressedChannelAgentMode.allChannelAgents) { + audience = _uniqueSorted(eligible).where((pk) => !removed.contains(pk)).toList(); + } else { + audience = []; + } + } + + return ContextualAgentConversationDecision( + audiencePubkeys: audience, + replyPlacement: _placementFor(input, audience.length), + sharedThread: audience.length >= 2, + retainDraft: false, + ); +} diff --git a/mobile/test/shared/contextual_agent/contextual_agent_conversation_policy_test.dart b/mobile/test/shared/contextual_agent/contextual_agent_conversation_policy_test.dart new file mode 100644 index 0000000000..90118042e0 --- /dev/null +++ b/mobile/test/shared/contextual_agent/contextual_agent_conversation_policy_test.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/contextual_agent/contextual_agent_conversation_policy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map loadFixture() { + // Walk up from CWD to the monorepo root fixture. + var dir = Directory.current; + for (var i = 0; i < 8; i++) { + final candidate = File( + '${dir.path}/tests/fixtures/contextual-agent-conversation-cases.json', + ); + if (candidate.existsSync()) { + return jsonDecode(candidate.readAsStringSync()) as Map; + } + // When tests run with CWD=mobile/, also check the parent monorepo root. + final parentCandidate = File( + '${dir.path}/../tests/fixtures/contextual-agent-conversation-cases.json', + ); + if (parentCandidate.existsSync()) { + return jsonDecode(parentCandidate.readAsStringSync()) + as Map; + } + final parent = dir.parent; + if (parent.path == dir.path) break; + dir = parent; + } + fail( + 'could not locate tests/fixtures/contextual-agent-conversation-cases.json', + ); +} + +UnaddressedChannelAgentMode parseMode(String raw) { + switch (raw) { + case 'mentions-only': + return UnaddressedChannelAgentMode.mentionsOnly; + case 'all-channel-agents': + default: + return UnaddressedChannelAgentMode.allChannelAgents; + } +} + +List strList(Map map, String key) { + final value = map[key]; + if (value is! List) return const []; + return value.map((e) => e.toString()).toList(); +} + +String? optStr(Map map, String key) { + final value = map[key]; + if (value == null) return null; + return value.toString(); +} + +ContextualAgentConversationInput parseInput(Map input) { + return ContextualAgentConversationInput( + conversation: input['conversation'] as String, + messagePosition: input['messagePosition'] as String, + senderClass: input['senderClass'] as String, + unaddressedMode: parseMode(input['unaddressedMode'] as String), + keepAddressedAgentsActive: input['keepAddressedAgentsActive'] as bool, + explicitMentionPubkeys: strList(input, 'explicitMentionPubkeys'), + currentAgentPubkey: optStr(input, 'currentAgentPubkey'), + channelMemberPubkeys: strList(input, 'channelMemberPubkeys'), + verifiedChannelAgentPubkeys: strList(input, 'verifiedChannelAgentPubkeys'), + unverifiedAgentPubkeys: strList(input, 'unverifiedAgentPubkeys'), + nonMemberAgentPubkeys: strList(input, 'nonMemberAgentPubkeys'), + threadRootEventId: optStr(input, 'threadRootEventId'), + replyingUnderEventId: optStr(input, 'replyingUnderEventId'), + persistentThreadAudience: strList(input, 'persistentThreadAudience'), + manualRemovedPubkeys: strList(input, 'manualRemovedPubkeys'), + recipientLoadError: input['recipientLoadError'] as bool? ?? false, + humanMessageEventId: optStr(input, 'humanMessageEventId'), + ); +} + +ReplyPlacement parseExpectedPlacement(Map expected) { + final placement = expected['replyPlacement'] as Map; + switch (placement['kind'] as String) { + case 'thread-root': + return ThreadRootPlacement(placement['eventId'] as String); + case 'unconstrained': + return const UnconstrainedPlacement(); + case 'top-level': + default: + return const TopLevelPlacement(); + } +} + +void main() { + final fixture = loadFixture(); + final cases = (fixture['cases'] as List).cast>(); + + test('fixture version and case count', () { + expect(fixture['version'], 1); + expect(cases.length, greaterThanOrEqualTo(12)); + }); + + for (final c in cases) { + final id = c['id'] as String; + test('contextual fixture (flutter): $id', () { + final inputMap = Map.from(c['input'] as Map); + final expected = c['expected'] as Map; + final placement = expected['replyPlacement'] as Map; + if (placement['kind'] == 'thread-root' && + placement['eventId'] == 'human-message-id') { + inputMap['humanMessageEventId'] = 'human-message-id'; + } + final input = parseInput(inputMap); + final decision = resolveContextualAgentConversation(input); + + final actualAudience = [...decision.audiencePubkeys]..sort(); + final expectedAudience = strList(expected, 'audiencePubkeys')..sort(); + expect(actualAudience, expectedAudience, reason: '$id audience'); + + final expectedPlacement = parseExpectedPlacement(expected); + expect( + decision.replyPlacement.runtimeType, + expectedPlacement.runtimeType, + reason: '$id placement kind', + ); + if (expectedPlacement is ThreadRootPlacement && + decision.replyPlacement is ThreadRootPlacement) { + expect( + (decision.replyPlacement as ThreadRootPlacement).eventId, + expectedPlacement.eventId, + reason: '$id thread root id', + ); + } + + expect( + decision.sharedThread, + expected['sharedThread'], + reason: '$id shared', + ); + expect( + decision.retainDraft, + expected['retainDraft'], + reason: '$id retain', + ); + if (expected.containsKey('nestUnderAgentReply')) { + expect( + decision.nestUnderAgentReply, + expected['nestUnderAgentReply'], + reason: '$id nest', + ); + } + }); + } +} diff --git a/tests/fixtures/contextual-agent-conversation-cases.json b/tests/fixtures/contextual-agent-conversation-cases.json new file mode 100644 index 0000000000..a3367b7d86 --- /dev/null +++ b/tests/fixtures/contextual-agent-conversation-cases.json @@ -0,0 +1,446 @@ +{ + "version": 1, + "description": "Test-only contract for contextual agent audience selection and reply placement. Shared by Desktop (TypeScript), ACP (Rust), and Flutter (Dart). Do not use as runtime protocol.", + "storageKey": "buzz:unaddressed-channel-agent-mode:v1", + "defaultMode": "all-channel-agents", + "modes": ["all-channel-agents", "mentions-only"], + "replyPlacementKinds": ["top-level", "thread-root", "unconstrained"], + "actors": { + "human": "1111111111111111111111111111111111111111111111111111111111111111", + "agentA": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "agentB": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "agentCUnverified": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "agentOutsideChannel": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "events": { + "threadRoot": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "agentReplyInThread": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "cases": [ + { + "id": "direct-top-level-flat", + "description": "Direct conversation, top-level human message: address current agent; agent response is top-level (no reply e tag).", + "input": { + "conversation": "direct", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "direct-threaded-stays-at-root", + "description": "Direct conversation already in a thread continues at the existing root.", + "input": { + "conversation": "direct", + "messagePosition": "in-thread", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "threadRootEventId": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "persistentThreadAudience": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "replyPlacement": { + "kind": "thread-root", + "eventId": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "channel-one-agent-unaddressed-flat", + "description": "Unaddressed channel message with one verified current-channel agent: address that agent; flat top-level response.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "channel-multi-agent-unaddressed-shared-thread", + "description": "Unaddressed channel message with multiple verified agents: address all; one shared thread; depth-one sibling replies.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "replyPlacement": { + "kind": "thread-root", + "eventId": "human-message-id" + }, + "sharedThread": true, + "retainDraft": false + } + }, + { + "id": "explicit-mentions-override-implicit", + "description": "Explicit @mentions override implicit all-channel-agents broadcast.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "mentions-only-disables-implicit", + "description": "Mentions-only mode: unaddressed channel message addresses no agents.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "mentions-only", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "keep-addressed-agents-persistent", + "description": "When Keep addressed agents active is enabled, prior addressed agents join the audience without explicit re-mention.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "mentions-only", + "keepAddressedAgentsActive": true, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "threadRootEventId": null, + "persistentThreadAudience": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "manual-removal-wins", + "description": "Manual audience removal wins over persistent Keep-addressed for the current composition.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "mentions-only", + "keepAddressedAgentsActive": true, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "threadRootEventId": null, + "persistentThreadAudience": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "manualRemovedPubkeys": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "exclude-unverified-and-non-members", + "description": "Implicit audience excludes non-members and unverified relay agents; never community-wide fanout.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "unverifiedAgentPubkeys": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "nonMemberAgentPubkeys": [ + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": false + } + }, + { + "id": "recipient-load-error-retains-draft", + "description": "Recipient-loading errors fail closed and retain the draft.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": true + }, + "expected": { + "audiencePubkeys": [], + "replyPlacement": { "kind": "top-level" }, + "sharedThread": false, + "retainDraft": true + } + }, + { + "id": "human-followup-in-thread-targets-root", + "description": "Human follow-up inside a multi-agent thread targets the root and never nests under an agent reply (no depth two).", + "input": { + "conversation": "channel", + "messagePosition": "in-thread", + "senderClass": "human", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": true, + "explicitMentionPubkeys": [], + "currentAgentPubkey": null, + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "threadRootEventId": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "replyingUnderEventId": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "persistentThreadAudience": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "replyPlacement": { + "kind": "thread-root", + "eventId": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "sharedThread": true, + "retainDraft": false, + "nestUnderAgentReply": false + } + }, + { + "id": "agent-authored-traffic-unchanged", + "description": "Agent-only traffic preserves unconstrained placement and existing audience behavior.", + "input": { + "conversation": "channel", + "messagePosition": "top-level", + "senderClass": "agent", + "unaddressedMode": "all-channel-agents", + "keepAddressedAgentsActive": false, + "explicitMentionPubkeys": [], + "currentAgentPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "channelMemberPubkeys": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "verifiedChannelAgentPubkeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "threadRootEventId": null, + "persistentThreadAudience": [], + "manualRemovedPubkeys": [], + "recipientLoadError": false + }, + "expected": { + "audiencePubkeys": [], + "replyPlacement": { "kind": "unconstrained" }, + "sharedThread": false, + "retainDraft": false + } + } + ] +} From 86cc3c94dbd432a9945da6596c4b66971bf8ae07 Mon Sep 17 00:00:00 2001 From: Brian Charbonneau Date: Mon, 3 Aug 2026 17:00:03 -0700 Subject: [PATCH 2/9] feat(desktop): wire contextual agent audience settings and send path Add device-local Unaddressed channel messages setting, merge pure policy into mention send p-tags, and show a composer audience hint. Explicit mentions still win; mentions-only disables implicit channel-agent notify. Signed-off-by: Brian Charbonneau (cherry picked from commit 88a1161a7a1fa0c0d8319005bfacc3ee749e9188) Signed-off-by: Brian Charbonneau --- .../lib/unaddressedChannelAgentMode.ts | 68 +++++++++- .../lib/composerSendAudience.test.mjs | 96 +++++++++++++ .../messages/lib/composerSendAudience.ts | 128 ++++++++++++++++++ .../features/messages/ui/MessageComposer.tsx | 118 ++++++++++++++++ .../messages/ui/useMentionSendFlow.ts | 42 +++++- .../settings/ui/PreventSleepSettingsCard.tsx | 57 +++++++- 6 files changed, 500 insertions(+), 9 deletions(-) create mode 100644 desktop/src/features/messages/lib/composerSendAudience.test.mjs create mode 100644 desktop/src/features/messages/lib/composerSendAudience.ts diff --git a/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts index cb261ed6ef..9a893669ed 100644 --- a/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts +++ b/desktop/src/features/channels/lib/unaddressedChannelAgentMode.ts @@ -8,6 +8,8 @@ * 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). */ @@ -17,6 +19,10 @@ export const UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY = 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 { @@ -25,23 +31,73 @@ export function parseUnaddressedChannelAgentMode( : DEFAULT_UNADDRESSED_CHANNEL_AGENT_MODE; } -export function readUnaddressedChannelAgentMode( - storage: Pick | null | undefined = globalThis.localStorage, +function readStoredMode( + storage: + | Pick + | null + | undefined = globalThis.localStorage, ): UnaddressedChannelAgentMode { try { - return parseUnaddressedChannelAgentMode(storage?.getItem(UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY)); + 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( - mode: UnaddressedChannelAgentMode, - storage: Pick | null | undefined = globalThis.localStorage, + next: UnaddressedChannelAgentMode, + storage: + | Pick + | null + | undefined = globalThis.localStorage, ): void { + mode = next; try { - storage?.setItem(UNADDRESSED_CHANNEL_AGENT_MODE_STORAGE_KEY, mode); + 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..c0300dc8f5 --- /dev/null +++ b/desktop/src/features/messages/lib/composerSendAudience.test.mjs @@ -0,0 +1,96 @@ +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, + ); +}); diff --git a/desktop/src/features/messages/lib/composerSendAudience.ts b/desktop/src/features/messages/lib/composerSendAudience.ts new file mode 100644 index 0000000000..ac70066cc9 --- /dev/null +++ b/desktop/src/features/messages/lib/composerSendAudience.ts @@ -0,0 +1,128 @@ +/** + * 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); + const agentAudience = uniqueNormalized(decision.audiencePubkeys); + const humanMentions = uniqueNormalized(input.explicitMentionPubkeys).filter( + (pk) => !explicitAgentSet.has(pk), + ); + const mentionPubkeys = uniqueNormalized([...humanMentions, ...agentAudience]); + + return { + mentionPubkeys, + agentAudiencePubkeys: agentAudience, + sharedThread: decision.sharedThread, + 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/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5..b40a4f5cef 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -24,8 +24,14 @@ import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditi import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { useUnaddressedChannelAgentMode } from "@/features/channels/lib/unaddressedChannelAgentMode"; +import { + describeComposerAudienceHint, + resolveComposerSendAudience, +} from "@/features/messages/lib/composerSendAudience"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { hasMentionClipboardHtml, normalizeMentionClipboardHtml, @@ -292,6 +298,108 @@ function MessageComposerImpl({ persistentMentionHydration, ); persistentMentionHydrationRef.current = persistentMentionHydration; + 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 = resolveComposerSendAudience({ + conversation: conversationKind, + messagePosition: audienceThreadRootId ? "in-thread" : "top-level", + unaddressedMode, + keepAddressedAgentsActive: persistentAudience.enabled, + explicitMentionPubkeys, + explicitAgentPubkeys, + currentAgentPubkey, + channelMemberPubkeys: channelMemberPubkeyList, + verifiedChannelAgentPubkeys, + persistentThreadAudience: [...persistentAudience.pubkeys], + threadRootEventId: audienceThreadRootId, + recipientLoadError: !mentions.hasResolvedMembers, + }); + return describeComposerAudienceHint({ + conversation: conversationKind, + unaddressedMode, + explicitAgentCount: explicitAgentPubkeys.length, + implicitAgentCount: + explicitAgentPubkeys.length > 0 + ? 0 + : decision.agentAudiencePubkeys.length, + retainDraft: decision.retainDraft, + }); + }, [ + audienceThreadRootId, + channelMemberPubkeyList, + conversationKind, + currentAgentPubkey, + editTarget, + mentions, + persistentAudience.enabled, + persistentAudience.pubkeys, + richText, + unaddressedMode, + verifiedChannelAgentPubkeys, + ]); const mentionSendFlow = useMentionSendFlow({ channelId, @@ -321,6 +429,7 @@ function MessageComposerImpl({ } : undefined, resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, + resolveComposerAudience, }); // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger @@ -956,6 +1065,15 @@ function MessageComposerImpl({ ) : null} + {composerAudienceHint ? ( +

+ {composerAudienceHint} +

+ ) : null} + {(media.pendingImeta.length > 0 || media.isUploading) && (
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) => ( + + ))} +
+
+
+
) : null} - {composerAudienceHint ? ( -

- {composerAudienceHint} -

- ) : null} + {(media.pendingImeta.length > 0 || media.isUploading) && (
diff --git a/desktop/src/features/messages/ui/useComposerAgentAudience.ts b/desktop/src/features/messages/ui/useComposerAgentAudience.ts new file mode 100644 index 0000000000..cff29061d0 --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerAgentAudience.ts @@ -0,0 +1,177 @@ +import * as React from "react"; + +import { useUnaddressedChannelAgentMode } from "@/features/channels/lib/unaddressedChannelAgentMode"; +import { + describeComposerAudienceHint, + resolveComposerSendAudience, + type ComposerSendAudienceResult, +} from "@/features/messages/lib/composerSendAudience"; +import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { ChannelType } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +import type { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; + +type PersistentHydration = ReturnType< + typeof usePersistentAgentMentionHydration +>; + +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/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 00c798791c..42f5aa141c 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -25,8 +25,7 @@ void main() { addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), removeLocalMessage: (_, eventId) => removedIds.add(eventId), - readUnaddressedMode: () => - UnaddressedChannelAgentMode.mentionsOnly, + readUnaddressedMode: () => UnaddressedChannelAgentMode.mentionsOnly, fetchAgentDirectory: () async => const [], readChannel: (_) => null, ); From 35a2d2d0b030ee345752fa6af8a8a7cccd2e4cd2 Mon Sep 17 00:00:00 2001 From: Brian Charbonneau Date: Mon, 3 Aug 2026 18:29:05 -0700 Subject: [PATCH 9/9] fix(desktop): keep explicit agent mentions on DM audience merge Authored @agent mentions must survive the direct-conversation audience resolver so existing DMs can still expand to include a newly mentioned relay agent. Extracted composer audience helpers stay under the file-size ratchet. Signed-off-by: Brian Charbonneau (cherry picked from commit ff08ba01df504ae919a70f4cc2e9d5ddbb90a31a) Signed-off-by: Brian Charbonneau --- .../messages/lib/composerSendAudience.test.mjs | 17 +++++++++++++++++ .../messages/lib/composerSendAudience.ts | 9 +++++++-- .../composer_send_audience.dart | 7 ++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/lib/composerSendAudience.test.mjs b/desktop/src/features/messages/lib/composerSendAudience.test.mjs index bd3a8f1375..e7a2af080a 100644 --- a/desktop/src/features/messages/lib/composerSendAudience.test.mjs +++ b/desktop/src/features/messages/lib/composerSendAudience.test.mjs @@ -148,6 +148,23 @@ test("direct conversation addresses current agent only", () => { 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", diff --git a/desktop/src/features/messages/lib/composerSendAudience.ts b/desktop/src/features/messages/lib/composerSendAudience.ts index ac70066cc9..5e4954a24c 100644 --- a/desktop/src/features/messages/lib/composerSendAudience.ts +++ b/desktop/src/features/messages/lib/composerSendAudience.ts @@ -76,7 +76,12 @@ export function resolveComposerSendAudience( }; const decision = resolveContextualAgentConversation(policyInput); - const agentAudience = uniqueNormalized(decision.audiencePubkeys); + // 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), ); @@ -85,7 +90,7 @@ export function resolveComposerSendAudience( return { mentionPubkeys, agentAudiencePubkeys: agentAudience, - sharedThread: decision.sharedThread, + sharedThread: decision.sharedThread || agentAudience.length >= 2, retainDraft: decision.retainDraft, replyPlacement: decision.replyPlacement, }; diff --git a/mobile/lib/shared/contextual_agent/composer_send_audience.dart b/mobile/lib/shared/contextual_agent/composer_send_audience.dart index fc47862541..c5158c7467 100644 --- a/mobile/lib/shared/contextual_agent/composer_send_audience.dart +++ b/mobile/lib/shared/contextual_agent/composer_send_audience.dart @@ -56,7 +56,12 @@ ComposerSendAudienceResult resolveComposerSendAudience({ ), ); - final agentAudience = _uniqueNormalized(decision.audiencePubkeys); + // Always retain authored agent @mentions (DM expansion to a new agent) + // while still applying implicit/persistent audience from policy. + final agentAudience = _uniqueNormalized([ + ...decision.audiencePubkeys, + ...explicitAgentSet, + ]); final humanMentions = _uniqueNormalized( explicitMentionPubkeys, ).where((pk) => !explicitAgentSet.contains(pk)).toList();