diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 354ee4471e6..0ad8f940d51 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -40,6 +40,7 @@ import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; import 'channel_messages_provider.dart'; +import 'mentions/mention_ack_store.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; import 'channels_provider.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 8c953b7211b..d75e24d4bce 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -267,6 +267,13 @@ class _MessageBubble extends HookConsumerWidget { onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey), ), + _MentionAckIndicator( + messageId: message.id, + mentionPubkeys: message.mentionPubkeys, + isOwnMessage: + currentPubkey != null && + currentPubkey!.toLowerCase() == pk, + ), ], ), ), @@ -300,6 +307,96 @@ class _MessageBubble extends HookConsumerWidget { } } +/// Accepted / declined outcome for an agent mention, rendered on the sender's +/// own message only. +/// +/// NIP-MR: agents publish kind:44102 acks. Nothing renders until an ack has +/// actually arrived — there is no timer and no "silent" verdict — so a message +/// with no ack looks exactly as it did before this widget existed. +class _MentionAckIndicator extends ConsumerWidget { + final String messageId; + final List mentionPubkeys; + final bool isOwnMessage; + + const _MentionAckIndicator({ + required this.messageId, + required this.mentionPubkeys, + required this.isOwnMessage, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Only the sender is told whether their own mention was picked up. Acks for + // other people's mentions produce no visible change. + if (!isOwnMessage || mentionPubkeys.isEmpty) { + return const SizedBox.shrink(); + } + + final acks = ref.watch(mentionAckStoreProvider); + // outcomesFor() applies the authorization rule: an ack counts only when its + // SIGNER was actually tagged in this mention. + if (acks.isAccepted(messageId, mentionPubkeys)) { + return _AckLine( + key: ValueKey('mention-ack-accepted-$messageId'), + icon: LucideIcons.check, + color: context.colors.onSurfaceVariant, + text: 'Accepted', + ); + } + + final declines = acks.declines(messageId, mentionPubkeys); + if (declines.isEmpty) return const SizedBox.shrink(); + + // `reason` is untrusted text from the relay. It is length-clamped at the + // parse boundary and rendered here as PLAIN text only — never markdown and + // never a link. + final reason = declines + .map((outcome) => outcome.reason) + .firstWhere((reason) => reason != null, orElse: () => null); + + return _AckLine( + key: ValueKey('mention-ack-declined-$messageId'), + icon: LucideIcons.circleSlash, + color: context.colors.error, + text: reason == null ? 'Declined' : 'Declined — $reason', + ); + } +} + +class _AckLine extends StatelessWidget { + final IconData icon; + final Color color; + final String text; + + const _AckLine({ + super.key, + required this.icon, + required this.color, + required this.text, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Grid.quarter), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: Grid.twelve, color: color), + const SizedBox(width: Grid.half), + Flexible( + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} + Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: Grid.xxl), diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index deb6869cb2e..6cf99804a7a 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -13,6 +13,7 @@ import 'channel_management_provider.dart' show ChannelMember, channelDetailsProvider; import 'channel_mutes/channel_mutes_provider.dart'; import 'huddle_channel_filter.dart'; +import 'mentions/mention_ack_store.dart'; import '../../shared/read_state/read_state_provider.dart'; import 'thread_follows/thread_follows_provider.dart'; import 'unread_badge/is_high_priority_event.dart'; @@ -763,6 +764,17 @@ class ChannelsNotifier extends AsyncNotifier> { } void _handleLiveEvent(NostrEvent event) { + // NIP-MR: acks must resolve wherever they land. This handler spans every + // live channel, so an ack is consumed before any channel-visibility or + // unread work — mirroring desktop's ordering in useLiveChannelUpdates.ts. + // Applied idempotently, so double delivery is harmless. Returning here also + // guarantees 44102 never reaches the unread/last-message path below: an ack + // must never bump a badge, reorder channels, or change the preview. + if (event.kind == EventKind.agentMentionAck) { + ref.read(mentionAckStoreProvider.notifier).applyAck(event); + return; + } + final channelId = event.channelId; if (channelId == null) return; diff --git a/mobile/lib/features/channels/mentions/mention_ack_store.dart b/mobile/lib/features/channels/mentions/mention_ack_store.dart new file mode 100644 index 00000000000..57adfc97064 --- /dev/null +++ b/mobile/lib/features/channels/mentions/mention_ack_store.dart @@ -0,0 +1,230 @@ +/// NIP-MR: agent acknowledgements (kind:44102) for a mention. +/// +/// An agent harness publishes a receipt the moment it decides what to do with a +/// mention — `accepted` when a turn is coming, `declined` with a reason when it +/// knowingly will not act. Desktop consumes these +/// (`desktop/src/features/agents/pendingMentionAckStore.ts`); mobile did not, so +/// a phone user could not tell a decline from a delay. +/// +/// Deliberate scope cut versus desktop: there is NO `silent` outcome and no +/// client-side timer. Mobile suspends and disconnects often, so a pending timer +/// would fire false "nobody picked this up" verdicts after resume. This store +/// holds only *received facts*: an ack either arrived or it did not. A missing +/// ack renders exactly as today, never as a false decline. +/// +/// Live-only in memory and community-scoped, matching desktop's +/// `resetPendingMentionAckStore()` discipline: the provider below rebuilds (and +/// therefore empties) when the active community changes. +library; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/relay/relay.dart'; + +/// Maximum characters retained from an untrusted `reason` tag. +/// +/// The reason is attacker-controlled text from an arbitrary relay member, so it +/// is clamped here — at the parse boundary — rather than trusting every future +/// render site to bound it. +const int mentionAckReasonMaxLength = 200; + +/// Upper bound on tracked mention event ids. +/// +/// Acks for *other* people's mentions are recorded too, because this store +/// cannot know which messages are the local identity's without coupling itself +/// to the timeline. That makes the map attacker-growable, so it is bounded and +/// evicts in insertion order. +const int mentionAckMaxTrackedEvents = 512; + +/// The status values this client understands. Anything else is ignored rather +/// than being coerced into a decline. +const String mentionAckStatusAccepted = 'accepted'; +const String mentionAckStatusDeclined = 'declined'; + +enum MentionAckStatus { accepted, declined } + +/// One agent's verdict on one mention. +@immutable +class MentionAckOutcome { + /// The pubkey that SIGNED the ack. Never a `p` tag value. + final String agentPubkey; + final MentionAckStatus status; + + /// Untrusted, already length-clamped. Render as plain text only — never as + /// markdown and never as a link. + final String? reason; + + const MentionAckOutcome({ + required this.agentPubkey, + required this.status, + this.reason, + }); + + bool get isAccepted => status == MentionAckStatus.accepted; + bool get isDeclined => status == MentionAckStatus.declined; + + @override + bool operator ==(Object other) => + other is MentionAckOutcome && + other.agentPubkey == agentPubkey && + other.status == status && + other.reason == reason; + + @override + int get hashCode => Object.hash(agentPubkey, status, reason); + + @override + String toString() => + 'MentionAckOutcome($agentPubkey, ${status.name}, reason: $reason)'; +} + +/// Immutable snapshot: mention event id -> signer pubkey -> outcome. +/// +/// Keyed by signer so a double delivery of the same ack overwrites rather than +/// appends: idempotency falls out of the data shape instead of relying on +/// callers to deduplicate. +@immutable +class MentionAckState { + final Map> byEventId; + + const MentionAckState({this.byEventId = const {}}); + + /// Outcomes for [eventId], restricted to signers the mention actually tagged. + /// + /// This is the authorization boundary. The relay is pure fan-out and cannot + /// check agent-ness, so ANY member can publish a well-formed ack for someone + /// else's message. Requiring the signer to appear in [taggedPubkeys] makes + /// such an ack inert. Callers pass the mention's own `p`/`mention` tags. + List outcomesFor( + String eventId, + Iterable taggedPubkeys, + ) { + final outcomes = byEventId[eventId]; + if (outcomes == null || outcomes.isEmpty) return const []; + + final allowed = { + for (final pubkey in taggedPubkeys) pubkey.trim().toLowerCase(), + }..remove(''); + if (allowed.isEmpty) return const []; + + return [ + for (final entry in outcomes.entries) + if (allowed.contains(entry.key)) entry.value, + ]; + } + + /// Whether any tagged agent accepted. An accept outranks a decline: if one + /// agent is taking the turn, the mention was answered. + bool isAccepted(String eventId, Iterable taggedPubkeys) => + outcomesFor(eventId, taggedPubkeys).any((o) => o.isAccepted); + + /// Declines, surfaced only when nothing accepted. + List declines( + String eventId, + Iterable taggedPubkeys, + ) { + final outcomes = outcomesFor(eventId, taggedPubkeys); + if (outcomes.any((o) => o.isAccepted)) return const []; + return [ + for (final outcome in outcomes) + if (outcome.isDeclined) outcome, + ]; + } +} + +/// Parse a kind:44102 event into an outcome, or null when it is not a +/// well-formed ack this client understands. +/// +/// Attribution is to `event.pubkey` — the SIGNER — never to the `p` tag, which +/// carries the mention's author and is therefore trivially forgeable as an +/// identity claim. +MentionAckOutcome? parseMentionAckOutcome(NostrEvent event) { + if (event.kind != EventKind.agentMentionAck) return null; + + final signer = event.pubkey.trim().toLowerCase(); + if (signer.isEmpty) return null; + + switch (event.getTagValue('status')) { + case mentionAckStatusAccepted: + return MentionAckOutcome( + agentPubkey: signer, + status: MentionAckStatus.accepted, + ); + case mentionAckStatusDeclined: + final raw = event.getTagValue('reason')?.trim(); + final reason = (raw == null || raw.isEmpty) + ? null + : (raw.length > mentionAckReasonMaxLength + ? raw.substring(0, mentionAckReasonMaxLength) + : raw); + return MentionAckOutcome( + agentPubkey: signer, + status: MentionAckStatus.declined, + reason: reason, + ); + default: + // Unknown or absent status: ignored, NOT rendered as a decline. + return null; + } +} + +/// The mention event id an ack refers to, from its `e` tag. +String? mentionAckTargetEventId(NostrEvent event) { + final target = event.getTagValue('e')?.trim(); + return (target == null || target.isEmpty) ? null : target; +} + +class MentionAckNotifier extends Notifier { + @override + MentionAckState build() { + // Community-scoped: the relay config rebuilds on community switch, which + // drops every ack recorded against the previous community's identities. + ref.watch(relayConfigProvider); + return const MentionAckState(); + } + + /// Apply an incoming ack. Returns true when state changed. + /// + /// Idempotent by construction: re-applying the same ack produces an equal + /// outcome for the same signer key and is dropped as a no-op. + bool applyAck(NostrEvent event) { + final eventId = mentionAckTargetEventId(event); + if (eventId == null) return false; + + final outcome = parseMentionAckOutcome(event); + if (outcome == null) return false; + + final existing = state.byEventId[eventId]; + if (existing != null && existing[outcome.agentPubkey] == outcome) { + return false; + } + + final next = >{ + ...state.byEventId, + eventId: {...?existing, outcome.agentPubkey: outcome}, + }; + + // Bounded: evict oldest insertions first. Map literals preserve insertion + // order in Dart, and an updated key keeps its original position, so a + // long-lived conversation cannot be made to grow without limit. + if (next.length > mentionAckMaxTrackedEvents) { + final surplus = next.length - mentionAckMaxTrackedEvents; + for (final stale in next.keys.take(surplus).toList()) { + next.remove(stale); + } + } + + state = MentionAckState(byEventId: next); + return true; + } + + /// Explicit reset. The provider already empties on community switch; this + /// exists for tests and for any future identity-change path. + void reset() => state = const MentionAckState(); +} + +final mentionAckStoreProvider = + NotifierProvider( + MentionAckNotifier.new, + ); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 98039ff2ab2..3cc3353f773 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -41,6 +41,13 @@ abstract final class EventKind { static const jobError = 43006; static const forumPost = 45001; static const forumComment = 45003; + + /// NIP-MR agent mention acknowledgement. An agent publishes this to report + /// that it accepted or declined a mention. Overlay only — it must never + /// render as a timeline row. Mirrors `KIND_AGENT_MENTION_ACK` in + /// `crates/buzz-core/src/kind.rs` and desktop's `KIND_AGENT_MENTION_ACK`. + static const agentMentionAck = 44102; + static const huddleStarted = 48100; static const huddleParticipantJoined = 48101; static const huddleParticipantLeft = 48102; @@ -69,6 +76,7 @@ abstract final class EventKind { huddleParticipantJoined, // 48101 — huddle lifecycle metadata huddleParticipantLeft, // 48102 — huddle lifecycle metadata huddleEnded, // 48103 — visible huddle ended row + agentMentionAck, // 44102 — NIP-MR ack overlay, never a timeline row ]; /// Auxiliary timeline kinds that overlay or hide existing rows. @@ -77,6 +85,7 @@ abstract final class EventKind { reaction, nip29DeleteEvent, streamMessageEdit, + agentMentionAck, ]; /// Visible content kinds requested by the NIP-CW channel-window path. diff --git a/mobile/test/features/channels/mentions/mention_ack_store_test.dart b/mobile/test/features/channels/mentions/mention_ack_store_test.dart new file mode 100644 index 00000000000..fe22a0ea3f6 --- /dev/null +++ b/mobile/test/features/channels/mentions/mention_ack_store_test.dart @@ -0,0 +1,310 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/mentions/mention_ack_store.dart'; +import 'package:buzz/shared/relay/relay.dart'; + +// NIP-MR mobile ack consumption. Mirrors the desktop semantics in +// desktop/src/features/agents/pendingMentionAckStore.ts, minus the `silent` +// timeout outcome, which is deliberately out of scope for this slice. + +const _mentionId = 'mention-event-id'; +const _agentPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const _otherPubkey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const _authorPubkey = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; +const _channelId = 'channel-1'; + +/// Builds an ack with the exact tag shape the ACP publisher emits. +/// See crates/buzz-acp/src/pool.rs build_mention_ack_event: h, e, p, status, +/// and an optional reason. +NostrEvent _ack({ + String signer = _agentPubkey, + String targetEventId = _mentionId, + String? status = mentionAckStatusAccepted, + String? reason, + String id = 'ack-1', + int kind = EventKind.agentMentionAck, +}) { + return NostrEvent( + id: id, + pubkey: signer, + createdAt: 1000, + kind: kind, + tags: [ + ['h', _channelId], + ['e', targetEventId], + // `p` is the mention's AUTHOR, not the agent. Attribution must never + // come from this tag. + ['p', _authorPubkey], + if (status != null) ['status', status], + if (reason != null) ['reason', reason], + ], + content: '', + sig: 'sig', + ); +} + +MentionAckNotifier _notifier(ProviderContainer container) => + container.read(mentionAckStoreProvider.notifier); + +ProviderContainer _container() { + final container = ProviderContainer(); + addTearDown(container.dispose); + return container; +} + +void main() { + group('parseMentionAckOutcome', () { + test('accepted ack is attributed to the signer, never the p tag', () { + final outcome = parseMentionAckOutcome(_ack())!; + + expect(outcome.status, MentionAckStatus.accepted); + expect(outcome.agentPubkey, _agentPubkey); + expect(outcome.agentPubkey, isNot(_authorPubkey)); + expect(outcome.reason, isNull); + }); + + test('declined ack carries its reason', () { + final outcome = parseMentionAckOutcome( + _ack(status: mentionAckStatusDeclined, reason: 'queue_full'), + )!; + + expect(outcome.status, MentionAckStatus.declined); + expect(outcome.reason, 'queue_full'); + }); + + test('unknown, absent and empty status values are ignored', () { + // The security invariant: an unrecognized status must never be coerced + // into a decline. + expect(parseMentionAckOutcome(_ack(status: 'maybe')), isNull); + expect(parseMentionAckOutcome(_ack(status: null)), isNull); + expect(parseMentionAckOutcome(_ack(status: '')), isNull); + expect(parseMentionAckOutcome(_ack(status: 'ACCEPTED')), isNull); + }); + + test('non-44102 kinds are not acks', () { + expect( + parseMentionAckOutcome(_ack(kind: EventKind.streamMessageV2)), + isNull, + ); + }); + + test('an untrusted reason is length-clamped', () { + final outcome = parseMentionAckOutcome( + _ack( + status: mentionAckStatusDeclined, + reason: 'x' * (mentionAckReasonMaxLength + 500), + ), + )!; + + expect(outcome.reason!.length, mentionAckReasonMaxLength); + }); + + test('a blank reason becomes null rather than an empty line', () { + final outcome = parseMentionAckOutcome( + _ack(status: mentionAckStatusDeclined, reason: ' '), + )!; + + expect(outcome.reason, isNull); + }); + }); + + group('applyAck', () { + test('accepted ack is visible to the tagged mention', () { + final container = _container(); + + expect(_notifier(container).applyAck(_ack()), isTrue); + + final state = container.read(mentionAckStoreProvider); + expect(state.isAccepted(_mentionId, [_agentPubkey]), isTrue); + expect(state.declines(_mentionId, [_agentPubkey]), isEmpty); + }); + + test('declined ack surfaces the reason', () { + final container = _container(); + + _notifier(container).applyAck( + _ack(status: mentionAckStatusDeclined, reason: 'agent is offline'), + ); + + final declines = container + .read(mentionAckStoreProvider) + .declines(_mentionId, [_agentPubkey]); + expect(declines.single.reason, 'agent is offline'); + expect( + container.read(mentionAckStoreProvider).isAccepted(_mentionId, [ + _agentPubkey, + ]), + isFalse, + ); + }); + + test('double delivery is idempotent', () { + final container = _container(); + + expect(_notifier(container).applyAck(_ack()), isTrue); + // Same ack redelivered — both live subscriptions can deliver it. + expect(_notifier(container).applyAck(_ack()), isFalse); + // Same verdict, different event id: still the same fact. + expect(_notifier(container).applyAck(_ack(id: 'ack-2')), isFalse); + + expect( + container.read(mentionAckStoreProvider).outcomesFor(_mentionId, [ + _agentPubkey, + ]), + hasLength(1), + ); + }); + + test('an ack whose signer was not tagged in the mention is ignored', () { + final container = _container(); + + // A well-formed ack from an arbitrary member. The relay is pure fan-out + // and cannot check agent-ness, so this must be inert. + _notifier(container).applyAck(_ack(signer: _otherPubkey)); + + final state = container.read(mentionAckStoreProvider); + expect(state.outcomesFor(_mentionId, [_agentPubkey]), isEmpty); + expect(state.isAccepted(_mentionId, [_agentPubkey]), isFalse); + // It is only visible to a mention that actually tagged that signer. + expect(state.isAccepted(_mentionId, [_otherPubkey]), isTrue); + }); + + test('an ack with no e tag is dropped', () { + final container = _container(); + + expect( + _notifier(container).applyAck(_ack(targetEventId: '')), + isFalse, + ); + }); + + test('an accept from any tagged agent outranks another agent decline', () { + final container = _container(); + + _notifier(container).applyAck( + _ack(status: mentionAckStatusDeclined, reason: 'busy'), + ); + _notifier(container).applyAck(_ack(signer: _otherPubkey)); + + final state = container.read(mentionAckStoreProvider); + final tagged = [_agentPubkey, _otherPubkey]; + expect(state.isAccepted(_mentionId, tagged), isTrue); + // Nothing to warn about once someone is taking the turn. + expect(state.declines(_mentionId, tagged), isEmpty); + }); + + test('a later verdict from the same agent replaces the earlier one', () { + final container = _container(); + + _notifier(container).applyAck(_ack()); + expect( + _notifier(container).applyAck( + _ack(status: mentionAckStatusDeclined, reason: 'changed my mind'), + ), + isTrue, + ); + + final state = container.read(mentionAckStoreProvider); + expect(state.isAccepted(_mentionId, [_agentPubkey]), isFalse); + expect(state.declines(_mentionId, [_agentPubkey]).single.reason, + 'changed my mind'); + }); + + test('acks for other mentions do not leak across event ids', () { + final container = _container(); + + _notifier(container).applyAck(_ack(targetEventId: 'someone-else')); + + final state = container.read(mentionAckStoreProvider); + expect(state.outcomesFor(_mentionId, [_agentPubkey]), isEmpty); + expect(state.isAccepted(_mentionId, [_agentPubkey]), isFalse); + }); + + test('a mention that tagged nobody can never show an outcome', () { + final container = _container(); + + _notifier(container).applyAck(_ack()); + + expect( + container + .read(mentionAckStoreProvider) + .outcomesFor(_mentionId, const []), + isEmpty, + ); + }); + + test('signer matching is case-insensitive on both sides', () { + final container = _container(); + + _notifier(container).applyAck(_ack(signer: _agentPubkey.toUpperCase())); + + expect( + container.read(mentionAckStoreProvider).isAccepted(_mentionId, [ + _agentPubkey.toUpperCase(), + ]), + isTrue, + ); + }); + + test('tracked mentions are bounded so acks cannot grow memory', () { + final container = _container(); + + for (var i = 0; i < mentionAckMaxTrackedEvents + 25; i++) { + _notifier(container).applyAck( + _ack(targetEventId: 'mention-$i', id: 'ack-$i'), + ); + } + + final state = container.read(mentionAckStoreProvider); + expect(state.byEventId.length, mentionAckMaxTrackedEvents); + // Oldest evicted, newest retained. + expect(state.byEventId.containsKey('mention-0'), isFalse); + expect( + state.byEventId.containsKey( + 'mention-${mentionAckMaxTrackedEvents + 24}', + ), + isTrue, + ); + }); + + test('reset clears state, mirroring the community-switch discipline', () { + final container = _container(); + + _notifier(container).applyAck(_ack()); + expect(container.read(mentionAckStoreProvider).byEventId, isNotEmpty); + + _notifier(container).reset(); + + final state = container.read(mentionAckStoreProvider); + expect(state.byEventId, isEmpty); + expect(state.isAccepted(_mentionId, [_agentPubkey]), isFalse); + }); + }); + + group('kind wiring', () { + test('44102 is subscribed and treated as an overlay, never a row', () { + // Subscribed, so acks reach the client at all. + expect( + EventKind.channelEventKinds, + contains(EventKind.agentMentionAck), + ); + // Aux, so it overlays instead of rendering as a timeline row. + expect( + EventKind.channelAuxEventKinds, + contains(EventKind.agentMentionAck), + ); + // Never a visible message row. + expect( + EventKind.channelMessageEventKinds, + isNot(contains(EventKind.agentMentionAck)), + ); + expect( + EventKind.channelTimelineContentKinds, + isNot(contains(EventKind.agentMentionAck)), + ); + }); + }); +}