diff --git a/mobile/lib/features/channels/me/me_assistant_list.dart b/mobile/lib/features/channels/me/me_assistant_list.dart index 2f6dc6f5860..c7c0c0b4a10 100644 --- a/mobile/lib/features/channels/me/me_assistant_list.dart +++ b/mobile/lib/features/channels/me/me_assistant_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/profile/user_cache_provider.dart'; import '../../../shared/theme/theme.dart'; @@ -25,6 +26,7 @@ class MeAssistantList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final profiles = ref.watch(userCacheProvider); + final names = watchMeAssistantNames(ref, choices); return ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.6, @@ -68,7 +70,7 @@ class MeAssistantList extends ConsumerWidget { : 'A', ), ), - title: Text(agent.displayName ?? 'Agent'), + title: Text(names.labelFor(agent.pubkey)), trailing: agent.pubkey == selected ? const Icon(BuzzIcons.check) : null, diff --git a/mobile/lib/features/channels/me/me_chat_assistant.dart b/mobile/lib/features/channels/me/me_chat_assistant.dart index 8237bedaf09..53683a36abe 100644 --- a/mobile/lib/features/channels/me/me_chat_assistant.dart +++ b/mobile/lib/features/channels/me/me_chat_assistant.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/me/me_assistants.dart'; import '../../../shared/me/me_agent_change.dart'; import '../../../shared/me/me_sync.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/profile/user_cache_provider.dart'; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme.dart'; @@ -73,7 +75,9 @@ useMeChatAssistant( final prefs = sync?.prefs; final selection = useState((key, initial ?? prefs?.getString(key))); final busy = useState(false); - final agents = enabled ? ref.watch(meAssistantsProvider).value ?? [] : []; + final List agents = enabled + ? ref.watch(meAssistantsProvider).value ?? [] + : []; final members = enabled ? ref.watch(channelMembersProvider(channelId)).value ?? [] : []; @@ -93,30 +97,6 @@ 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 = - agents - .where((agent) => agent.pubkey == selected) - .firstOrNull - ?.displayName ?? - profile?.displayName; - final avatarInitial = name?.trim().isNotEmpty == true - ? name!.trim()[0].toUpperCase() - : profile?.initial ?? '?'; - String agentName(String pubkey) { - final names = [ - agents.where((a) => a.pubkey == pubkey).firstOrNull?.displayName, - ref.read(userCacheProvider)[pubkey]?.displayName, - ]; - return names - .whereType() - .where((name) => name.trim().isNotEmpty) - .firstOrNull ?? - (pubkey.length > 12 - ? '${pubkey.substring(0, 8)}…${pubkey.substring(pubkey.length - 4)}' - : pubkey); - } - MeAgentChange? pending; if (sync != null) { try { @@ -125,6 +105,31 @@ useMeChatAssistant( // The action surfaces an unreadable journal without crashing the chat. } } + // The owner's inventory name outranks the agent's self-published profile. + final names = watchMeAssistantNames( + ref, + agents, + saved: [selected, pending?.agent], + ); + // A known agent outside the inventory, such as an archived one, is labeled + // against the live agents so it never reads as a live namesake. + bool named(String pubkey) => + agents.any((agent) => agent.pubkey == pubkey) || + ref.read(userCacheProvider)[pubkey]?.displayName?.trim().isNotEmpty == + true; + final name = selected != null && named(selected) + ? names.labelFor(selected) + : profile?.displayName; + final avatarInitial = name?.trim().isNotEmpty == true + ? name!.trim()[0].toUpperCase() + : profile?.initial ?? '?'; + String agentName(String pubkey) { + if (named(pubkey)) return names.labelFor(pubkey); + return pubkey.length > 12 + ? '${pubkey.substring(0, 8)}…${pubkey.substring(pubkey.length - 4)}' + : pubkey; + } + final pendingName = pending == null ? null : agentName(pending.agent); final label = pendingName != null ? 'Pending agent change: $pendingName' diff --git a/mobile/lib/features/channels/me/me_chat_draft.dart b/mobile/lib/features/channels/me/me_chat_draft.dart index f5bfe8c3dab..cf5242eb279 100644 --- a/mobile/lib/features/channels/me/me_chat_draft.dart +++ b/mobile/lib/features/channels/me/me_chat_draft.dart @@ -27,10 +27,18 @@ class _MeChatDraft extends HookConsumerWidget { final agent = choices .where((agent) => agent.pubkey == selected) .firstOrNull; - final name = + final names = watchMeAssistantNames(ref, choices, saved: [selected]); + // The composer keeps the plain name; only what the owner reads is labeled. + final plainName = agent?.displayName ?? profile?.label ?? (selected == null ? 'Choose an agent' : 'Agent'); + // A saved choice that is no longer offered, such as an archived agent, is + // labeled against the live agents so it never reads as a live namesake. + final name = + agent != null || profile?.displayName?.trim().isNotEmpty == true + ? names.labelFor(selected!) + : plainName; final avatarInitial = name.isEmpty ? 'A' : name.characters.first; final scopeChanged = originalScope != (sync.community, sync.pubkey); final frozen = @@ -249,7 +257,7 @@ class _MeChatDraft extends HookConsumerWidget { pubkey: selected, role: 'bot', joinedAt: DateTime(2026), - displayName: name, + displayName: plainName, ), ], onSendingChanged: (value) { diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index e878a894fc8..d48f217a3df 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -22,6 +22,7 @@ import '../../../shared/me/me_chat_creation.dart'; import '../../../shared/me/me_sync.dart'; import '../../../shared/relay/relay.dart'; import '../../../shared/me/me_recovery.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/me/me_assistants.dart'; import '../../../shared/theme/theme.dart'; import '../../../shared/theme/buzz_icons.dart'; diff --git a/mobile/lib/features/channels/me/me_working_provider.dart b/mobile/lib/features/channels/me/me_working_provider.dart index a602769ae2a..f22f6ad75f0 100644 --- a/mobile/lib/features/channels/me/me_working_provider.dart +++ b/mobile/lib/features/channels/me/me_working_provider.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/me/me_assistant_names.dart'; import '../../../shared/me/me_assistants.dart'; import '../channel_typing_provider.dart'; @@ -12,11 +13,12 @@ final meWorkingAgentsProvider = Provider.autoDispose if (agents.isEmpty) return const []; final typing = ref.watch(channelTypingProvider(channelId)); final active = typing.map((entry) => entry.pubkey.toLowerCase()).toSet(); - return [ + final working = [ for (final agent in agents) - if (active.contains(agent.pubkey.toLowerCase())) - agent.displayName?.trim().isNotEmpty == true - ? agent.displayName!.trim() - : 'Agent', + if (active.contains(agent.pubkey.toLowerCase())) agent.pubkey, ]; + // An idle chat reads no names at all. + if (working.isEmpty) return const []; + final names = ref.watch(meAssistantNamesProvider); + return [for (final pubkey in working) names.labelFor(pubkey)]; }); diff --git a/mobile/lib/shared/identity_names/identity_names_provider.dart b/mobile/lib/shared/identity_names/identity_names_provider.dart index 5bf7c5eccac..bb1aec9ee92 100644 --- a/mobile/lib/shared/identity_names/identity_names_provider.dart +++ b/mobile/lib/shared/identity_names/identity_names_provider.dart @@ -42,15 +42,18 @@ void loadIdentityNameOwners(Ref ref, IdentityNames names) { /// [candidates]). Pass [shown] when the context also holds identities that are /// only compared, such as a channel roster whose names another owner loads, /// so opening the view does not queue a profile read for every one of them. +/// [sources] replaces the client-wide facts for views that adjust them. IdentityNames watchIdentityNames( WidgetRef ref, Iterable candidates, { Set agentPubkeys = const {}, Map fallbackNames = const {}, Iterable? shown, + IdentityNameSources? sources, }) { - final sources = ref.watch(identityNameSourcesProvider); - final names = sources.scope( + final IdentityNameSources facts = + sources ?? ref.watch(identityNameSourcesProvider); + final names = facts.scope( candidates, agentPubkeys: agentPubkeys, fallbackNames: fallbackNames, @@ -66,7 +69,7 @@ IdentityNames watchIdentityNames( }; final missing = { for (final key in loadFor) - if (!sources.profiles.containsKey(key)) key, + if (!facts.profiles.containsKey(key)) key, ...names.missingOwnerProfiles(loadFor), }; if (missing.isNotEmpty) { diff --git a/mobile/lib/shared/me/me_assistant_names.dart b/mobile/lib/shared/me/me_assistant_names.dart new file mode 100644 index 00000000000..0442f3cc84d --- /dev/null +++ b/mobile/lib/shared/me/me_assistant_names.dart @@ -0,0 +1,86 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../identity_names/identity_names.dart'; +import '../identity_names/identity_names_provider.dart'; +import '../mentions/agent_identity_provider.dart'; +import '../profile/user_profile.dart'; +import 'me_assistants.dart'; + +/// [base] with the owner's inventory name ahead of each agent's self-published +/// profile name, as Solo has always shown it. Verified names still win, and +/// every other naming fact is untouched. +IdentityNameSources meAssistantNameSources( + IdentityNameSources base, + List agents, +) { + final inventory = { + for (final agent in agents) + if (agent.displayName?.trim().isNotEmpty == true) + agent.pubkey.toLowerCase(): agent.displayName!, + }; + return IdentityNameSources( + // An uncached profile stays absent so the caller still loads it. + profiles: { + for (final MapEntry(:key, :value) in base.profiles.entries) + key: inventory.containsKey(key) + ? UserProfile( + pubkey: value.pubkey, + displayName: inventory[key], + avatarUrl: value.avatarUrl, + about: value.about, + nip05Handle: value.nip05Handle, + ownerPubkey: value.ownerPubkey, + ) + : value, + }, + verifiedNames: base.verifiedNames, + agentPubkeys: base.agentPubkeys, + agentDisplayNames: {...base.agentDisplayNames, ...inventory}, + agentOwners: base.agentOwners, + viewer: base.viewer, + ); +} + +/// Labels for the agents Solo offers. The comparison context is exactly +/// [agents] (the live inventory, archived agents already excluded), so a +/// hidden archived namesake never adds a qualifier, and the picker, draft and +/// header all read the same label for one agent. +IdentityNames meAssistantNames( + IdentityNameSources sources, + List agents, +) { + final keys = [for (final agent in agents) agent.pubkey]; + return meAssistantNameSources( + sources, + agents, + ).scope(keys, agentPubkeys: keys.toSet()); +} + +/// [meAssistantNames] for the live inventory, built once for every reader so +/// each Solo row does not copy the profile cache again. +final meAssistantNamesProvider = Provider.autoDispose( + (ref) => meAssistantNames( + ref.watch(identityNameSourcesProvider), + ref.watch(meAssistantsProvider).value ?? const [], + ), +); + +/// [meAssistantNames] for a widget, loading the profiles it still lacks. +/// [saved] are agents a saved choice still names that Solo no longer offers, +/// such as an archived agent: they are labeled as agents against [agents]. +IdentityNames watchMeAssistantNames( + WidgetRef ref, + List agents, { + Iterable saved = const [], +}) { + final keys = [for (final agent in agents) agent.pubkey]; + return watchIdentityNames( + ref, + keys, + agentPubkeys: {...keys, ...saved.nonNulls}, + sources: meAssistantNameSources( + ref.watch(identityNameSourcesProvider), + agents, + ), + ); +} 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 8cb71244e72..a9425e1817d 100644 --- a/mobile/test/features/channels/me/me_chat_assistant_test.dart +++ b/mobile/test/features/channels/me/me_chat_assistant_test.dart @@ -12,6 +12,8 @@ import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,6 +30,13 @@ class _Profiles extends UserCacheNotifier { }; } +class _CachedProfiles extends UserCacheNotifier { + _CachedProfiles(this.profiles); + final Map profiles; + @override + Map build() => profiles; +} + class _Harness extends HookConsumerWidget { final ValueChanged> onSend; const _Harness(this.onSend); @@ -61,7 +70,8 @@ class _Harness extends HookConsumerWidget { void main() { for (final keyFallback in [false, true]) { final target = keyFallback ? '0123456789abcdef' * 4 : 'b'; - final targetName = keyFallback ? '01234567…cdef' : 'Beta'; + // An unnamed agent reads as its compact npub, like every other picker. + final targetName = keyFallback ? shortPubkey(target) : 'Beta'; for (final action in ['Resume', 'Later', 'Discard choice']) { testWidgets( 'header restores pending agent choice: $action fallback=$keyFallback', @@ -426,4 +436,115 @@ void main() { expect(ownershipReads, 2); }, ); + testWidgets('same-name agents read distinctly in the picker and header', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ), + prefs: prefs, + ); + final first = 'a1' * 32; + final second = 'b2' * 32; + await prefs.setString( + 'me-assistant:${sync.community}:${sync.pubkey}:chat', + first, + ); + var live = [first, second]; + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meAssistantsProvider.overrideWith( + (ref) async => [ + for (final key in live) + AgentDirectoryEntry(pubkey: key, displayName: 'Atlas'), + ], + ), + channelMembersProvider('chat').overrideWith( + (ref) async => [ + for (final key in [first, second]) + ChannelMember( + pubkey: key, + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ), + meAssistantOwnershipProvider.overrideWith((ref, key) async => true), + // An archived agent's profile stays in the client's cache. + userCacheProvider.overrideWith( + () => _CachedProfiles({ + first: UserProfile(pubkey: first, displayName: 'Atlas'), + }), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: _Harness((_) {})), + ), + ); + await tester.pumpAndSettle(); + String headerLabel() => tester + .widget(find.byKey(const ValueKey('me-assistant-button'))) + .tooltip!; + List pickerLabels() => [ + for (final row in tester.widgetList( + find.byType(AppListRowRaw), + )) + (row.title as Text).data!, + ]; + + final firstHeader = headerLabel(); + expect(firstHeader, startsWith('Change agent: Atlas')); + expect(firstHeader, isNot('Change agent: Atlas')); + await tester.tap(find.byKey(const ValueKey('me-assistant-button'))); + await tester.pumpAndSettle(); + final labels = pickerLabels(); + expect(labels, hasLength(2)); + expect(labels.toSet(), hasLength(2), reason: 'Namesakes must differ'); + expect('Change agent: ${labels.first}', firstHeader); + await tester.tap(find.text(labels.last)); + await tester.pumpAndSettle(); + expect(headerLabel(), 'Change agent: ${labels.last}'); + + // The first agent is archived: no live namesake remains, so the cached + // profiles of hidden agents add no qualifier. + live = [second]; + ProviderScope.containerOf( + tester.element(find.byType(_Harness)), + ).invalidate(meAssistantsProvider); + await tester.pumpAndSettle(); + expect(headerLabel(), 'Change agent: Atlas'); + await tester.tap(find.byKey(const ValueKey('me-assistant-button'))); + await tester.pumpAndSettle(); + expect(pickerLabels(), ['Atlas']); + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // A saved switch to the archived agent still names it apart from its live + // namesake, where the owner decides whether to finish it. + await MeAgentChange.create(sync, 'chat', first).save(sync, 'chat'); + ProviderScope.containerOf( + tester.element(find.byType(_Harness)), + ).invalidate(meAssistantsProvider); + await tester.pumpAndSettle(); + final pending = find.textContaining('Finish choosing '); + final archivedName = tester + .widget(pending) + .data! + .substring('Finish choosing '.length); + expect(archivedName, startsWith('Atlas')); + expect(archivedName, isNot('Atlas')); + expect(headerLabel(), 'Pending agent change: $archivedName'); + await tester.tap(pending); + await tester.pumpAndSettle(); + expect(find.textContaining('Your choice of $archivedName'), findsOneWidget); + }); } diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index 7bef3f3c7c8..9a89975375f 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -22,6 +22,16 @@ import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; + +class _CachedProfiles extends UserCacheNotifier { + _CachedProfiles(this.profiles); + final Map profiles; + @override + Map build() => profiles; +} class _UnavailableRelay extends RelaySessionNotifier { @override @@ -426,6 +436,143 @@ void main() { }, ); + testWidgets('new chat tells same-name agents apart in the picker and draft', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final action = ValueNotifier(null); + addTearDown(action.dispose); + final sync = MeSync( + _UnavailableRelay(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys.generate().nsec, + ), + prefs: prefs, + ); + var live = ['a1' * 32, 'b2' * 32]; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + defaultAgentProvider.overrideWith((ref) => Stream.value(null)), + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith( + (ref) async => [ + for (final key in live) + AgentDirectoryEntry(pubkey: key, displayName: 'Atlas'), + ], + ), + agentDirectoryProvider.overrideWith((ref) async => []), + agentOwnersProvider.overrideWith((ref) async => {}), + customEmojiListProvider.overrideWithValue([]), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: MePage(newChatAction: action), + ), + ), + ); + await tester.pumpAndSettle(); + action.value!(); + await tester.pumpAndSettle(); + List pickerLabels() => [ + for (final row in tester.widgetList( + find.byType(AppListRowRaw), + )) + (row.title as Text).data!, + ]; + + await tester.tap(find.byTooltip('Choose an agent')); + await tester.pumpAndSettle(); + final labels = pickerLabels(); + expect(labels.toSet(), hasLength(2), reason: 'Namesakes must differ'); + await tester.tap(find.text(labels.last)); + await tester.pumpAndSettle(); + expect(find.byTooltip('Change agent: ${labels.last}'), findsOneWidget); + expect(find.text('Message ${labels.last}'), findsOneWidget); + + // Once the namesake is archived the plain name is enough. + live = [live.last]; + ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ).invalidate(meAssistantsProvider); + await tester.pumpAndSettle(); + expect(find.byTooltip('Change agent: Atlas'), findsOneWidget); + expect(find.text('Message Atlas'), findsOneWidget); + await tester.tap(find.byTooltip('Change agent: Atlas')); + await tester.pumpAndSettle(); + expect(pickerLabels(), ['Atlas']); + }); + + testWidgets('a resumed chat for an archived agent names it apart', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final action = ValueNotifier(null); + addTearDown(action.dispose); + final sync = MeSync( + _UnavailableRelay(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys.generate().nsec, + ), + prefs: prefs, + ); + final archived = 'a1' * 32; + final live = 'b2' * 32; + await MeChatCreation('same-id', 'Unfinished chat', archived).save(sync); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + defaultAgentProvider.overrideWith((ref) => Stream.value(null)), + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith( + (ref) async => [ + AgentDirectoryEntry(pubkey: live, displayName: 'Atlas'), + ], + ), + // The archived agent's profile stays in the client's cache. + userCacheProvider.overrideWith( + () => _CachedProfiles({ + archived: UserProfile(pubkey: archived, displayName: 'Atlas'), + }), + ), + agentDirectoryProvider.overrideWith((ref) async => []), + agentOwnersProvider.overrideWith((ref) async => {}), + customEmojiListProvider.overrideWithValue([]), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: MePage(newChatAction: action), + ), + ), + ); + await tester.pumpAndSettle(); + action.value!(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Resume')); + await tester.pumpAndSettle(); + final tooltip = tester + .widgetList(find.byType(IconButton)) + .map((button) => button.tooltip) + .whereType() + .singleWhere((label) => label.startsWith('Change agent: ')); + expect(tooltip, startsWith('Change agent: Atlas')); + expect(tooltip, isNot('Change agent: Atlas')); + }); + for (final resume in [true, false]) { testWidgets( 'new chat resumes a named creation in the draft: resume=$resume', diff --git a/mobile/test/features/channels/me/me_working_provider_test.dart b/mobile/test/features/channels/me/me_working_provider_test.dart new file mode 100644 index 00000000000..ee589c9fe13 --- /dev/null +++ b/mobile/test/features/channels/me/me_working_provider_test.dart @@ -0,0 +1,66 @@ +import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/features/channels/me/me_working_provider.dart'; +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/shared/identity_names/identity_names_provider.dart'; +import 'package:buzz/shared/me/me_assistant_names.dart'; +import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _Typing extends ChannelTypingNotifier { + _Typing(super.channelId); + @override + List build() => []; + void show(List pubkeys) => state = [ + for (final pubkey in pubkeys) + TypingEntry(pubkey: pubkey, expiresAtMs: 999999), + ]; +} + +void main() { + test('idle Solo rows build no names; working namesakes read apart', () async { + final first = 'a1' * 32; + final second = 'b2' * 32; + final chats = ['one', 'two']; + final typing = {for (final id in chats) id: _Typing(id)}; + var nameReads = 0; + final container = ProviderContainer( + overrides: [ + meAssistantsProvider.overrideWith( + (ref) async => [ + for (final key in [first, second]) + AgentDirectoryEntry(pubkey: key, displayName: 'Atlas'), + ], + ), + identityNameSourcesProvider.overrideWith((ref) { + nameReads++; + return IdentityNameSources.empty; + }), + for (final id in chats) + channelTypingProvider(id).overrideWith(() => typing[id]!), + ], + ); + addTearDown(container.dispose); + await container.read(meAssistantsProvider.future); + final rows = { + for (final id in chats) + id: container.listen(meWorkingAgentsProvider(id), (_, _) {}), + }; + + // Nobody is working: no row copies the profile cache into a name context. + expect([for (final row in rows.values) row.read()], [isEmpty, isEmpty]); + expect(nameReads, 0); + expect(container.exists(meAssistantNamesProvider), isFalse); + + typing['one']!.show([first]); + typing['two']!.show([first, second]); + final one = rows['one']!.read(); + final two = rows['two']!.read(); + expect(one, hasLength(1)); + expect(two, hasLength(2)); + expect(two.toSet(), hasLength(2), reason: 'Namesakes must differ'); + expect(two.first, one.single, reason: 'One agent reads alike everywhere'); + expect(two, everyElement(startsWith('Atlas'))); + }); +} diff --git a/mobile/test/shared/me/me_assistant_names_test.dart b/mobile/test/shared/me/me_assistant_names_test.dart new file mode 100644 index 00000000000..c1374f81804 --- /dev/null +++ b/mobile/test/shared/me/me_assistant_names_test.dart @@ -0,0 +1,37 @@ +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/shared/me/me_assistant_names.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final atlas = 'a' * 64; + final uncached = 'b' * 64; + final agents = [ + AgentDirectoryEntry(pubkey: atlas, displayName: 'Atlas'), + AgentDirectoryEntry(pubkey: uncached, displayName: 'Beacon'), + ]; + + test('Solo prefers the inventory name to a cached profile name', () { + final cached = IdentityNameSources( + profiles: {atlas: UserProfile(pubkey: atlas, displayName: 'Old name')}, + ); + final names = meAssistantNames(cached, agents); + expect(names.labelFor(atlas), 'Atlas'); + expect(names.labelFor(uncached), 'Beacon'); + // An uncached profile stays absent, so the caller still loads it. + expect(meAssistantNameSources(cached, agents).profiles, contains(atlas)); + expect( + meAssistantNameSources(cached, agents).profiles, + isNot(contains(uncached)), + ); + }); + + test('a verified name still wins over the inventory name', () { + final verified = IdentityNameSources( + profiles: {atlas: UserProfile(pubkey: atlas, displayName: 'Old name')}, + verifiedNames: {atlas: 'Atlas Prime'}, + ); + expect(meAssistantNames(verified, agents).labelFor(atlas), 'Atlas Prime'); + }); +}