fix(mobile): show newly created remote agents in the new-chat picker (#9030)

After creating a remote agent on mobile (for example in onboarding), the
agent doesn't appear in the Solo **New chat** agent picker. Mentioning
it in a channel, or restarting the app, works around this.

The picker's agent list (`meAssistantsProvider`) is loaded once and kept
for the app session. Creating a remote agent publishes its owner
registration (kind 30177), and Beekeeper publishes the agent's attested
profile, but nothing told the cached list to reload. So the new agent
stayed missing until something unrelated (a relay reconnect or an app
restart) rebuilt the list.

With this change, the list reloads whenever this device publishes or
removes a remote agent registration, using the controller's existing
`registrationRevision`. Agents created on another device still show up
only after the next reload, as before.

### Validation

- Added test: `Solo agent choices reload after this device creates an
agent` (hosted build) creates an agent through the real
`RemoteAgentController.create` path and checks that the picker's list
includes it. It fails without the fix (list stays empty) and passes with
it.
- Full mobile suite passes, and so do the hosted-build `shared`,
`features/agents` and `features/channels/me` suites. Analyzer and format
are clean.
- Automated simulator pass on the fixed build (signed in to a
Builderlab-hosted community): opened **New chat**, the picker listed all
7 agents, picked one, opened an existing Solo chat and the Agents page,
then reopened the picker and it still loaded correctly. Human testing is
waived per the repository's mobile testing exception.
- Limitation: I didn't create a real remote agent on the simulator,
because its Builderlab web sign-in stalled. The create → picker path is
covered by the added test.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-03 17:04:07 -08:00
committed by GitHub
parent b14899ffb1
commit 1bef1da77d
3 changed files with 99 additions and 0 deletions

View File

@@ -118,6 +118,20 @@ final remoteAgentControllerProvider = Provider<RemoteAgentController>((ref) {
return controller;
});
/// Changes when this device publishes or removes a remote agent registration,
/// so caches of the owner's agent inventory can reload.
final remoteAgentRegistrationRevisionProvider = Provider<int>((ref) {
final controller = ref.watch(remoteAgentControllerProvider);
final revision = controller.registrationRevision;
void changed() {
if (controller.registrationRevision != revision) ref.invalidateSelf();
}
controller.addListener(changed);
ref.onDispose(() => controller.removeListener(changed));
return revision;
});
/// The active community's remote agents that the signed-in owner's registry
/// reports ACTIVE. Remote workers wake on messages, so this means available,
/// not socket presence.

View File

@@ -6,6 +6,7 @@ import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../crypto/nip_oa.dart';
import '../agents/remote_agents.dart';
import '../mentions/agent_identity_provider.dart';
import '../relay/relay.dart';
import 'me_agent_inventory.dart';
@@ -21,6 +22,8 @@ final meAssistantsProvider = FutureProvider<List<AgentDirectoryEntry>>((
if (sync.connectionState != null) {
ref.watch(relaySessionProvider.select((state) => state.status));
}
// An agent this device just created or deleted changes the inventory.
ref.watch(remoteAgentRegistrationRevisionProvider);
// Refresh archive evidence with the inventory, including explicit retries.
// Ordinary sheet openings reuse this provider's completed directory.
final archivedRequest = ref.refresh(meArchivedAgentsProvider.future);

View File

@@ -11,6 +11,9 @@ import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
import 'package:buzz/shared/me/me_archived_agents.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/storage/secure_storage.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -1002,6 +1005,50 @@ void main() {
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
}, skip: !HostedEnvironment.enabled);
test(
'Solo agent choices reload after this device creates an agent',
() async {
final registry = FakeRegistry(bound: fakeOwner.public);
final controller = await _controller(registry);
final relay = _InventoryRelay();
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => relay),
meSyncProvider.overrideWithValue(
MeSync(
relay,
RelayConfig(baseUrl: 'https://relay.test', nsec: fakeOwner.nsec),
),
),
meArchivedAgentsProvider.overrideWith((ref) async => {}),
remoteAgentControllerProvider.overrideWithValue(controller),
],
);
addTearDown(container.dispose);
final choices = container.listen(meAssistantsProvider, (_, _) {});
addTearDown(choices.close);
expect(await container.read(meAssistantsProvider.future), isEmpty);
relay.listed = true;
expect(
await controller.create(
_community(),
'Scout',
modelId: 'test-model',
reasoningEffort: 'high',
),
isTrue,
);
expect(
(await container.read(
meAssistantsProvider.future,
)).map((a) => a.pubkey),
[fakeAgent],
);
},
skip: !HostedEnvironment.enabled,
);
test(
'runtime absence and deletion survive sign-out and a fresh owner-scoped load',
() async {
@@ -1156,3 +1203,38 @@ class _AgentRelay extends RelaySessionNotifier {
_ => [],
};
}
/// Serves Solo's inventory reads: the owner's signed registration once
/// [listed], and the agent's attested profile.
class _InventoryRelay extends RelaySessionNotifier {
bool listed = false;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async => switch (filters.single.kinds.single) {
0 => [signedAgentProfile(fakeAgentKeys, fakeOwner)],
30177 when listed => [
NostrEvent.fromJson(
nostr.Event.from(
kind: 30177,
content: jsonEncode({
'name': 'Scout',
'parallelism': 1,
'respond_to': 'owner-only',
}),
secretKey: fakeOwner.secret,
createdAt: 1,
tags: [
['d', fakeAgent],
],
).toMap(),
),
],
_ => [],
};
}