diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 930ccef2b35..c3501b72f23 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -113,7 +113,8 @@ List buildMentionCandidates({ avatarUrl: profile?.avatarUrl, isAgent: true, isMember: false, - ownerPubkey: ownerByAgentPubkey[pk] ?? profile?.ownerPubkey, + ownerPubkey: + agent.ownerPubkey ?? ownerByAgentPubkey[pk] ?? profile?.ownerPubkey, ), ); } diff --git a/mobile/lib/shared/mentions/agent_discovery.dart b/mobile/lib/shared/mentions/agent_discovery.dart new file mode 100644 index 00000000000..ac4bbec15b5 --- /dev/null +++ b/mobile/lib/shared/mentions/agent_discovery.dart @@ -0,0 +1,152 @@ +part of 'agent_identity_provider.dart'; + +// Coordinates seed discovery only. Exact signed profiles and current policy +// are resolved by readAgentAuthorization before any candidate is exposed. +Future> _ownedAgentKeys( + RelaySessionNotifier session, + String viewer, + bool Function() current, +) async { + final keys = {}; + NostrEvent? cursor; + for (var page = 0; page < 200; page++) { + if (!current()) throw StateError('Agent discovery scope changed'); + final events = await session.queryRelay([ + NostrFilter( + kinds: const [30177], + authors: [viewer], + limit: 500, + until: cursor?.createdAt, + extensions: {if (cursor != null) 'before_id': cursor.id}, + ), + ]); + if (!current()) throw StateError('Agent discovery scope changed'); + for (final event in events) { + final key = event.getTagValue('d'); + if (event.kind == 30177 && + event.pubkey == viewer && + verifySignedEvent(event) && + key != null && + RegExp(r'^[0-9a-f]{64}$').hasMatch(key) && + event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'd').length == + 1) { + keys.add(key); + } + } + if (keys.length > 1000) { + throw StateError('Agent discovery exceeds key budget'); + } + if (events.length < 500) return keys; + final next = events.last; + if (cursor != null && + (next.createdAt > cursor.createdAt || + (next.createdAt == cursor.createdAt && + next.id.compareTo(cursor.id) <= 0))) { + throw StateError('Agent discovery pagination did not advance'); + } + cursor = next; + } + throw StateError('Agent discovery exceeds page budget'); +} + +// Separate subscription owner: a refresh must not tear down/replay its trigger. +class _AgentDirectoryUpdates extends Notifier { + Object? failure; + @override + int build() { + final sessionState = ref.watch(relaySessionProvider); + ref.watch(myPubkeyProvider); + ref.watch(relayConfigProvider); + failure = null; + var disposed = false; + var attempts = 0; + Timer? retry; + void Function()? unsubscribe; + Timer? debounce; + ref.onDispose(() { + disposed = true; + unsubscribe?.call(); + debounce?.cancel(); + retry?.cancel(); + }); + void changed() { + if (disposed || debounce != null) return; + debounce = Timer(const Duration(milliseconds: 150), () { + debounce = null; + if (!disposed) state++; + }); + } + + if (sessionState.status == SessionStatus.connected) { + final session = ref.read(relaySessionProvider.notifier); + Future subscribe() async { + if (disposed) return; + attempts++; + var active = true; + bool current() => !disposed && active; + void lost(Object error) { + if (!current()) return; + active = false; + unsubscribe?.call(); + unsubscribe = null; + failure = error; + changed(); + // One budget for establishment and terminal closure per generation. + // Exhaustion remains visible until session/account rebuild. + if (attempts < 3) { + retry = Timer(Duration(milliseconds: 250 * attempts), () { + retry = null; + unawaited(subscribe()); + }); + } + } + + try { + final close = await session.subscribeWithStatus( + NostrFilter( + kinds: const [0, 5, 10100, 30177, 39002], + limit: 0, + since: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + (event) { + if (!current()) return; + if (event.kind == 5 && !_isAgentCoordinateDeletion(event)) return; + changed(); + }, + onClosed: (message) => lost(StateError(message)), + onStatusChanged: (status) { + if (!current()) return; + if (status == RelaySubscriptionStatus.ready) failure = null; + changed(); + }, + ); + if (!current()) { + close(); + } else { + unsubscribe = close; + } + } catch (error) { + lost(error); + } + } + + unawaited(subscribe()); + } + return 0; + } +} + +final _agentDirectoryUpdatesProvider = + NotifierProvider<_AgentDirectoryUpdates, int>(_AgentDirectoryUpdates.new); + +// Desktop build_agent_delete emits a kind:5 a-coordinate, not a 30177 event. +bool _isAgentCoordinateDeletion(NostrEvent event) => + verifySignedEvent(event) && + event.tags.any((tag) { + if (tag.length < 2 || tag[0] != 'a') return false; + final coordinate = tag[1].split(':'); + return coordinate.length == 3 && + coordinate[0] == '30177' && + coordinate[1] == event.pubkey && + RegExp(r'^[0-9a-f]{64}$').hasMatch(coordinate[2]); + }); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index f8b4dc6cf49..89c1c9d75c5 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:collection'; import 'dart:convert'; @@ -10,6 +11,7 @@ import '../../shared/relay/relay.dart'; part 'agent_policy.dart'; part 'agent_authorization.dart'; +part 'agent_discovery.dart'; /// A relay agent parsed from its kind:10100 agent-profile event. /// @@ -65,27 +67,34 @@ Map? _tryDecodeJsonMap(String content) { } } -/// Relay agent directory from kind:10100 agent-profile events. +/// Relay directory, including verified owned agents without runtime records. /// /// Watches the session and only fetches after the WebSocket connects. final agentDirectoryProvider = FutureProvider>(( ref, ) async { + ref.watch(_agentDirectoryUpdatesProvider); + final failure = ref.read(_agentDirectoryUpdatesProvider.notifier).failure; + if (failure != null) throw failure; final sessionState = ref.watch(relaySessionProvider); if (sessionState.status != SessionStatus.connected) return const []; final session = ref.read(relaySessionProvider.notifier); - final config = ref.read(relayConfigProvider); + final config = ref.watch(relayConfigProvider); var disposed = false; ref.onDispose(() => disposed = true); - final viewer = ref.read(myPubkeyProvider); + final viewer = ref.watch(myPubkeyProvider); + bool current() => + !disposed && identical(config, ref.read(relayConfigProvider)); + if (viewer == null) return []; + final owned = await _ownedAgentKeys(session, viewer, current); + if (!current()) return []; final events = await session.fetchHistory(NostrFilters.agentProfiles()); - if (disposed) return []; + if (!current()) return []; return readAgentAuthorization( session, - events.map((event) => event.pubkey).toSet(), + {...owned, ...events.map((event) => event.pubkey)}, viewer: viewer, - isCurrent: () => - !disposed && identical(config, ref.read(relayConfigProvider)), + isCurrent: current, ); }); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 23b19d95577..9f1191393d0 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -30,6 +30,12 @@ import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../shared/mentions/agent_policy_test.dart' + show PolicySession, signed; +import '../../shared/crypto/nip_oa_test.dart' show authTag, profile; + +part 'discovery_lifecycle_tests.dart'; + final _pngBytes = Uint8List.fromList([ 0x89, 0x50, @@ -174,6 +180,7 @@ Widget _buildComposeBar({ required ComposeBarOnSend onSend, List members = const [], Future>? membersFuture, + RelaySessionNotifier? discoverySession, List relayAgents = const [], List channels = const [], List cachedMembers = const [], @@ -210,7 +217,12 @@ Widget _buildComposeBar({ channelMembersProvider( 'channel-1', ).overrideWith((ref) => membersFuture ?? Future.value(members)), - agentDirectoryProvider.overrideWith((ref) async => relayAgents), + if (discoverySession == null) + agentDirectoryProvider.overrideWith((ref) async => relayAgents), + if (discoverySession != null) + relaySessionProvider.overrideWith(() => discoverySession), + if (discoverySession != null) + myPubkeyProvider.overrideWithValue(currentPubkey), agentOwnersProvider.overrideWith((ref) async => const {}), relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), @@ -668,6 +680,7 @@ void main() { _setMockMediaUploadPlatformHandler(null); }); + discoveryLifecycleTests(); group('ComposeBar', () { testWidgets('starts compact and grows to the full-width composer', ( tester, diff --git a/mobile/test/features/channels/discovery_lifecycle_tests.dart b/mobile/test/features/channels/discovery_lifecycle_tests.dart new file mode 100644 index 00000000000..f0514d0706a --- /dev/null +++ b/mobile/test/features/channels/discovery_lifecycle_tests.dart @@ -0,0 +1,89 @@ +part of 'compose_bar_test.dart'; + +void discoveryLifecycleTests() { + testWidgets( + 'terminal CLOSED recovers; signed roster removal drops open picker', + (tester) async { + final viewer = nostr.Keys.generate(); + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + NostrEvent roster(int time, bool includeViewer) => signed( + relay, + 39002, + '', + time: time, + tags: [ + ['d', 'channel-1'], + if (includeViewer) ['p', viewer.public], + ['p', agent.public, '', 'bot'], + ], + ); + final events = [ + roster(100, true), + profile(agent, [authTag(owner, agent.public)]), + signed(agent, 10100, { + 'name': 'Helper Bot', + 'channel_ids': ['channel-1'], + }), + signed( + owner, + 30177, + {'name': 'Helper Bot', 'parallelism': 1, 'respond_to': 'anyone'}, + tags: [ + ['d', agent.public], + ], + ), + ]; + // PolicySession replaces queries only: live REQ/EOSE/CLOSED/event handling + // and subscription removal/disposal are the production relay SDK. + final session = PolicySession(events); + await tester.pumpWidget( + _buildComposeBar( + discoverySession: session, + currentPubkey: viewer.public, + uploadService: _testUploadService(viewer.nsec), + channels: [_makeCurrentChannel()], + onSend: + ( + content, + mentions, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pump(); + session.debugHandleMessage(['EOSE', 'l-1']); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + final c = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + expect(c.read(agentDirectoryProvider).requireValue, hasLength(1)); + expect(find.text('Helper Bot'), findsOneWidget); + session.debugHandleMessage(['CLOSED', 'l-1', 'restricted: terminal']); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(); + expect(c.read(agentDirectoryProvider).hasError, isTrue); + expect(find.text('Helper Bot'), findsNothing); + await tester.pump(const Duration(milliseconds: 100)); + session.debugHandleMessage(['EOSE', 'l-2']); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + expect(find.text('Helper Bot'), findsOneWidget); + final removed = roster(101, false); + events.add(removed); + session.debugHandleMessage(['EVENT', 'l-2', removed.toJson()]); + session.debugFlushEventBuffer(); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + expect(c.read(agentDirectoryProvider).requireValue, isEmpty); + expect(find.text('Helper Bot'), findsNothing); + expect(find.byType(TextField), findsOneWidget); // same mounted editor + await tester.pumpWidget(const SizedBox.shrink()); + session.debugDispose(); + }, + ); +} diff --git a/mobile/test/shared/mentions/agent_discovery_test.dart b/mobile/test/shared/mentions/agent_discovery_test.dart new file mode 100644 index 00000000000..af83dd681c7 --- /dev/null +++ b/mobile/test/shared/mentions/agent_discovery_test.dart @@ -0,0 +1,306 @@ +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import '../crypto/nip_oa_test.dart' show authTag, profile; +import 'agent_policy_test.dart' show PolicySession, signed; + +class _Session extends PolicySession { + _Session(super.events, this.authority); + final String authority; + void Function(NostrEvent)? changed; + void Function(RelaySubscriptionStatus)? status; + void Function(String)? terminal; + int closed = 0; + int attempts = 0; + int failures = 0; + NostrFilter? subscription; + void deliver(NostrEvent event) { + if (subscription?.kinds.contains(event.kind) == true) changed?.call(event); + } + + List Function(NostrFilter)? ownedPage; + @override + Future fetchRelaySelf() async => authority; + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + required void Function(RelaySubscriptionStatus) onStatusChanged, + }) async { + attempts++; + if (attempts <= failures) throw StateError('establishment unavailable'); + terminal = onClosed; + subscription = filter; + changed = onEvent; + status = onStatusChanged; + onStatusChanged(RelaySubscriptionStatus.ready); + return () { + closed++; + }; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) { + if (filters.singleOrNull?.kinds.contains(30177) == true && + filters.single.tags.isEmpty && + ownedPage != null) { + return Future.value(ownedPage!(filters.single)); + } + return super.queryRelay(filters, timeout: timeout); + } +} + +void main() { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + final owned = profile(agent, [authTag(owner, agent.public)]); + final policy = signed( + owner, + 30177, + {'name': 'Remote helper', 'parallelism': 1, 'respond_to': 'owner-only'}, + tags: [ + ['d', agent.public], + ], + ); + ProviderContainer container(_Session session) => ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(owner.public), + ], + ); + + test( + 'no runtime or shared channel required; signed owner policy reaches picker', + () async { + final session = _Session([owned, policy], relay.public); + final c = container(session); + addTearDown(c.dispose); + final entries = await c.read(agentDirectoryProvider.future); + expect(session.subscription!.kinds, contains(39002)); + expect(entries.single.pubkey, agent.public); + expect(entries.single.channelIds, isEmpty); + final choices = buildMentionCandidates( + members: [], + relayAgents: entries, + sharedChannelIds: {}, + userCache: {}, + ownerByAgentPubkey: {}, + currentPubkey: owner.public, + ); + expect(choices.single.label, 'Remote helper'); + expect(choices.single.ownerPubkey, owner.public); + expect(choices.single.isAgent, isTrue); + expect(choices.single.isMember, isFalse); + expect(session.queries.first.authors, [owner.public]); + expect(session.queries.first.kinds, [30177]); + }, + ); + + test( + 'owner coordinate without latest valid owner proof does not expose agent', + () async { + final noProof = signed(agent, 0, {'name': 'revoked'}, time: 101); + final c = container(_Session([owned, noProof, policy], relay.public)); + addTearDown(c.dispose); + expect(await c.read(agentDirectoryProvider.future), isEmpty); + }, + ); + + test( + 'latest nobody policy retains identity but removes picker eligibility', + () async { + final deny = signed( + owner, + 30177, + {'name': 'Remote helper', 'parallelism': 1, 'respond_to': 'nobody'}, + time: 101, + tags: [ + ['d', agent.public], + ], + ); + final c = container(_Session([owned, policy, deny], relay.public)); + addTearDown(c.dispose); + final entries = await c.read(agentDirectoryProvider.future); + expect(entries.single.respondTo, 'nobody'); + expect( + buildMentionCandidates( + members: [], + relayAgents: entries, + sharedChannelIds: {}, + userCache: {}, + ownerByAgentPubkey: {}, + currentPubkey: owner.public, + ), + isEmpty, + ); + }, + ); + + testWidgets( + 'live ownership changes and recovered subscription rebuild current directory', + (tester) async { + final events = [owned, policy]; + final session = _Session(events, relay.public); + final c = container(session); + expect((await c.read(agentDirectoryProvider.future)).length, 1); + events.remove(policy); + session.changed!(policy); + await tester.pump(const Duration(milliseconds: 180)); + expect(await c.read(agentDirectoryProvider.future), isEmpty); + events.add(policy); + final retiredClose = session.terminal!; + retiredClose('restricted: terminal'); + await tester.pump(const Duration(milliseconds: 150)); + await expectLater( + c.read(agentDirectoryProvider.future), + throwsStateError, + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 150)); + expect(session.attempts, 2); + retiredClose('late duplicate'); // cannot retire the replacement + expect((await c.read(agentDirectoryProvider.future)).length, 1); + session.terminal!('restricted: again'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 150)); + expect(session.attempts, 3); + expect((await c.read(agentDirectoryProvider.future)).length, 1); + session.terminal!('restricted: exhausted'); + await tester.pump(const Duration(seconds: 2)); + expect(session.attempts, 3); + await expectLater( + c.read(agentDirectoryProvider.future), + throwsStateError, + ); + c.dispose(); + expect(session.closed, 3); + session.changed!(policy); // late events cannot invalidate a retired scope + }, + ); + + testWidgets( + 'failed establishment recovers while connected; coordinate delete refreshes', + (tester) async { + final events = [owned, policy]; + final session = _Session(events, relay.public)..failures = 1; + final c = container(session); + addTearDown(c.dispose); + await c.read(agentDirectoryProvider.future); + await tester.pump(const Duration(milliseconds: 180)); + await expectLater( + c.read(agentDirectoryProvider.future), + throwsStateError, + ); + await tester.pump(const Duration(milliseconds: 300)); + expect(session.attempts, 2); + expect((await c.read(agentDirectoryProvider.future)).length, 1); + final deny = signed( + owner, + 30177, + {'name': 'Remote helper', 'parallelism': 1, 'respond_to': 'nobody'}, + time: 101, + tags: [ + ['d', agent.public], + ], + ); + events.add(deny); + session.deliver(deny); + await tester.pump(const Duration(milliseconds: 180)); + expect( + (await c.read(agentDirectoryProvider.future)).single.respondTo, + 'nobody', + ); + final deletion = signed( + owner, + 5, + '', + tags: [ + ['a', '30177:${owner.public}:${agent.public}'], + ], + ); + events.remove(policy); + events.remove(deny); + session.deliver( + signed( + owner, + 5, + '', + tags: [ + ['a', '30000:${owner.public}:other'], + ], + ), + ); + await tester.pump(const Duration(milliseconds: 180)); + expect( + (await c.read(agentDirectoryProvider.future)).single.respondTo, + 'nobody', + ); + session.deliver(deletion); + await tester.pump(const Duration(milliseconds: 180)); + expect(await c.read(agentDirectoryProvider.future), isEmpty); + session.status!(RelaySubscriptionStatus.ready); + session.deliver(deletion); // replay remains removed after reconnect + await tester.pump(const Duration(milliseconds: 180)); + expect(await c.read(agentDirectoryProvider.future), isEmpty); + }, + ); + + testWidgets( + 'establishment retries are bounded and retired scopes cannot retry', + (tester) async { + final session = _Session([owned, policy], relay.public)..failures = 99; + final c = container(session); + await c.read(agentDirectoryProvider.future); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 150)); + expect(session.attempts, 3); + await expectLater( + c.read(agentDirectoryProvider.future), + throwsStateError, + ); + c.dispose(); + final retired = _Session([owned, policy], relay.public)..failures = 99; + final c2 = container(retired); + await c2.read(agentDirectoryProvider.future); + c2.dispose(); + await tester.pump(const Duration(milliseconds: 300)); + expect(retired.attempts, 1); + }, + ); + + test( + 'owned coordinates paginate equal-time pages and reject a stalled cursor', + () async { + final session = _Session([owned, policy], relay.public); + var calls = 0; + session.ownedPage = (filter) { + calls++; + if (calls == 1) return List.filled(500, policy); + expect(filter.until, policy.createdAt); + expect(filter.extensions['before_id'], policy.id); + return []; + }; + final c = container(session); + addTearDown(c.dispose); + expect((await c.read(agentDirectoryProvider.future)).length, 1); + expect(calls, 2); + final stalled = _Session([owned, policy], relay.public) + ..ownedPage = (_) => List.filled(500, policy); + final c2 = container(stalled); + addTearDown(c2.dispose); + await expectLater( + c2.read(agentDirectoryProvider.future), + throwsStateError, + ); + }, + ); +} diff --git a/mobile/test/shared/mentions/agent_policy_test.dart b/mobile/test/shared/mentions/agent_policy_test.dart index f195ace7e59..04022489df6 100644 --- a/mobile/test/shared/mentions/agent_policy_test.dart +++ b/mobile/test/shared/mentions/agent_policy_test.dart @@ -47,7 +47,7 @@ class PolicySession extends RelaySessionNotifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { - expect(filters.length, lessThanOrEqualTo(10)); + expectSync(filters.length, lessThanOrEqualTo(10)); queries.addAll(filters); if (failPolicy && filters.any((f) => f.kinds.contains(30177))) { throw StateError('policy read failed'); @@ -132,7 +132,7 @@ void main() { [runtime, owned, allow], inspect: (session) { final query = session.queries.singleWhere( - (q) => q.kinds.contains(30177), + (q) => q.kinds.contains(30177) && q.tags.containsKey('#d'), ); expect(query.authors, [owner.public]); expect(query.tags, {