From 101f971874f7578b1f93d492cd1bdb6911453390 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 26 Oct 2026 22:07:24 -0700 Subject: [PATCH] fix(mobile): label Solo agents with the owner's inventory name Solo surfaces showed the agent's self-authored kind:0 display name, so an agent could rename itself in the owner's picker and chat header. Use the name from the owner's 30177 record, as desktop's managed-agent directory does, and keep the profile for NIP-OA verification and other metadata. Signed-off-by: Tom Brow --- mobile/lib/shared/me/me_assistants.dart | 42 ++++++++++++------- mobile/test/shared/me/me_assistants_test.dart | 22 +++++++++- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index b4eaf823fc..c3256a71ff 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -55,7 +55,8 @@ Future> _loadAssistants( RelaySessionNotifier session, { String? pubkey, }) async { - final candidates = await _managedAgentPubkeys(sync, session, agent: pubkey); + final names = await _managedAgentNames(sync, session, agent: pubkey); + final candidates = names.keys.toList(); final profiles = {}; for (var offset = 0; offset < candidates.length; offset += _pageSize) { final batch = candidates.sublist( @@ -84,7 +85,18 @@ Future> _loadAssistants( for (final event in profiles.values) { try { if (verifiedProfileOaOwnerPubkey(event) == sync.pubkey) { - agents.add(AgentDirectoryEntry.fromEvent(event)); + // The owner's inventory name is authoritative on Solo surfaces; the + // agent's self-authored profile cannot rename itself there. + final entry = AgentDirectoryEntry.fromEvent(event); + agents.add( + AgentDirectoryEntry( + pubkey: entry.pubkey, + displayName: names[entry.pubkey], + respondTo: entry.respondTo, + respondToAllowlist: entry.respondToAllowlist, + channelIds: entry.channelIds, + ), + ); } } catch (_) { // A malformed profile must not hide unrelated verified agents. @@ -94,12 +106,12 @@ Future> _loadAssistants( return agents; } -/// Candidate agents from the owner's own managed-agent records, as desktop -/// discovers them. The records are discovery hints only: each candidate's +/// Candidate agents and their owner-assigned names from the owner's own +/// managed-agent records, as desktop discovers them. The records are discovery hints only: each candidate's /// profile must still carry a valid NIP-OA attestation by the owner. With /// [agent], only that agent's record is read, so an agent removed from the /// inventory also fails revalidation before chat creation and send. -Future> _managedAgentPubkeys( +Future> _managedAgentNames( MeSync sync, RelaySessionNotifier session, { String? agent, @@ -161,34 +173,35 @@ Future> _managedAgentPubkeys( } cursor = last; } - return [ + return { for (final MapEntry(key: d, value: event) in heads.entries) - if (_agentPubkey.hasMatch(d) && _isSupportedPolicy(event)) d, - ]; + if (_agentPubkey.hasMatch(d)) d: ?_supportedPolicyName(event), + }; } bool _newer(NostrEvent a, NostrEvent b) => a.createdAt > b.createdAt || (a.createdAt == b.createdAt && a.id.compareTo(b.id) < 0); -/// Whether a managed-agent record holds a policy desktop can apply. This +/// The owner-assigned name of a managed-agent record whose policy desktop can +/// apply, or null. This /// mirrors desktop's `ManagedAgentEventContent` parse, plus the non-empty name /// desktop's agent library requires. An unsupported latest record fails /// closed: it excludes the agent rather than falling back to an older one. -bool _isSupportedPolicy(NostrEvent event) { +String? _supportedPolicyName(NostrEvent event) { final Object? decoded; try { decoded = jsonDecode(event.content); } on FormatException { - return false; + return null; } - if (decoded is! Map) return false; + 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']; - return name is String && - name.trim().isNotEmpty && + final supported = parallelism is int && parallelism >= 0 && parallelism <= 0xFFFFFFFF && @@ -199,6 +212,7 @@ bool _isSupportedPolicy(NostrEvent event) { final value = body[field]; return value == null || value is String; }); + return supported ? name.trim() : null; } const _respondTo = {'owner-only', 'allowlist', 'anyone'}; diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index 4b046769fb..c3599b5c2f 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -157,7 +157,7 @@ void main() { ); final choices = await setup(relay).read(meAssistantsProvider.future); expect(choices.map((a) => a.pubkey), [agent.public]); - expect(choices.single.displayName, 'My assistant'); + expect(choices.single.displayName, 'Agent'); expect(relay.inventoryFilters.single, { 'kinds': [30177], 'authors': [owner.public], @@ -169,6 +169,24 @@ void main() { ); expect(relay.profileFilters.single['limit'], 3); }); + test('the owner inventory name outranks the agent profile name', () async { + final relay = _Relay( + inventory: [ + [ + managed(agent.public, content: {...policy, 'name': ' Owner Name '}), + ], + ], + profiles: [ + profile( + agent, + ownedBy: owner, + content: {'display_name': 'Confusable Name', 'name': 'Other'}, + ), + ], + ); + final choices = await setup(relay).read(meAssistantsProvider.future); + expect(choices.single.displayName, 'Owner Name'); + }); test('does not query profiles without managed agents', () async { final relay = _Relay(profiles: [profile(agent, ownedBy: owner)]); expect(await setup(relay).read(meAssistantsProvider.future), isEmpty); @@ -206,7 +224,7 @@ void main() { final choices = await container.read(meAssistantsProvider.future); expect( choices.singleWhere((a) => a.pubkey == agent.public).displayName, - 'My assistant', + 'Agent', ); expect( await isOwnedMeAssistant(