From b77ad2ed10a00e869e4e64d06d70300d23616b7f Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 27 Oct 2026 11:05:46 -0700 Subject: [PATCH] fix(mobile): align Solo agent policy and labels with the owner's inventory (#8616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `main` now discovers Solo agents from the owner's kind 30177 managed-agent inventory. This PR aligns two remaining parts of that path with desktop's agent library. - **Unsupported policies fail closed.** Desktop parses each agent's latest 30177 record as `ManagedAgentEventContent` and drops records it can't parse. Mobile only checked `name`. It now also requires an integer `parallelism` in `u32` range, a `respond_to` of `owner-only`, `allowlist` or `anyone`, an allowlist that is absent or a list of strings, and string-or-null optional text fields. A newer unsupported record hides the agent instead of falling back to an older one. Records written by the apps always carry these fields. - **The owner's name labels the agent in Solo's selection, header and creation surfaces.** The directory already used the 30177 `name`. Message authors and the typing indicator still use the agent's profile name. The existing-chat header, pending-change label, fallback avatar initials (header, native header action, new-chat row) and the resumed native creation's saved agent preferred the agent's self-published kind:0 name. They now use the owner's name first and fall back to the profile. Profile artwork is unchanged. ### Validation - Added tests: a table of 11 unsupported policy shapes that must hide the agent, accepted policy shapes, and journeys where the owner's name ("Owner Name", "Alpha") differs from the cached profile name ("Confusable …"). The journeys cover the new-chat row's title and initial and the existing-chat header tooltip, initial and pending label. They also assert the native new-chat payload and picker row, which already used the inventory name. - Mutation checks: removing the policy check fails 10 tests; preferring the profile for the header name, header initial or new-chat initial fails the matching journey. The saved-agent name on resumed native creation has no dedicated test. - The full mobile suite passes (3,806 tests), and `flutter analyze` is clean. - The human-testing requirement does not apply under the mobile client testing exception. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../channels/me/me_chat_assistant.dart | 17 +++-- .../channels/me/native_new_me_chat.dart | 2 +- .../lib/features/channels/me/new_me_chat.dart | 7 +- mobile/lib/shared/me/me_agent_inventory.dart | 58 ++++++++++---- .../channels/me/me_chat_assistant_test.dart | 10 ++- .../features/channels/me/me_page_test.dart | 46 ++++++++++- mobile/test/shared/me/me_assistants_test.dart | 76 ++++++++++++++++++- 7 files changed, 183 insertions(+), 33 deletions(-) diff --git a/mobile/lib/features/channels/me/me_chat_assistant.dart b/mobile/lib/features/channels/me/me_chat_assistant.dart index 99ac3906e7..2dc03200d7 100644 --- a/mobile/lib/features/channels/me/me_chat_assistant.dart +++ b/mobile/lib/features/channels/me/me_chat_assistant.dart @@ -89,16 +89,20 @@ useMeChatAssistant( ? ref.watch(userCacheProvider)[selected] ?? ref.read(userCacheProvider.notifier).get(selected) : null; + // The owner's inventory name outranks the agent's self-published profile. final name = - profile?.displayName ?? agents .where((agent) => agent.pubkey == selected) .firstOrNull - ?.displayName; + ?.displayName ?? + profile?.displayName; + final avatarInitial = name?.trim().isNotEmpty == true + ? name!.trim()[0].toUpperCase() + : profile?.initial ?? '?'; String agentName(String pubkey) { final names = [ - ref.read(userCacheProvider)[pubkey]?.displayName, agents.where((a) => a.pubkey == pubkey).firstOrNull?.displayName, + ref.read(userCacheProvider)[pubkey]?.displayName, ]; return names .whereType() @@ -226,8 +230,7 @@ useMeChatAssistant( avatarIdentity: selected, avatarIsAgent: selected != null, imageUrl: profile?.avatarUrl, - avatarInitial: - profile?.initial ?? (name?.isNotEmpty == true ? name![0] : '?'), + avatarInitial: avatarInitial, onPressed: busy.value ? null : choose, ), action: IconButton( @@ -241,9 +244,7 @@ useMeChatAssistant( backgroundColor: context.controlSurface, fallback: selected == null ? const Icon(BuzzIcons.plus, size: 20) - : Text( - profile?.initial ?? (name?.isNotEmpty == true ? name![0] : '?'), - ), + : Text(avatarInitial), ), ), ); diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index 9bf932e38c..daf72977cd 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -80,11 +80,11 @@ Future<(Channel, String)?> _showNewMeChat( 'savedAssistant': { 'id': assistant, 'name': - ref.read(userCacheProvider)[assistant]?.label ?? directory ?.where((a) => a.pubkey == assistant) .firstOrNull ?.displayName ?? + ref.read(userCacheProvider)[assistant]?.label ?? assistant, 'avatarUrl': ref.read(userCacheProvider)[assistant]?.avatarUrl ?? '', diff --git a/mobile/lib/features/channels/me/new_me_chat.dart b/mobile/lib/features/channels/me/new_me_chat.dart index efd8c36055..d57657f05a 100644 --- a/mobile/lib/features/channels/me/new_me_chat.dart +++ b/mobile/lib/features/channels/me/new_me_chat.dart @@ -97,10 +97,9 @@ class _NewMeChat extends HookConsumerWidget { isAgent: true, backgroundColor: context.controlSurface, fallback: Text( - profile?.initial ?? - (chosen.displayName?.isNotEmpty == true - ? chosen.displayName![0] - : 'A'), + chosen.displayName?.trim().isNotEmpty == true + ? chosen.displayName!.trim()[0].toUpperCase() + : profile?.initial ?? 'A', ), ), title: Text(chosen?.displayName ?? 'Choose an agent'), diff --git a/mobile/lib/shared/me/me_agent_inventory.dart b/mobile/lib/shared/me/me_agent_inventory.dart index f5e89c6cd9..2c8421771f 100644 --- a/mobile/lib/shared/me/me_agent_inventory.dart +++ b/mobile/lib/shared/me/me_agent_inventory.dart @@ -72,18 +72,48 @@ Map _inventoryNames(List events, String owner) { final previous = heads[id]; if (previous == null || meEventIsNewer(event, previous)) heads[id] = event; } - final names = {}; - for (final entry in heads.entries) { - try { - final body = jsonDecode(entry.value.content); - if (body is! Map) continue; - final name = body['name']; - if (name is String && name.trim().isNotEmpty) { - names[entry.key] = name.trim(); - } - } on FormatException { - // Empty/deleted and malformed heads never resurrect an earlier listing. - } - } - return names; + // Empty/deleted, malformed and unsupported heads never resurrect an earlier + // listing. + return { + for (final MapEntry(key: id, value: head) in heads.entries) + id: ?_supportedPolicyName(head), + }; } + +/// Desktop's `ManagedAgentEventContent` contract: a head desktop cannot parse +/// fails closed instead of offering an agent whose policy is unknown. +String? _supportedPolicyName(NostrEvent event) { + final Object? decoded; + try { + decoded = jsonDecode(event.content); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final body = decoded; + final name = body['name']; + if (name is! String || name.trim().isEmpty) return null; + final parallelism = body['parallelism']; + final allowlist = body['respond_to_allowlist']; + final supported = + parallelism is int && + parallelism >= 0 && + parallelism <= 0xFFFFFFFF && + _respondTo.contains(body['respond_to']) && + (!body.containsKey('respond_to_allowlist') || + (allowlist is List && allowlist.every((entry) => entry is String))) && + _optionalPolicyText.every((field) { + final value = body[field]; + return value == null || value is String; + }); + return supported ? name.trim() : null; +} + +const _respondTo = {'owner-only', 'allowlist', 'anyone'}; +const _optionalPolicyText = [ + 'persona_id', + 'system_prompt', + 'model', + 'provider', + 'persona_source_version', +]; diff --git a/mobile/test/features/channels/me/me_chat_assistant_test.dart b/mobile/test/features/channels/me/me_chat_assistant_test.dart index d279fd757a..8cb71244e7 100644 --- a/mobile/test/features/channels/me/me_chat_assistant_test.dart +++ b/mobile/test/features/channels/me/me_chat_assistant_test.dart @@ -22,8 +22,9 @@ import 'package:shared_preferences/shared_preferences.dart'; class _Profiles extends UserCacheNotifier { @override Map build() => { - 'a': const UserProfile(pubkey: 'a', displayName: 'Alpha'), - 'b': const UserProfile(pubkey: 'b', displayName: 'Beta'), + // Self-published names differ so tests prove the owner's name wins. + 'a': const UserProfile(pubkey: 'a', displayName: 'Confusable Alpha'), + 'b': const UserProfile(pubkey: 'b', displayName: 'Confusable Beta'), }; } @@ -383,6 +384,11 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip('Change agent: Alpha'), findsOneWidget); expect(find.byType(AvatarImage), findsOneWidget); + // Without artwork, the fallback initial follows the owner's label. + expect( + find.descendant(of: find.byType(AvatarImage), matching: find.text('A')), + findsOneWidget, + ); expect(find.text('Choose follow-up assistant'), findsNothing); final readsBeforeSend = directoryReads; await tester.tap(find.text('Send')); diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index 64911f1187..a4b7669acf 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -6,6 +6,9 @@ import 'package:flutter/services.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:flutter/material.dart'; @@ -234,7 +237,12 @@ void main() { ), meSyncProvider.overrideWithValue(sync), channelsProvider.overrideWith(FakeChannels.new), - meAssistantsProvider.overrideWith((ref) async => []), + meAssistantsProvider.overrideWith( + (ref) async => const [ + AgentDirectoryEntry(pubkey: 'agent', displayName: 'Owner Name'), + ], + ), + userCacheProvider.overrideWith(_ConfusableProfiles.new), ], child: MaterialApp( theme: AppTheme.light(), @@ -274,6 +282,21 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Enter a name and choose an agent.'), findsOneWidget); expect(find.text('Design session'), findsOneWidget); + // The owner's label and its initial outrank the agent's own profile. + await tester.tap(find.byKey(const ValueKey('new-session-assistant'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Owner Name').last); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('new-session-assistant')); + expect( + find.descendant(of: row, matching: find.text('Owner Name')), + findsOneWidget, + ); + expect( + find.descendant(of: row, matching: find.text('O')), + findsOneWidget, + ); + expect(find.textContaining('Confusable'), findsNothing); }, ); @@ -328,8 +351,11 @@ void main() { channelsProvider.overrideWith(FakeChannels.new), meAssistantsProvider.overrideWith((ref) async { directoryLoads++; - return []; + return const [ + AgentDirectoryEntry(pubkey: 'agent', displayName: 'Owner Name'), + ]; }), + userCacheProvider.overrideWith(_ConfusableProfiles.new), ], child: MaterialApp(theme: AppTheme.light(), home: const MePage()), ), @@ -347,7 +373,14 @@ void main() { final args = calls.single.arguments as Map; expect(args['title'], 'New chat'); expect(args['placeholder'], 'Chat name'); - expect(args['assistants'], isEmpty); + expect(args['assistants'], [ + { + 'id': 'agent', + 'name': 'Owner Name', + 'artworkScale': '1', + 'avatarUrl': '', + }, + ]); expect(args['projects'], [ {'id': 'mobile', 'name': 'Mobile app'}, ]); @@ -471,3 +504,10 @@ void main() { expect(find.text('A place to work with your agents'), findsNothing); }); } + +class _ConfusableProfiles extends UserCacheNotifier { + @override + Map build() => { + 'agent': const UserProfile(pubkey: 'agent', displayName: 'Confusable Name'), + }; +} diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index 3e952feace..777011495a 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -87,7 +87,13 @@ void main() { }) => NostrEvent.fromJson( nostr.Event.from( kind: 30177, - content: content ?? jsonEncode({'name': name}), + content: + content ?? + jsonEncode({ + 'name': name, + 'parallelism': 1, + 'respond_to': 'owner-only', + }), secretKey: owner.secret, createdAt: time, tags: [ @@ -277,6 +283,74 @@ void main() { expect(relay.requestedAuthors, isEmpty); }, ); + const policy = { + 'name': 'Saved agent', + 'parallelism': 1, + 'respond_to': 'owner-only', + }; + for (final (label, content) in [ + ('unsupported respond_to', {...policy, 'respond_to': 'nobody'}), + ('missing respond_to', {'name': 'Agent', 'parallelism': 1}), + ('missing parallelism', {'name': 'Agent', 'respond_to': 'anyone'}), + ('negative parallelism', {...policy, 'parallelism': -1}), + ('fractional parallelism', {...policy, 'parallelism': 1.5}), + ('oversized parallelism', {...policy, 'parallelism': 0x100000000}), + ('non-list allowlist', {...policy, 'respond_to_allowlist': 'x'}), + ( + 'non-string allowlist entry', + { + ...policy, + 'respond_to': 'allowlist', + 'respond_to_allowlist': [7], + }, + ), + ('null allowlist', {...policy, 'respond_to_allowlist': null}), + ('non-string model', {...policy, 'model': 7}), + ('non-object content', ['Agent']), + ]) { + test('an unsupported latest policy fails closed: $label', () async { + final relay = _Relay( + [ + [profile(agent, ownedBy: owner)], + ], + inventory: [ + listing(agent.public, time: 2, content: jsonEncode(content)), + listing(agent.public), + ], + ); + expect(await setup(relay).read(meAssistantsProvider.future), isEmpty); + }); + } + test('accepts every supported policy shape', () async { + final shapes = [ + policy, + {...policy, 'respond_to': 'anyone', 'parallelism': 0}, + { + ...policy, + 'respond_to': 'allowlist', + 'respond_to_allowlist': [other.public], + 'persona_id': 'scout', + 'model': null, + 'future_field': true, + }, + ]; + final agents = [for (final _ in shapes) nostr.Keys.generate()]; + final relay = _Relay( + [ + [for (final key in agents) profile(key, ownedBy: owner)], + ], + inventory: [ + for (var i = 0; i < shapes.length; i++) + listing(agents[i].public, content: jsonEncode(shapes[i])), + ], + ); + expect( + (await setup( + relay, + ).read(meAssistantsProvider.future)).map((a) => a.pubkey), + unorderedEquals(agents.map((key) => key.public)), + ); + }); test('inventory rejects records outside owner scope', () async { final foreign = NostrEvent.fromJson( nostr.Event.from(