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(