From 4bffc6e281da560652cd88e2af74bc0562a88803 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 22 Oct 2026 19:57:58 -0400 Subject: [PATCH] fix(mobile): keep record agents in a Huddle while disconnected The managed-agent record read returned an empty set while the relay was disconnected. That counted as a successful read, so a reconnect replaced the trusted records with nothing. An agent known only by its record then left the Huddle participant list and lost its "preparing a response" state until the next connected read. The read now fails while disconnected, so the last records loaded stay in use, still limited to current members. Each result carries the relay and account that read it, and a result from another account is not used. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/huddle_agents_provider.dart | 50 ++++++++++--- .../channels/channel_detail_page_test.dart | 9 ++- .../channels/huddle_agents_provider_test.dart | 73 ++++++++++++++++++- 3 files changed, 117 insertions(+), 15 deletions(-) diff --git a/mobile/lib/features/channels/huddle_agents_provider.dart b/mobile/lib/features/channels/huddle_agents_provider.dart index f86d41aa8d..3c0c2870ab 100644 --- a/mobile/lib/features/channels/huddle_agents_provider.dart +++ b/mobile/lib/features/channels/huddle_agents_provider.dart @@ -30,16 +30,21 @@ const managedAgentRecordKind = 30177; /// /// The result holds only current members. Until the records first load, /// only the `bot` role counts. While the records reload after a membership -/// change, or if that reload fails, the last loaded records still apply to -/// the members who remain; a failed read never removes a `bot` agent. +/// change, if that reload fails, or while the relay is disconnected, the last +/// records loaded for the same relay and account still apply to the members +/// who remain; a failed read never removes a `bot` agent. final huddleAgentPubkeysProvider = Provider.autoDispose .family, String>((ref, channelId) { final members = ref.watch(channelMembersProvider(channelId)).value ?? const []; - final recordAgents = - ref.watch(huddleManagedAgentRecordPubkeysProvider(channelId)).value ?? - const {}; + final scope = ref.watch(huddleManagedAgentRecordScopeProvider); + final loaded = ref + .watch(huddleManagedAgentRecordPubkeysProvider(channelId)) + .value; + final recordAgents = loaded != null && loaded.scope == scope + ? loaded.agents + : const {}; return Set.unmodifiable({ for (final member in members) if (member.isBot || @@ -48,25 +53,50 @@ final huddleAgentPubkeysProvider = Provider.autoDispose }); }); +/// The relay and account that a managed-agent record read belongs to. +typedef HuddleManagedAgentRecordScope = ({String relay, String? viewer}); + +/// Agents named by trusted managed-agent records, with the relay and account +/// that read them. +typedef HuddleManagedAgentRecords = ({ + HuddleManagedAgentRecordScope scope, + Set agents, +}); + +final huddleManagedAgentRecordScopeProvider = + Provider( + (ref) => ( + relay: ref.watch( + relayConfigProvider.select((config) => config.baseUrl), + ), + viewer: ref.watch(myPubkeyProvider), + ), + ); + +/// Fails, rather than returning no records, while the relay is disconnected: +/// "not read" must not replace the last records read. final huddleManagedAgentRecordPubkeysProvider = FutureProvider.autoDispose - .family, String>((ref, channelId) async { + .family((ref, channelId) async { final connected = ref.watch( relaySessionProvider.select( (state) => state.status == SessionStatus.connected, ), ); - final viewer = ref.watch(myPubkeyProvider); + final scope = ref.watch(huddleManagedAgentRecordScopeProvider); final members = await ref.watch(channelMembersProvider(channelId).future); - if (!connected) return const {}; + if (!connected) { + throw StateError('relay disconnected; managed-agent records not read'); + } try { - return await fetchTrustedManagedAgentPubkeys( + final agents = await fetchTrustedManagedAgentPubkeys( ref.read(relaySessionProvider.notifier), members: [ for (final member in members) MemberEntry(pubkey: member.pubkey, role: member.role), ], - viewerPubkey: viewer, + viewerPubkey: scope.viewer, ); + return (scope: scope, agents: agents); } catch (error) { debugPrint('[HuddleAgents] managed-agent query failed: $error'); rethrow; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index e760a1def0..761c277033 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -395,9 +395,12 @@ Widget _buildTestable({ if (huddleMembersNotifier != null) _mutableHuddleMembersProvider.overrideWith(() => huddleMembersNotifier), if (huddleRecordAgentPubkeys != null) - huddleManagedAgentRecordPubkeysProvider( - _huddleChannelId, - ).overrideWith((ref) async => huddleRecordAgentPubkeys), + huddleManagedAgentRecordPubkeysProvider(_huddleChannelId).overrideWith( + (ref) async => ( + scope: ref.watch(huddleManagedAgentRecordScopeProvider), + agents: huddleRecordAgentPubkeys, + ), + ), if (!watchChannelMembershipUpdates) channelBotPubkeysProvider(_channelId).overrideWith( (ref) async => loadChannelBotPubkeys?.call() ?? const {}, diff --git a/mobile/test/features/channels/huddle_agents_provider_test.dart b/mobile/test/features/channels/huddle_agents_provider_test.dart index 513a9bb38c..e8a0f0d7ce 100644 --- a/mobile/test/features/channels/huddle_agents_provider_test.dart +++ b/mobile/test/features/channels/huddle_agents_provider_test.dart @@ -95,6 +95,64 @@ void main() { expect(await _settledAgents(container), {_botAgent}); }); + test( + 'a relay disconnect keeps record agents until a connected read', + () async { + final relay = _HuddleRelaySession( + members: { + _owner.public: 'owner', + _botAgent: 'bot', + _ownerAgent: 'member', + _viewerAgent: 'member', + }, + records: [_record(_owner, _ownerAgent), _record(_owner, _viewerAgent)], + ); + final container = _container(relay); + expect(await _settledAgents(container), { + _botAgent, + _ownerAgent, + _viewerAgent, + }); + + // While disconnected, the last records still apply. + relay.setStatus(SessionStatus.reconnecting); + expect(await _settledAgents(container), { + _botAgent, + _ownerAgent, + _viewerAgent, + }); + expect(relay.recordFilters, hasLength(1)); + + // After reconnecting, a successful read is authoritative again. + relay.records = [_record(_owner, _ownerAgent)]; + relay.setStatus(SessionStatus.connected); + expect(await _settledAgents(container), {_botAgent, _ownerAgent}); + expect(relay.recordFilters, hasLength(2)); + }, + ); + + test('records read for another account do not apply', () async { + final relay = _HuddleRelaySession( + members: {_owner.public: 'owner', _viewerAgent: 'member'}, + // Only the viewer vouches for this agent. + records: [_record(_viewer, _viewerAgent)], + ); + final container = _container(relay); + final viewer = container.read(_viewerOverride.notifier); + // Load the Huddle once as the other account, so its member snapshot is + // cached, then as the viewer. + viewer.set(_stranger.public); + expect(await _settledAgents(container), isEmpty); + viewer.set(_viewer.public); + expect(await _settledAgents(container), {_viewerAgent}); + + // Switching account while disconnected must not carry the viewer's + // records over to the other account's cached members. + relay.setStatus(SessionStatus.reconnecting); + viewer.set(_stranger.public); + expect(await _settledAgents(container), isEmpty); + }); + test('last-human count leaves out agents known by their record', () async { final relay = _HuddleRelaySession( members: { @@ -174,12 +232,21 @@ void main() { }); } +final _viewerOverride = NotifierProvider<_Viewer, String?>(_Viewer.new); + +class _Viewer extends Notifier { + @override + String? build() => _viewer.public; + + void set(String? pubkey) => state = pubkey; +} + ProviderContainer _container(_HuddleRelaySession relay) { final container = ProviderContainer( retry: (_, _) => null, overrides: [ relaySessionProvider.overrideWith(() => relay), - myPubkeyProvider.overrideWithValue(_viewer.public), + myPubkeyProvider.overrideWith((ref) => ref.watch(_viewerOverride)), ], ); addTearDown(container.dispose); @@ -220,7 +287,7 @@ MemberEntry _member(String pubkey, String role) => class _HuddleRelaySession extends RelaySessionNotifier { Map members; - final List records; + List records; Object? recordError; Completer? recordGate; final List recordFilters = []; @@ -234,6 +301,8 @@ class _HuddleRelaySession extends RelaySessionNotifier { @override SessionState build() => const SessionState(status: SessionStatus.connected); + void setStatus(SessionStatus status) => state = SessionState(status: status); + @override Future> fetchHistory( NostrFilter filter, {