From b0549856435f2198d344d415b5ecfd02a06fb313 Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 15:13:16 -0700 Subject: [PATCH 1/3] fix(mobile): add mentioned agents to channels Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- .../channels/channel_management_provider.dart | 27 +++ mobile/lib/features/channels/compose_bar.dart | 35 +++- .../channels/compose_bar/helpers.dart | 8 + mobile/lib/shared/relay/relay_session.dart | 11 ++ mobile/lib/shared/relay/relay_socket.dart | 4 + .../features/channels/compose_bar_test.dart | 156 +++++++++++++++++- 6 files changed, 227 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index bb2e14636d..0ac7315794 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -281,6 +281,33 @@ class ChannelActions { return _refreshChannelsAndRead(channelId); } + Future addMembers({ + required String channelId, + required List pubkeys, + String role = 'member', + }) async { + final normalizedRole = role.trim(); + if (normalizedRole.isEmpty) { + throw ArgumentError.value(role, 'role', 'must not be empty'); + } + final normalizedPubkeys = { + for (final pubkey in pubkeys) + if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(), + }; + for (final pubkey in normalizedPubkeys) { + await _signedEventRelay.submit( + kind: 9000, + content: '', + tags: [ + ['h', channelId], + ['p', pubkey], + ['role', normalizedRole], + ], + ); + } + _ref.invalidate(channelMembersProvider(channelId)); + } + Future joinChannel(String channelId) async { await _signedEventRelay.submit( kind: 9021, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 2eaae9a41e..271b3d18ef 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -75,9 +77,10 @@ class ComposeBar extends HookConsumerWidget { // Mention state -------------------------------------------------------- final mentionQuery = useState(null); final mentionStartIdx = useState(-1); - // Map of displayName → pubkey built as the user selects mentions. - // Used to pass resolved pubkeys directly to onSend, avoiding regex. - final mentionMap = useRef({}); + // Map of displayName → selected mention candidate built as the user selects + // mentions. Used to pass resolved pubkeys directly to onSend and to attach + // selected non-member agents before the message is published. + final mentionMap = useRef({}); // Channel autocomplete state ---------------------------------------------- final channelQuery = useState(null); @@ -213,8 +216,9 @@ class ComposeBar extends HookConsumerWidget { // Insert a selected mention into the text field. void insertMention(MentionCandidate candidate) { final name = candidate.label; - // Track the resolved pubkey so we can pass it at send time. - mentionMap.value[name] = candidate.pubkey; + // Track the resolved candidate so we can pass its pubkey and prepare + // selected non-member agents at send time. + mentionMap.value[name] = candidate; final start = mentionStartIdx.value.clamp(0, controller.text.length); spliceAndMoveCursor( @@ -269,10 +273,18 @@ class ComposeBar extends HookConsumerWidget { } // Extract pubkeys for mentions present in the final text. - final pubkeys = [ + final selectedMentions = [ for (final entry in mentionMap.value.entries) - if (text.contains('@${entry.key}')) entry.value, + if (hasMention(text, entry.key)) entry.value, ]; + final pubkeys = LinkedHashSet.from( + selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), + ).toList(); + final nonMemberAgentPubkeys = LinkedHashSet.from( + selectedMentions + .where((candidate) => candidate.isAgent && !candidate.isMember) + .map((candidate) => candidate.pubkey.toLowerCase()), + ).toList(); final payload = _ComposeDraftPayload.fromDraft( text: text, @@ -282,6 +294,15 @@ class ComposeBar extends HookConsumerWidget { isSending.value = true; try { + if (nonMemberAgentPubkeys.isNotEmpty) { + await ref + .read(channelActionsProvider) + .addMembers( + channelId: channelId, + pubkeys: nonMemberAgentPubkeys, + role: 'bot', + ); + } await onSend(payload.content, pubkeys, mediaTags: payload.mediaTags); if (context.mounted) { clearComposer(); diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index a017ed43bd..0450ce7042 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -90,6 +90,14 @@ void _insertTriggerAtCursor( /// via HTTP. Ephemeral events like typing indicators are broadcast-only and /// the relay doesn't persist them, so the HTTP `/api/events` endpoint may /// silently discard them. +bool hasMention(String text, String name) { + final pattern = RegExp( + '(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)', + caseSensitive: false, + ); + return pattern.hasMatch(text); +} + void _sendTypingIndicator( WidgetRef ref, { required String channelId, diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index b2e3e519bd..9ebf7fe217 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -266,6 +266,17 @@ class RelaySessionNotifier extends Notifier { @visibleForTesting void debugFlushEventBuffer() => _flushEventBuffer(); + @visibleForTesting + void debugHandleSocketMessageForTest(List data) => + _handleMessage(data); + + @visibleForTesting + void debugAttachSocketForTest(RelaySocket socket) { + _socket?.dispose(); + _socket = socket; + state = const SessionState(status: SessionStatus.connected); + } + /// Force a reconnect (e.g., returning from background). Future reconnect() async { await _socket?.disconnect(); diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 9054ddff2e..0142ba1ac6 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -205,6 +206,9 @@ class RelaySocket { } } + @visibleForTesting + void debugHandleOkForTest(List data) => _onMessage(data); + /// Handle OK frames. During auth, complete the auth flow. void _handleOk(List data) { if (data.length < 3) return; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 00e1236cca..b7306dea08 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -13,6 +13,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -110,21 +111,23 @@ void _setMockMediaUploadPlatformHandler( Widget _buildComposeBar({ required MediaUploadService uploadService, required ComposeBarOnSend onSend, + List members = const [], + List relayAgents = const [], + List channels = const [], + String? currentPubkey, }) { return ProviderScope( overrides: [ mediaUploadServiceProvider.overrideWithValue(uploadService), - currentPubkeyProvider.overrideWith((ref) => null), - channelMembersProvider( - 'channel-1', - ).overrideWith((ref) async => const []), - agentDirectoryProvider.overrideWith( - (ref) async => const [], - ), + currentPubkeyProvider.overrideWith((ref) => currentPubkey), + channelMembersProvider('channel-1').overrideWith((ref) async => members), + agentDirectoryProvider.overrideWith((ref) async => relayAgents), agentOwnersProvider.overrideWith((ref) async => const {}), relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), + relayConfigProvider.overrideWith(() => _FakeRelayConfigNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)), ], child: MaterialApp( theme: AppTheme.light(), @@ -137,6 +140,60 @@ Widget _buildComposeBar({ ); } +class _FakeRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => RelayConfig( + baseUrl: 'http://localhost:3000', + nsec: nostr.Keys.generate().nsec, + ); +} + +class _RecordingRelaySocket extends RelaySocket { + final List> events; + final void Function(List message) handleMessage; + + _RecordingRelaySocket(this.events, this.handleMessage) + : super( + wsUrl: 'ws://localhost', + nsec: null, + onMessage: handleMessage, + onConnected: () {}, + onDisconnected: (_) {}, + ); + + @override + SocketState get state => SocketState.connected; + + @override + void send(List payload) { + if (payload case ['EVENT', final Map event]) { + events.add(event); + final id = event['id'] as String; + super.debugHandleOkForTest(['OK', id, true, '']); + } + } + + @override + Future disconnect() async {} + + @override + void dispose() {} +} + +class _FakeChannelsNotifier extends ChannelsNotifier { + final List _channels; + + _FakeChannelsNotifier(this._channels); + + @override + Future> build() async => _channels; + + @override + Future refresh() async { + state = AsyncData(_channels); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -361,6 +418,77 @@ void main() { ); }); + testWidgets('adds a selected non-member agent as a bot before sending', ( + tester, + ) async { + final agentPubkey = 'c' * 64; + final signer = nostr.Keys.generate(); + final publishedEvents = >[]; + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: signer.nsec, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + String? sentContent; + List sentMentionPubkeys = const []; + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + currentPubkey: signer.public, + relayAgents: [ + AgentDirectoryEntry( + pubkey: agentPubkey, + displayName: 'Helper Bot', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + ], + channels: [_makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMentionPubkeys = mentionPubkeys; + }, + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + final socket = _RecordingRelaySocket( + publishedEvents, + session.debugHandleSocketMessageForTest, + ); + session.debugAttachSocketForTest(socket); + + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(sentContent, 'hello @Helper Bot'); + expect(sentMentionPubkeys, [agentPubkey]); + final addMemberEvent = publishedEvents.singleWhere( + (event) => event['kind'] == 9000, + ); + expect(addMemberEvent['tags'], [ + ['h', 'channel-1'], + ['p', agentPubkey], + ['role', 'bot'], + ]); + }); + testWidgets('shows a clean error when an animated PNG is picked', ( tester, ) async { @@ -676,6 +804,20 @@ void main() { }); } +Channel _makeSharedMemberChannel() { + return Channel( + id: 'shared-channel', + name: 'shared', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'pubkey123', + createdAt: DateTime(2024), + memberCount: 5, + isMember: true, + ); +} + Channel _makeChannel({required String name, required String channelType}) { return Channel( id: 'id-$name', From 150f950a2127190d9224f1e37a73f469284c9efc Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 16:54:38 -0700 Subject: [PATCH 2/3] address compose review feedback Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- .../lib/features/channels/compose_bar/helpers.dart | 12 ++++++------ mobile/test/features/channels/compose_bar_test.dart | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 0450ce7042..1d43a90c14 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -84,12 +84,6 @@ void _insertTriggerAtCursor( focusNode.requestFocus(); } -/// Send a typing indicator over the WebSocket (fire-and-forget). -/// -/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not -/// via HTTP. Ephemeral events like typing indicators are broadcast-only and -/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may -/// silently discard them. bool hasMention(String text, String name) { final pattern = RegExp( '(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)', @@ -98,6 +92,12 @@ bool hasMention(String text, String name) { return pattern.hasMatch(text); } +/// Send a typing indicator over the WebSocket (fire-and-forget). +/// +/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not +/// via HTTP. Ephemeral events like typing indicators are broadcast-only and +/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may +/// silently discard them. void _sendTypingIndicator( WidgetRef ref, { required String channelId, diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index b7306dea08..298e5b58c4 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -474,7 +474,6 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); - await tester.pump(); await tester.pumpAndSettle(); expect(sentContent, 'hello @Helper Bot'); From f7f8d5d41cc25dd0132d150b3ecd7deb144783f4 Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 15:17:58 -0700 Subject: [PATCH 3/3] chore(desktop): ratchet readiness file-size guard Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- desktop/scripts/check-file-sizes.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2f2539e3f1..3a77f7404e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -131,7 +131,8 @@ const overrides = new Map([ // databricks-v1-to-v2-migration: databricks-v2 hyphen-alias added to all // host/credential match arms + 30+ readiness tests for provider aliases, // missing-host, and DATABRICKS_MODEL fallback. Load-bearing correctness fix. - ["src-tauri/src/managed_agents/readiness.rs", 1546], + // +3 lines landed on main after the last ratchet; preserve current headroom. + ["src-tauri/src/managed_agents/readiness.rs", 1549], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing