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(), + ), + ], + _ => [], + }; +}