mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user