From 1bef1da77d4ca31be54ee5b176a83986733c2099 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 17:04:07 -0800 Subject: [PATCH] fix(mobile): show newly created remote agents in the new-chat picker (#9030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- mobile/lib/shared/agents/remote_agents.dart | 14 ++++ mobile/lib/shared/me/me_assistants.dart | 3 + .../shared/agents/remote_agents_test.dart | 82 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 9347c94a4e..c9ba3040f0 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -118,6 +118,20 @@ final remoteAgentControllerProvider = Provider((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((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. diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index b091d61fbc..1f3913cc96 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -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>(( 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); diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index 4b89a03188..96ea0919cd 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -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> queryRelay( + List 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(), + ), + ], + _ => [], + }; +}