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 <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-22 19:57:58 -04:00
parent 50ed93bfe7
commit 4bffc6e281
3 changed files with 117 additions and 15 deletions

View File

@@ -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<Set<String>, String>((ref, channelId) {
final members =
ref.watch(channelMembersProvider(channelId)).value ??
const <ChannelMember>[];
final recordAgents =
ref.watch(huddleManagedAgentRecordPubkeysProvider(channelId)).value ??
const <String>{};
final scope = ref.watch(huddleManagedAgentRecordScopeProvider);
final loaded = ref
.watch(huddleManagedAgentRecordPubkeysProvider(channelId))
.value;
final recordAgents = loaded != null && loaded.scope == scope
? loaded.agents
: const <String>{};
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<String> agents,
});
final huddleManagedAgentRecordScopeProvider =
Provider<HuddleManagedAgentRecordScope>(
(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<Set<String>, String>((ref, channelId) async {
.family<HuddleManagedAgentRecords, String>((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;

View File

@@ -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 <String>{},

View File

@@ -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<String?> {
@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<String, String> members;
final List<NostrEvent> records;
List<NostrEvent> records;
Object? recordError;
Completer<void>? recordGate;
final List<NostrFilter> 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<List<NostrEvent>> fetchHistory(
NostrFilter filter, {