mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
fix(mobile): align Solo agent policy and labels with the owner's inventory (#8616)
## 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 <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>()
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -72,18 +72,48 @@ Map<String, String> _inventoryNames(List<NostrEvent> events, String owner) {
|
||||
final previous = heads[id];
|
||||
if (previous == null || meEventIsNewer(event, previous)) heads[id] = event;
|
||||
}
|
||||
final names = <String, String>{};
|
||||
for (final entry in heads.entries) {
|
||||
try {
|
||||
final body = jsonDecode(entry.value.content);
|
||||
if (body is! Map<String, dynamic>) 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<String, dynamic>) 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',
|
||||
];
|
||||
|
||||
@@ -22,8 +22,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
class _Profiles extends UserCacheNotifier {
|
||||
@override
|
||||
Map<String, UserProfile> 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'));
|
||||
|
||||
@@ -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<String, UserProfile> build() => {
|
||||
'agent': const UserProfile(pubkey: 'agent', displayName: 'Confusable Name'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user