feat(mobile): open a prefilled Create agent form from Builderlab agent cards

A printed Builderlab agent card's QR leads to its share page
(app.builderlab.xyz/a/<id>), whose "Add to Buzz" button opens
buzz://agent/new?card=<id>. Mobile now handles that link: it fetches the
card's agent.json from the avatars CDN for this build's Builderlab
environment and opens the Agents page with an editable, prefilled Create
agent form. Opening a link never creates an agent.

- shared/deeplink: strict parser for buzz://agent/new?card=<token>; any
  other shape is not this link.
- HostedEnvironment.avatarsCdnOrigin maps each account environment to its
  CDN; the link can never name the host.
- AgentCreationDraft.fetch reads name, avatarUrl, instructions, model and
  reasoningEffort. Every field is optional: published cards have only name
  and avatar today. snake_case and a few synonyms are also read so a later
  agent.json revision degrades to a partial draft instead of failing. The
  avatar is used only from the same CDN; the body is capped at 256 KiB.
- AgentsPage waits for sign-in, the registry load and remote-agent support,
  drops the link with a message if a setup is unfinished or the card can't
  be loaded, and replaces an open Agents stack rather than stacking one.
- CreateAgentPage takes an optional draft; model and effort apply only if
  the live catalog offers them.
- Table tests for the link and agent.json, and a journey test from link to
  Create against a fake CDN (needs BUZZ_AUTH_DOMAINS, like the other hosted
  agent tests).

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-10-31 10:17:29 -07:00
parent b4509dee8b
commit ab516337a5
14 changed files with 917 additions and 51 deletions

View File

@@ -29,6 +29,44 @@ names, not URLs or wildcards. The list does not add trusted account servers:
configured domains outside the supported Builderlab realm cannot use hosted
setup. `BUZZ_BUILDERLAB_URL` still selects the trusted account environment.
## Agent creation links
`buzz://agent/new?card=<id>` opens the Agents page and an editable, pre-filled
Create agent form for the active community. Opening a link never creates an
agent. A Builderlab agent card's QR leads to its share page
(`app.builderlab.xyz/a/<id>`), whose **Add to Buzz** button opens this link.
The app fetches `<avatars CDN>/<id>/agent.json` from the CDN for this build's
Builderlab environment (`HostedEnvironment.avatarsCdnOrigin`), never from a
host named by the link. The link carries only the id, so instructions can be
any length while the QR stays small.
```json
{
"name": "Scout",
"avatarUrl": "https://<avatars CDN>/<id>/<revision>/avatar.png",
"instructions": "Reply concisely.",
"model": "<model catalog id>",
"reasoningEffort": "high"
}
```
- Every field is optional and left for the user when missing. Cards published
so far have only `name` and `avatarUrl`. snake_case and a few synonyms
(`system_prompt`, `model_id`, `effort`, a `model` object with an `id`) are
also read. Other fields are ignored.
- The avatar is used only if it is an `https` URL on the same CDN; otherwise the
form picks its usual default. It stays editable.
- `model` and `reasoningEffort` are pre-selected only if the live model catalog
offers them (the effort only for that model).
- The link waits for sign-in and community load. It needs Builderlab login and a
hosted community that supports remote agents, and is dropped with a message if
an unfinished agent setup exists (resume or discard that first) or the card
can't be loaded.
The parser is `lib/shared/deeplink/agent_creation_link.dart`; the card reader
is `lib/features/agents/agent_creation_draft.dart`.
## Hosted relay authentication
`--dart-define=BUZZ_HOSTED_RELAY_AUTH=true` enables Builderlab relay assertions

View File

@@ -57,6 +57,26 @@ class AgentAvatarSelection {
}
}
/// Renders an emoji on a solid [color] to the 512px PNG that creation uploads.
Future<Uint8List?> renderEmojiAvatarPng(String emoji, int color) async {
// Export pixels, as desktop does: registry pictures are ordinary HTTPS
// media URLs, not client-specific SVG data URLs.
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawColor(Color(color), BlendMode.src);
final text = TextPainter(
text: TextSpan(text: emoji, style: const TextStyle(fontSize: 258)),
textDirection: TextDirection.ltr,
)..layout();
text.paint(canvas, Offset((512 - text.width) / 2, (512 - text.height) / 2));
final picture = recorder.endRecording();
final raster = await picture.toImage(512, 512);
final data = await raster.toByteData(format: ui.ImageByteFormat.png);
raster.dispose();
picture.dispose();
return data?.buffer.asUint8List();
}
/// Avatar preview with character, shared emoji, and image selection controls.
class AgentAvatarPicker extends HookConsumerWidget {
const AgentAvatarPicker({
@@ -121,30 +141,10 @@ class AgentAvatarPicker extends HookConsumerWidget {
final locked = disabled || busy.value;
Future<void> selectEmoji() async {
// Export pixels, as desktop does: registry pictures are ordinary HTTPS
// media URLs, not client-specific SVG data URLs.
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawColor(Color(color.value), BlendMode.src);
final text = TextPainter(
text: TextSpan(
text: emoji.value,
style: const TextStyle(fontSize: 258),
),
textDirection: TextDirection.ltr,
)..layout();
text.paint(
canvas,
Offset((512 - text.width) / 2, (512 - text.height) / 2),
);
final picture = recorder.endRecording();
final raster = await picture.toImage(512, 512);
final data = await raster.toByteData(format: ui.ImageByteFormat.png);
raster.dispose();
picture.dispose();
final data = await renderEmojiAvatarPng(emoji.value, color.value);
if (context.mounted && data != null) {
final rendered = AgentAvatarSelection.emoji(
data.buffer.asUint8List(),
data,
emoji.value,
color.value,
);

View File

@@ -0,0 +1,171 @@
import 'dart:async';
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../shared/deeplink/deep_link.dart';
import '../../shared/hosted/hosted_environment.dart';
import 'agent_avatar_picker.dart';
/// Creates the HTTP client that fetches a card's `agent.json`; overridden in
/// tests.
final agentCardClientProvider = Provider<http.Client Function()>(
(ref) => http.Client.new,
);
/// Largest `agent.json` accepted, far above any real card.
const maxAgentCardBytes = 256 * 1024;
const _fetchTimeout = Duration(seconds: 15);
/// Why a card's draft could not be loaded, worded for the user.
class AgentCardException implements Exception {
const AgentCardException(this.message);
final String message;
@override
String toString() => 'AgentCardException: $message';
}
/// Initial, fully editable values for [CreateAgentPage], from a link.
///
/// Unlike an unfinished setup in `RemoteAgentController.pending`, a draft has
/// created nothing: every field stays editable and nothing is persisted.
class AgentCreationDraft {
const AgentCreationDraft({
required this.name,
required this.instructions,
this.avatar,
this.modelId,
this.reasoningEffort,
});
final String name;
final String instructions;
/// Card avatar, or null to let the form pick its usual default.
final AgentAvatarSelection? avatar;
/// Requested model, applied only if the live catalog lists it.
final String? modelId;
/// Requested effort, applied only if the chosen model supports it.
final String? reasoningEffort;
/// Fetches [link]'s card from this build's avatars CDN and reads its draft.
static Future<AgentCreationDraft> fetch(
AgentCreationDeepLink link,
http.Client client,
) async {
final cdn = HostedEnvironment.avatarsCdnOrigin;
final url = cdn.replace(path: '/${link.cardId}/agent.json');
final http.StreamedResponse response;
final List<int> body;
try {
response = await client
.send(
http.Request('GET', url)..headers['accept'] = 'application/json',
)
.timeout(_fetchTimeout);
if (response.statusCode == 403 || response.statusCode == 404) {
throw const AgentCardException('This agent card isn’t available.');
}
if (response.statusCode != 200) {
throw AgentCardException(
'Couldn’t load this agent card (HTTP ${response.statusCode}).',
);
}
body = await _readBounded(response.stream).timeout(_fetchTimeout);
} on AgentCardException {
rethrow;
} on Exception {
throw const AgentCardException(
'Couldn’t load this agent card. Check your connection and reopen the '
'link.',
);
}
final Object? json;
try {
json = jsonDecode(utf8.decode(body));
} on FormatException {
throw const AgentCardException('This agent card is invalid.');
}
if (json is! Map<String, dynamic>) {
throw const AgentCardException('This agent card is invalid.');
}
return parseAgentCard(json, cdn);
}
/// Reads a card's draft. Only `name`, `avatarUrl`, `instructions`, `model`
/// and `reasoningEffort` are defined; cards published before instructions
/// and model existed have just the first two. Any field may be missing or
/// spelled in snake_case or a common synonym, and is then left for the user.
/// An avatar is used only from [cdn], the host the card itself came from.
static AgentCreationDraft parseAgentCard(Map<String, dynamic> json, Uri cdn) {
final model = json['model'];
final avatarUrl = _string(json, const [
'avatarUrl',
'avatar_url',
'avatar',
'pictureUrl',
'picture_url',
'picture',
]);
final avatar = Uri.tryParse(avatarUrl ?? '');
return AgentCreationDraft(
name: _string(json, const ['name', 'displayName', 'display_name']) ?? '',
instructions:
_string(json, const [
'instructions',
'systemPrompt',
'system_prompt',
'prompt',
]) ??
'',
avatar:
avatar != null &&
avatar.scheme == 'https' &&
avatar.host == cdn.host &&
!avatar.hasPort &&
avatar.userInfo.isEmpty
? AgentAvatarSelection.url(avatarUrl!)
: null,
// `model` may also be an object such as {"id": ..., "reasoningEffort": ...}.
modelId: model is Map<String, dynamic>
? _string(model, const ['id', 'modelId', 'model_id', 'name'])
: _string(json, const ['model', 'modelId', 'model_id']),
reasoningEffort:
_string(json, _effortKeys) ??
(model is Map<String, dynamic> ? _string(model, _effortKeys) : null),
);
}
static const _effortKeys = [
'reasoningEffort',
'reasoning_effort',
'effort',
'thinking',
];
/// The first non-blank string among [keys], trimmed.
static String? _string(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
}
static Future<List<int>> _readBounded(Stream<List<int>> stream) async {
final bytes = <int>[];
await for (final chunk in stream) {
bytes.addAll(chunk);
if (bytes.length > maxAgentCardBytes) {
throw const AgentCardException('This agent card is too large.');
}
}
return bytes;
}
}

View File

@@ -7,6 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/agents/owned_agent_directory.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/agents/remote_agents.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/widgets/feedback_snackbar.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/me/default_agent.dart';
import '../../shared/hosted/hosted_flow.dart';
@@ -21,6 +23,7 @@ import '../../shared/widgets/agent_navigation_icon.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../profile/user_profile_sheet.dart';
import 'agent_creation_draft.dart';
import 'create_agent_page.dart';
import 'agent_card.dart';
import 'remote_agent_model_control.dart';
@@ -32,16 +35,35 @@ import 'delete_agent_action.dart';
import 'agent_identity_sections.dart';
import 'bestie_agent_action.dart';
Route<Channel>? _openAgentsRoute;
/// Opens the active community's full-page Agents navigation stack.
Future<void> showAgents(BuildContext context) async {
///
/// A [draft] from a `buzz://agent/new` link opens [CreateAgentPage] pre-filled
/// once the account, registry and community gates allow it.
Future<void> showAgents(
BuildContext context, {
AgentCreationDeepLink? draft,
}) async {
final container = ProviderScope.containerOf(context);
final controller = container.read(remoteAgentControllerProvider);
unawaited(controller.run(controller.refresh));
final channel = await Navigator.of(context).push<Channel>(
MaterialPageRoute(
builder: (_) => _AgentsNavigation(controller: controller),
),
final navigator = Navigator.of(context);
// A link replaces an Agents stack that is already open instead of stacking
// a second one on top of it.
final open = _openAgentsRoute;
if (draft != null &&
open != null &&
open.isActive &&
open.navigator == navigator) {
navigator.removeRoute(open);
}
final route = MaterialPageRoute<Channel>(
builder: (_) => _AgentsNavigation(controller: controller, draft: draft),
);
_openAgentsRoute = route;
final channel = await navigator.push(route);
if (identical(_openAgentsRoute, route)) _openAgentsRoute = null;
if (channel != null && context.mounted) {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)),
@@ -50,9 +72,10 @@ Future<void> showAgents(BuildContext context) async {
}
class _AgentsNavigation extends HookConsumerWidget {
const _AgentsNavigation({required this.controller});
const _AgentsNavigation({required this.controller, this.draft});
final RemoteAgentController controller;
final AgentCreationDeepLink? draft;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -75,6 +98,7 @@ class _AgentsNavigation extends HookConsumerWidget {
color: context.colors.surface,
child: AgentsPage(
controller: controller,
draft: draft,
onClose: () => Navigator.of(context).pop(),
onSelect: (pubkey) async {
final channel = await showBuzzModalBottomSheet<Channel>(
@@ -130,8 +154,12 @@ class AgentsPage extends HookConsumerWidget {
required this.controller,
required this.onClose,
required this.onSelect,
this.draft,
super.key,
});
/// Link-supplied draft to open in [CreateAgentPage] when permitted.
final AgentCreationDeepLink? draft;
final VoidCallback onClose;
final ValueChanged<String> onSelect;
final RemoteAgentController controller;
@@ -224,6 +252,28 @@ class AgentsPage extends HookConsumerWidget {
}
}
Future<void> openCreate([AgentCreationDraft? initialDraft]) async {
if (community == null) return;
creating.value = true;
try {
await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => CreateAgentPage(
controller: controller,
community: community,
initialDraft: initialDraft,
onCreated: () {
ref.invalidate(agentDirectoryProvider);
ref.invalidate(ownedAgentDirectoryProvider);
},
),
),
);
} finally {
if (context.mounted) creating.value = false;
}
}
final VoidCallback? addAgent =
community == null || controller.busy || creating.value || signingIn
? null
@@ -232,25 +282,88 @@ class AgentsPage extends HookConsumerWidget {
await flow.reconnectAccount();
return;
}
creating.value = true;
try {
await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => CreateAgentPage(
controller: controller,
community: community,
onCreated: () {
ref.invalidate(agentDirectoryProvider);
ref.invalidate(ownedAgentDirectoryProvider);
},
),
),
);
} finally {
if (context.mounted) creating.value = false;
}
await openCreate();
};
// Open a link's draft once: sign in first if needed, then wait for the
// registry so an unfinished setup is known before anything is offered.
final draftStep = useRef(draft == null ? _DraftStep.done : _DraftStep.wait);
// Looked up during build: effects cannot read inherited widgets, and
// snack bars cannot show while a frame is building.
final messenger = ScaffoldMessenger.maybeOf(context);
void drop(String message) {
draftStep.value = _DraftStep.done;
WidgetsBinding.instance.addPostFrameCallback((_) {
messenger?.showFeedbackSnackBar(
SnackBar(content: Text(message)),
kind: ToastKind.error,
);
});
}
useEffect(
() {
final link = draft;
if (link == null || draftStep.value == _DraftStep.done) return null;
if (community == null || controller.busy || signingIn) return null;
if (!supportsRemoteAgents(community)) {
drop('Remote agents aren’t available in this community.');
return null;
}
if (controller.account.account == null) {
if (draftStep.value == _DraftStep.wait) {
draftStep.value = _DraftStep.signingIn;
unawaited(flow.reconnectAccount());
} else {
drop(
'Sign in to Builderlab to create this agent. Reopen the link.',
);
}
return null;
}
if (controller.listingFailed) {
drop('Couldn’t load your agents. Reopen the link to retry.');
return null;
}
if (controller.pending != null) {
drop(
'Finish your unfinished agent setup first, then reopen the link.',
);
return null;
}
draftStep.value = _DraftStep.done;
final client = ref.read(agentCardClientProvider)();
unawaited(() async {
// Hold Create (+) off while the card loads, like an open form does.
// Deferred past this build: hooks state can't change mid-build.
await Future<void>.value();
if (!context.mounted) return client.close();
creating.value = true;
final AgentCreationDraft resolved;
try {
resolved = await AgentCreationDraft.fetch(link, client);
} on AgentCardException catch (error) {
if (context.mounted) {
creating.value = false;
drop(error.message);
}
return;
} finally {
client.close();
}
if (context.mounted) await openCreate(resolved);
}());
return null;
},
[
controller.busy,
signingIn,
flow.stage,
community?.id,
controller.account.account?.subject,
],
);
final headerStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
@@ -391,3 +504,6 @@ class AgentsPage extends HookConsumerWidget {
);
}
}
/// Progress of opening a link's draft: waiting on gates, in sign-in, or done.
enum _DraftStep { wait, signingIn, done }

View File

@@ -11,6 +11,7 @@ import '../../shared/agents/remote_agents.dart';
import '../../shared/agents/agent_avatar_catalog.dart';
import '../../shared/relay/relay.dart';
import 'agent_avatar_picker.dart';
import 'agent_creation_draft.dart';
import '../../shared/community/community.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/theme/theme.dart';
@@ -28,8 +29,13 @@ class CreateAgentPage extends HookConsumerWidget {
required this.controller,
required this.community,
required this.onCreated,
this.initialDraft,
super.key,
});
/// Pre-fills a fresh setup (for example from a link). Ignored when an
/// unfinished setup is pending: that must be resumed as saved.
final AgentCreationDraft? initialDraft;
final RemoteAgentController controller;
final Community community;
final VoidCallback onCreated;
@@ -48,23 +54,48 @@ class CreateAgentPage extends HookConsumerWidget {
() => controller.account.account?.subject,
const [],
);
final draft = controller.pending == null ? initialDraft : null;
final model = useState(controller.pending?['model_id'] as String?);
final effort = useState(controller.pending?['reasoning_effort'] as String?);
// Draft model hints apply once, and only if the live catalog offers them.
final hintsApplied = useRef(false);
useEffect(() {
if (draft == null ||
hintsApplied.value ||
catalog.connectionState != ConnectionState.done) {
return null;
}
hintsApplied.value = true;
final match = catalog.data
?.where((entry) => entry.id == draft.modelId)
.firstOrNull;
if (match == null) return null;
model.value = match.id;
effort.value = match.efforts.contains(draft.reasoningEffort)
? draft.reasoningEffort
: null;
return null;
}, [catalog.connectionState]);
final name = useTextEditingController(
text: controller.pending?['name'] as String? ?? '',
text: controller.pending?['name'] as String? ?? draft?.name ?? '',
);
final instructions = useTextEditingController(
text: controller.pending?['instructions'] as String? ?? '',
text:
controller.pending?['instructions'] as String? ??
draft?.instructions ??
'',
);
useListenable(name);
final error = useState<String?>(null);
final avatarHeroTag = useMemoized(Object.new);
final editingAvatar = useState(false);
final avatar = useState(
AgentAvatarSelection.url(
controller.pending?['picture'] as String? ??
agentAvatarCatalog[Random().nextInt(agentAvatarCatalog.length)].url,
),
draft?.avatar ??
AgentAvatarSelection.url(
controller.pending?['picture'] as String? ??
agentAvatarCatalog[Random().nextInt(agentAvatarCatalog.length)]
.url,
),
);
final preparing = useState(false);
final busy = controller.busy || preparing.value;

View File

@@ -4,6 +4,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/agents/agents_surface.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import '../../shared/relay/relay.dart';
@@ -89,6 +90,12 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
void _maybeDispatch(BuzzDeepLink? link) {
if (link == null || _preparingInvite || _handlingOnboarding) return;
if (link is AgentCreationDeepLink) {
// Held until signed in and the community is hydrated.
if (!widget.dispatchMessageLinks) return;
unawaited(_dispatchAgentCreation(link));
return;
}
if (widget.onboardingHandler != null &&
(link is InviteDeepLink ||
link is NostrBindDeepLink ||
@@ -112,6 +119,41 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
_dispatchNavigableLink(link);
}
Future<void> _dispatchAgentCreation(AgentCreationDeepLink link) async {
_handlingOnboarding = true;
try {
final handler = ref.read(agentCreationSurfaceProvider);
if (handler == null) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(
content: Text('Agent creation is unavailable in this build.'),
),
kind: ToastKind.error,
);
} else {
await handler(context, link);
}
} catch (error) {
debugPrint('deep-link: agent draft failed: $error');
if (mounted) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(
content: Text('Couldn’t open this agent draft. Reopen the link.'),
),
kind: ToastKind.error,
);
}
} finally {
if (mounted) {
if (ref.read(pendingDeepLinkProvider) == link) {
ref.read(pendingDeepLinkProvider.notifier).consume();
}
_handlingOnboarding = false;
_maybeDispatch(ref.read(pendingDeepLinkProvider));
}
}
}
Future<void> _dispatchOnboarding(BuzzDeepLink link) async {
_handlingOnboarding = true;
var consumed = false;

View File

@@ -1,10 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'features/age_gate/age_signal_push_bootstrap.dart';
import 'features/agents/agents_page.dart';
import 'features/invites/invite_join_provider.dart';
import 'shared/agents/agents_surface.dart';
import 'shared/push/push_bridge.dart';
import 'shared/relay/relay_closed_policy.dart';
import 'shared/theme/theme_provider.dart';
@@ -33,6 +37,9 @@ ProviderScope buildRootProviderScope({
// everything else.
retry: relayProviderRetry,
overrides: [
agentCreationSurfaceProvider.overrideWithValue(
(context, link) async => unawaited(showAgents(context, draft: link)),
),
savedPrefsProvider.overrideWithValue(prefs),
inviteJoinRecoveryProvider.overrideWith(
(ref) =>

View File

@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../deeplink/deep_link.dart';
/// Opens the editable agent-creation draft for a `buzz://agent/new` link.
///
/// Supplied by the app composition root so `features/channels` can route the
/// link without importing `features/agents`. Null when unavailable.
final agentCreationSurfaceProvider =
Provider<Future<void> Function(BuildContext, AgentCreationDeepLink)?>(
(ref) => null,
);

View File

@@ -0,0 +1,45 @@
part of 'deep_link.dart';
/// `buzz://agent/new?card=<id>`: open an editable agent draft for a published
/// Builderlab agent card. The QR on a card leads to its share page, whose
/// "Add to Buzz" button opens this link. Opening it never creates an agent.
///
/// The link carries only the card id. The draft itself comes from the card's
/// `agent.json` on this build's avatars CDN, so instructions can be any length
/// while the printed QR stays small.
class AgentCreationDeepLink extends BuzzDeepLink {
/// Creates a link for the published card [cardId].
const AgentCreationDeepLink(this.cardId);
/// Published card id, a plain URL-safe token such as `av_…`.
final String cardId;
@override
bool operator ==(Object other) =>
other is AgentCreationDeepLink && cardId == other.cardId;
@override
int get hashCode => cardId.hashCode;
@override
String toString() => 'AgentCreationDeepLink($cardId)';
}
final _cardId = RegExp(r'^[A-Za-z0-9_-]{1,64}$');
/// Parses `buzz://agent/new?card=<id>`; anything else is not this link.
AgentCreationDeepLink? parseAgentCreationDeepLink(Uri uri) {
if (uri.scheme != 'buzz' ||
uri.host != 'agent' ||
uri.path != '/new' ||
uri.hasPort ||
uri.userInfo.isNotEmpty ||
uri.hasFragment) {
return null;
}
final params = uri.queryParametersAll;
final card = params['card'];
if (params.length != 1 || card == null || card.length != 1) return null;
if (!_cardId.hasMatch(card.single)) return null;
return AgentCreationDeepLink(card.single);
}

View File

@@ -10,6 +10,8 @@ library;
import '../relay/relay_validation.dart';
import '../hosted/hosted_environment.dart';
part 'agent_creation_link.dart';
/// A parsed deep link supported by the app.
sealed class BuzzDeepLink {
const BuzzDeepLink();
@@ -297,6 +299,7 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) {
/// Parse any supported Buzz deep link.
BuzzDeepLink? parseBuzzDeepLink(Uri uri) =>
parseAgentCreationDeepLink(uri) ??
parseNostrBindDeepLink(uri) ??
parseAddCommunityDeepLink(uri) ??
parseInviteDeepLink(uri) ??

View File

@@ -10,6 +10,14 @@ abstract final class HostedEnvironment {
'https://app.builderlabstaging.xyz',
};
/// Public CDN serving each published Builderlab agent card's
/// `<id>/agent.json` and images, for this build's account environment.
static Uri get avatarsCdnOrigin => Uri.parse(switch (origin.host) {
'app.builderlab.xyz' => 'https://d2z0pbxrxbsiz5.cloudfront.net',
'app.builderlabstaging.xyz' => 'https://d10hx6rb3fdt3l.cloudfront.net',
_ => throw StateError('Unsupported hosted account environment'),
});
/// Shortens hostname fallbacks for display without changing relay identity.
static String communityDisplayName(String name, String relayUrl) {
final relay = Uri.tryParse(relayUrl);

View File

@@ -0,0 +1,217 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/agents/agent_creation_draft.dart';
import 'package:buzz/features/agents/agents_page.dart';
import 'package:buzz/features/agents/create_agent_page.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/deep_link_dispatcher.dart';
import 'package:buzz/shared/agents/agents_surface.dart';
import 'package:buzz/shared/agents/remote_agents.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:buzz/shared/widgets/preset_agent_avatar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'agents_harness.dart';
class _NoChannels extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => const [];
}
const _cdn = 'https://d2z0pbxrxbsiz5.cloudfront.net';
String _link(String card) => 'buzz://agent/new?card=$card';
/// Published `agent.json` files on the avatars CDN, by card id.
final _cards = <String, Map<String, Object?>>{
'av_full': {
'v': 1,
'id': 'av_full',
'name': 'Scout',
'avatarUrl': '$_cdn/av_full/r1/avatar.png',
'instructions': 'Reply concisely.',
'model': 'test-model',
'reasoningEffort': 'high',
},
// A card as published today: no instructions or model.
'av_v1': {
'v': 1,
'id': 'av_v1',
'collection': 'gloopie',
'name': 'Owl',
'avatarUrl': '$_cdn/av_v1/r1/avatar.png',
'model_id': 'retired-model',
},
'av_third': {'name': 'Third'},
'av_fourth': {'name': 'Fourth'},
};
/// Fakes only the CDN: unknown ids get S3's 403.
final _cdnRequests = <Uri>[];
http.Client _cdnClient() => MockClient((request) async {
_cdnRequests.add(request.url);
final match = RegExp(r'^/([^/]+)/agent\.json$').firstMatch(request.url.path);
final card = request.url.origin == _cdn ? _cards[match?.group(1)] : null;
return card == null
? http.Response('<Error>AccessDenied</Error>', 403)
: http.Response(jsonEncode(card), 200);
});
Finder _field(String label) => find.descendant(
of: find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == label,
),
matching: find.byType(TextField),
);
void main() {
testWidgets(
'a buzz://agent/new?card= link opens an editable draft, never creates',
(tester) async {
final h = await AgentsHarness.create();
if (!supportsRemoteAgents(h.community)) return;
final links = StreamController<Uri>();
PendingDeepLinkNotifier.debugUriStreamOverride = links.stream;
addTearDown(() async {
PendingDeepLinkNotifier.debugUriStreamOverride = null;
await links.close();
});
_cdnRequests.clear();
await h.open(
tester,
directory: const [],
overrides: [
channelsProvider.overrideWith(_NoChannels.new),
agentCardClientProvider.overrideWithValue(_cdnClient),
// Same wiring as buildRootProviderScope.
agentCreationSurfaceProvider.overrideWithValue(
(context, link) async =>
unawaited(showAgents(context, draft: link)),
),
],
home: const DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
);
Future<void> receive(String url) async {
links.add(Uri.parse(url));
await tester.pumpAndSettle();
}
// A malformed link, or the retired inline form, opens nothing.
await receive('buzz://agent/new?card=../x');
await receive('buzz://agent/new#e30');
expect(find.byType(AgentsPage), findsNothing);
expect(_cdnRequests, isEmpty);
// A card lands on Agents, then a form prefilled from its agent.json,
// fetched from this build's CDN.
await receive(_link('av_full'));
expect(_cdnRequests.single.toString(), '$_cdn/av_full/agent.json');
expect(find.byType(CreateAgentPage), findsOneWidget);
expect(find.text('Scout'), findsOneWidget);
expect(find.text('Reply concisely.'), findsOneWidget);
expect(find.text('Test model'), findsOneWidget);
expect(find.text('high'), findsOneWidget);
expect(
tester
.widget<PresetAgentAvatar>(find.byType(PresetAgentAvatar))
.imageUrl,
'$_cdn/av_full/r1/avatar.png',
);
expect(h.registry.calls, isNot(contains('register-agent')));
// Everything stays editable, then Create submits the edited values.
await tester.enterText(_field('Name'), 'Scout Two');
await tester.enterText(_field('Instructions'), 'Be formal.');
await tester.ensureVisible(find.text('Thinking'));
await tester.tap(find.text('Thinking'));
await tester.pumpAndSettle();
await tester.tap(find.text('low').last);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create'));
await tester.pumpAndSettle();
expect(h.controller.submittedName, 'Scout Two');
expect(h.controller.submittedInstructions, 'Be formal.');
final registered = h.registry.bodies['/v3/beekeeper/register-agent']!;
expect(registered['agent_name'], 'Scout Two');
expect(registered['agent_picture_url'], '$_cdn/av_full/r1/avatar.png');
expect(
h.registry.bodies['/v3/beekeeper/update-agent'],
containsPair('model_id', 'test-model'),
);
expect(
h.registry.bodies['/v3/beekeeper/update-agent'],
containsPair('reasoning_effort', 'low'),
);
expect(find.byType(CreateAgentPage), findsNothing);
await tester.tap(find.byTooltip('Close agents'));
await tester.pumpAndSettle();
// A card that is gone shows why and opens no form.
await receive(_link('av_missing'));
expect(find.byType(CreateAgentPage), findsNothing);
expect(find.text('This agent card isn’t available.'), findsOneWidget);
await tester.tap(find.byTooltip('Close agents'));
await tester.pumpAndSettle();
// A card as published today (no instructions, a model the catalog
// lacks, under another key): the draft opens with empty instructions
// and the model left for the user, so Create stays disabled.
await receive(_link('av_v1'));
expect(find.byType(CreateAgentPage), findsOneWidget);
expect(find.text('Owl'), findsOneWidget);
expect(
tester.widget<TextField>(_field('Instructions')).controller!.text,
isEmpty,
);
expect(find.text('Retired model'), findsNothing);
expect(find.text('Choose'), findsNWidgets(2));
expect(
tester
.widget<IconButton>(find.widgetWithIcon(IconButton, Icons.check))
.onPressed,
isNull,
);
// Pick a model by hand and fail registration to leave a setup unfinished.
h.registry.loseRegistration = true;
await tester.ensureVisible(find.text('Model'));
await tester.tap(find.text('Model'));
await tester.pumpAndSettle();
await tester.tap(find.text('Test model').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Thinking'));
await tester.pumpAndSettle();
await tester.tap(find.text('high').last);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create'));
await tester.pumpAndSettle();
expect(h.controller.pending, isNotNull);
expect(
h.registry.bodies['/v3/beekeeper/register-agent']!['agent_picture_url'],
'$_cdn/av_v1/r1/avatar.png',
);
await tester.tap(find.byTooltip('Cancel'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close agents'));
await tester.pumpAndSettle();
// With an unfinished setup, a new link must not open a draft.
await receive(_link('av_third'));
expect(find.byType(CreateAgentPage), findsNothing);
expect(find.byType(AgentsPage), findsOneWidget);
expect(
find.textContaining('Finish your unfinished agent setup'),
findsOne,
);
// A link that arrives while Agents is open replaces it, not stacks.
await receive(_link('av_fourth'));
expect(find.byType(AgentsPage, skipOffstage: false), findsOneWidget);
},
);
}

View File

@@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart' show Override;
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/remote_agents_fixture.dart';
@@ -196,6 +197,7 @@ class AgentsHarness {
WidgetTester tester, {
required List<String> directory,
Widget? home,
List<Override> overrides = const [],
}) async {
registered = [...directory, oldAppAgentKeys.public];
final prefs = await SharedPreferences.getInstance();
@@ -207,6 +209,7 @@ class AgentsHarness {
await tester.pumpWidget(
ProviderScope(
overrides: [
...overrides,
ownedAgentDirectoryProvider.overrideWith(
(ref) =>
loadOwnedAgentDirectory(fakeOwner.public, relay.fetchHistory),

View File

@@ -0,0 +1,172 @@
import 'package:buzz/features/agents/agent_creation_draft.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('buzz://agent/new links', () {
const accepted = {
'card id': (
'buzz://agent/new?card=av_MioTAG6dm7q3Zcm0UV2j8A',
'av_MioTAG6dm7q3Zcm0UV2j8A',
),
'any URL-safe token': (
'buzz://agent/new?card=abc-DEF_123',
'abc-DEF_123',
),
};
accepted.forEach((label, c) {
test(label, () {
expect(parseBuzzDeepLink(Uri.parse(c.$1)), AgentCreationDeepLink(c.$2));
});
});
const rejected = {
'no card': 'buzz://agent/new',
'empty card': 'buzz://agent/new?card=',
'path in card': 'buzz://agent/new?card=..%2Fx',
'host in card': 'buzz://agent/new?card=evil.example%2Fx',
'card too long':
'buzz://agent/new?card=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'two cards': 'buzz://agent/new?card=a&card=b',
'extra parameter': 'buzz://agent/new?card=a&auto=true',
'inline draft (retired format)': 'buzz://agent/new#e30',
'card plus fragment': 'buzz://agent/new?card=a#e30',
'wrong path': 'buzz://agent/other?card=a',
'wrong host': 'buzz://agents/new?card=a',
'https share page': 'https://app.builderlab.xyz/a/av_x',
};
rejected.forEach((label, uri) {
test(label, () {
expect(
parseBuzzDeepLink(Uri.parse(uri)),
isNot(isA<AgentCreationDeepLink>()),
);
});
});
});
group('agent.json', () {
final cdn = Uri.parse('https://cdn.example');
const avatar = 'https://cdn.example/av_1/rev/avatar.png';
final cases =
<
String,
({
Map<String, dynamic> json,
String name,
String instructions,
String? avatar,
String? model,
String? effort,
})
>{
'published v1 card: name and avatar only': (
json: {
'v': 1,
'id': 'av_1',
'collection': 'gloopie',
'name': 'Nitrox',
'number': 100,
'avatarUrl': avatar,
'cardUrl': 'https://cdn.example/av_1/rev/card.png',
'shareUrl': 'https://app.builderlab.xyz/a/av_1',
},
name: 'Nitrox',
instructions: '',
avatar: avatar,
model: null,
effort: null,
),
'canonical keys': (
json: {
'name': ' Scout ',
'avatarUrl': avatar,
'instructions': 'Reply concisely.',
'model': 'sonnet-5-5',
'reasoningEffort': 'high',
},
name: 'Scout',
instructions: 'Reply concisely.',
avatar: avatar,
model: 'sonnet-5-5',
effort: 'high',
),
'snake_case and synonyms': (
json: {
'display_name': 'Scout',
'avatar_url': avatar,
'system_prompt': 'Be kind.',
'model_id': 'opus-5-5',
'reasoning_effort': 'low',
},
name: 'Scout',
instructions: 'Be kind.',
avatar: avatar,
model: 'opus-5-5',
effort: 'low',
),
'model object': (
json: {
'name': 'Scout',
'prompt': 'Hi.',
'model': {'id': 'opus-5-5', 'effort': 'medium'},
},
name: 'Scout',
instructions: 'Hi.',
avatar: null,
model: 'opus-5-5',
effort: 'medium',
),
'wrong types and blanks are left for the user': (
json: {
'name': 7,
'instructions': ['x'],
'model': ' ',
'reasoningEffort': true,
},
name: '',
instructions: '',
avatar: null,
model: null,
effort: null,
),
'avatar on another host is not used': (
json: {'name': 'Scout', 'avatarUrl': 'https://evil.example/a.png'},
name: 'Scout',
instructions: '',
avatar: null,
model: null,
effort: null,
),
'http avatar is not used': (
json: {'name': 'Scout', 'avatarUrl': 'http://cdn.example/a.png'},
name: 'Scout',
instructions: '',
avatar: null,
model: null,
effort: null,
),
'avatar with credentials is not used': (
json: {
'name': 'Scout',
'avatarUrl': 'https://u:p@cdn.example/a.png',
},
name: 'Scout',
instructions: '',
avatar: null,
model: null,
effort: null,
),
};
cases.forEach((label, c) {
test(label, () {
final draft = AgentCreationDraft.parseAgentCard(c.json, cdn);
expect(draft.name, c.name);
expect(draft.instructions, c.instructions);
expect(draft.avatar?.url, c.avatar);
expect(draft.modelId, c.model);
expect(draft.reasoningEffort, c.effort);
});
});
});
}