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

Builderlab agent cards (printed for the event, and generated at the
kiosk) carry a QR to a share page, `app.builderlab.xyz/a/<id>`. Its
**Add to Buzz** button opens `buzz://agent/new?card=<id>`, which no Buzz
client handled until now. This PR makes mobile handle it.

### Behavior

- Opening the link shows the Agents page, then a Create agent form
prefilled from the card. Everything stays editable, and nothing is
created until the user taps Create.
- The app fetches the card's `agent.json` from the avatars CDN for this
build's Builderlab environment (production or staging). The link carries
only the card id, never a host, so the QR stays small no matter how long
a card's instructions get.
- `agent.json` is validated against the card schema. `id` (matching the
link), `name` and `avatarUrl` (an https URL) are required.
`instructions`, `model` and `reasoningEffort` are optional (published
cards don't have them yet) and left for the user when absent. A missing
required field or a wrongly typed known field rejects the card; any
other key is ignored whatever its type, including `v` and `description`
(Buzz agents have no description to prefill), so Builderlab can add
fields without breaking released apps. The model and effort are
preselected only if the live catalog offers them. The card id is opaque:
any non-empty id is accepted and percent-encoded into the URL.
- The fetch uses the same limits as image downloads, now shared in
`bounded_fetch.dart`: aborted after 20 s without a byte or 5 min
overall, and rejected over 2 MB. `MediaImageProvider` now calls the same
helper, with no behavior change.
- The link waits for sign-in, then reloads the registry for the
signed-in account before anything else. It's dropped with a message if
the card is unavailable (403/404), invalid, can't be loaded (any other
response), or an agent setup is unfinished, including a setup saved
before a sign-in the link triggered.
- A link that arrives while Agents shows only its list replaces that
page instead of stacking a second one. If a form or sheet is open there,
the link is dropped with "Finish or close what’s open in Agents, then
reopen the link." and the open work is kept.

### Demo

Simulator (iPhone 17 Pro, debug build of this branch plus the sign-in
fix from [#8914](https://github.com/block/buzz/pull/8914) and the hidden
debug banner from [#8915](https://github.com/block/buzz/pull/8915),
signed in to Builderlab, remote-agent-pilot), recorded in one take with
idle time trimmed: the card's share page in Safari, Add to Buzz, the
prefilled Create agent form, a model, thinking level and instructions,
Create, then a message and the agent's reply.


https://github.com/user-attachments/assets/e66aae17-884a-4e68-a1b8-ecb40562be1d

### Validation

Added tests:
- Table tests for the link shape (only `?card=<id>` with an opaque id;
the inline `#` form, extra parameters and dot segments are rejected) and
for reading `agent.json` (a published card, a card with instructions and
model defaults, unknown keys of any type ignored, including `v`,
`description`, arrays, objects and null, and invalid cards: missing or
mismatched required fields, wrong types, and non-https or relative
avatars).
- Table tests for fetching: the request goes to this build's CDN without
following redirects, with the id percent-encoded as one path segment;
403/404, a redirect, a 500, a network error, declared and streamed
oversize bodies, non-JSON and a JSON array each give a specific message.
The image loader's existing stall, deadline, cap and cancel tests now
cover the shared helper.
- A journey test from link to Create against a fake CDN: prefill and
edit, then submit; an unavailable card; a failed model catalog whose
Retry then preselects the card's model; a Builderlab account change
while the card loads, which drops the draft; a v1 card with no
instructions and an unknown model; an unfinished setup; a link arriving
while a Create form with edits is open, which keeps the form and its
edits; and a link arriving while Agents is open. Disabling the https or
id check makes the table test fail, and removing either the account
recheck or the loaded-catalog check fails the journey. The journey runs
through the production root scope (removing its link handler fails it)
and runs in CI's configured-authentication-domains step.

Full mobile check and test suite pass locally.

Live data: a real staging card's `agent.json`
(`av_MioTAG6dm7q3Zcm0UV2j8A`) has the v1 shape the table test uses, and
an unknown id returns 403 from the CDN.

Live run on an iPhone SE (build of 63ed54c749, Builderlab sign-in
enabled, remote-agent-pilot, signed in as builderlab@tombrow.com) with a
real production card,
[ToastyMacbeth](https://app.builderlab.xyz/a/av_lrmRlJR6ylrQKvJkoLWCOQ)
(a v1 card with no instructions and an extra `description` key):
- Opening `buzz://agent/new?card=av_lrmRlJR6ylrQKvJkoLWCOQ` from a cold
start opens Create agent prefilled with the card's name and avatar.
- After choosing a model and adding instructions, Create adds the agent
with the card avatar, and it replies in a DM.
- An unknown card id shows "This agent card isn’t available."

| Prefilled | Created | Reply | Unknown card |
| --- | --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--01-prefilled.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--02-created.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--03-reply.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--04-unavailable.png"
width="200"> |

Before this PR, the app had no handler for `buzz://agent/new`, so the
link did nothing.

A separate widget test covers a link that needs Builderlab sign-in
first: once sign-in lands, a setup saved for that account blocks the
card, which is never fetched. Removing the registry reload fails it, as
does removing the busy-Agents guard for the journey step above.

### Before merge

- [x] Human live check waived under the repository owner's standing
testing exception. In its place, agent-driven full-app runs exercised
the flow end to end: the iPhone SE device run and the simulator demo
above (a real card's share page, **Add to Buzz**, the prefilled form,
Create and a reply from the agent). Limitation: those runs predate
[638f5ecac7](638f5ecac7),
whose two guards are covered by the widget tests above rather than a
live run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-02 13:00:08 -08:00
committed by GitHub
parent 61b8455a3c
commit fa395e9e80
20 changed files with 1379 additions and 112 deletions

View File

@@ -144,6 +144,7 @@ jobs:
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
--dart-define=BUZZ_AUTH_DOMAINS=buzz.xyz,builderlab.xyz,builderlabstaging.xyz
test/shared/auth/community_entry_requirements_test.dart
test/features/agents/agent_creation_link_journey_test.dart
test/features/invites/invite_join_provider_test.dart
test/features/onboarding/onboarding_entry_routes_test.dart
test/shared/hosted/hosted_environment_test.dart

View File

@@ -29,6 +29,54 @@ 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 the QR stays small
however long the instructions are.
```json
{
"id": "<id>",
"name": "Scout",
"avatarUrl": "https://<avatars CDN>/<id>/<revision>/avatar.png",
"instructions": "Reply concisely.",
"model": "<model catalog id>",
"reasoningEffort": "high"
}
```
- `id` (the link's card id), `name` and `avatarUrl` (an `https` URL) are
required. `instructions`, `model` and `reasoningEffort` are optional and left
for the user when absent. A missing required field or a known field of the
wrong type makes the card invalid, and no form opens. Other keys, including
`v` and `description`, are ignored whatever their type. The card id is opaque
to the app.
- Prefilled values are only a draft: Create applies its usual rules, so a name
outside 1 to 64 letters, numbers, spaces, dots, hyphens or underscores, or
instructions over 20,000 characters, must be edited before Create succeeds.
- The fetch shares the image loader's limits (`lib/shared/utils/bounded_fetch.dart`):
it is aborted after 20 seconds without a byte or 5 minutes overall, and a card
over 2 MB is rejected.
- `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. It reloads the signed-in
account's agents first, and is dropped with a message if an unfinished agent
setup exists (resume or discard that first) or the card can't be loaded.
- A link that arrives while Agents is open replaces it only when the list is
all that's showing. With a form, profile or dialog open, the link is dropped
with a message so unfinished work is kept.
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,141 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
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 '../../shared/utils/bounded_fetch.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 = 2 * 1024 * 1024;
/// 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;
// The id is opaque; pathSegments percent-encodes anything path-unsafe.
final url = cdn.replace(pathSegments: [link.cardId, 'agent.json']);
final BoundedResponse response;
try {
response = await boundedGet(
client,
url,
maxBytes: maxAgentCardBytes,
headers: const {'accept': 'application/json'},
// A redirect could lead off the CDN; treat it as unavailable.
followRedirects: false,
);
} on ResponseTooLargeException {
throw const AgentCardException('This agent card is too large.');
} on Exception {
throw const AgentCardException(
'Couldn’t load this agent card. Check your connection and reopen the '
'link.',
);
}
if (response.statusCode == 403 || response.statusCode == 404) {
throw const AgentCardException('This agent card isn’t available.');
}
if (response.statusCode != 200) {
debugPrint('agents: agent card HTTP ${response.statusCode}');
throw const AgentCardException(
'Couldn’t load this agent card. Try again later.',
);
}
final Object? json;
try {
json = jsonDecode(utf8.decode(response.bytes));
} 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, link.cardId);
}
/// Reads a card's draft from its `agent.json`. `id` (matching [cardId]),
/// `name` and `avatarUrl` (an https URL) are required; `instructions`,
/// `model` and `reasoningEffort` are optional and left for the user when
/// absent or blank. A missing required field, or a known field of the wrong
/// type, makes the card invalid. Any other key, including `v` and
/// `description` (agents have no description to prefill), is ignored
/// whatever its type, so Builderlab can add fields without breaking
/// released apps.
static AgentCreationDraft parseAgentCard(
Map<String, dynamic> json,
String cardId,
) {
const invalid = AgentCardException('This agent card is invalid.');
if (json['id'] != cardId) throw invalid;
final name = json['name'];
final avatarUrl = json['avatarUrl'];
if (name is! String || name.trim().isEmpty || avatarUrl is! String) {
throw invalid;
}
final avatar = Uri.tryParse(avatarUrl);
if (avatar == null || avatar.scheme != 'https' || avatar.host.isEmpty) {
throw invalid;
}
String? optional(String key) {
final value = json[key];
if (value != null && value is! String) throw invalid;
final text = (value as String?)?.trim();
return text == null || text.isEmpty ? null : text;
}
return AgentCreationDraft(
name: name.trim(),
instructions: optional('instructions') ?? '',
avatar: AgentAvatarSelection.url(avatarUrl),
modelId: optional('model'),
reasoningEffort: optional('reasoningEffort'),
);
}
}

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,55 @@ import 'delete_agent_action.dart';
import 'agent_identity_sections.dart';
import 'bestie_agent_action.dart';
Route<Channel>? _openAgentsRoute;
/// The open Agents stack's nested navigator, so a link can tell whether
/// anything is open above its list.
GlobalKey<NavigatorState>? _openAgentsNavigator;
/// 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);
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, but only while it shows just its list: a form,
// profile or dialog above it may hold work the user hasn't finished.
final open = _openAgentsRoute;
if (draft != null &&
open != null &&
open.isActive &&
open.navigator == navigator) {
if (!open.isCurrent ||
(_openAgentsNavigator?.currentState?.canPop() ?? false)) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(
content: Text(
'Finish or close what’s open in Agents, then reopen the link.',
),
),
kind: ToastKind.error,
);
return;
}
navigator.removeRoute(open);
}
unawaited(controller.run(controller.refresh));
final channel = await Navigator.of(context).push<Channel>(
MaterialPageRoute(
builder: (_) => _AgentsNavigation(controller: controller),
),
final route = MaterialPageRoute<Channel>(
builder: (_) => _AgentsNavigation(controller: controller, draft: draft),
);
_openAgentsRoute = route;
final channel = await navigator.push(route);
if (identical(_openAgentsRoute, route)) {
_openAgentsRoute = null;
_openAgentsNavigator = null;
}
if (channel != null && context.mounted) {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)),
@@ -50,9 +92,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) {
@@ -61,6 +104,9 @@ class _AgentsNavigation extends HookConsumerWidget {
final navigator = useMemoized(() => GlobalKey<NavigatorState>(), [
communityId,
]);
if (identical(ModalRoute.of(context), _openAgentsRoute)) {
_openAgentsNavigator = navigator;
}
return NavigatorPopHandler<Channel>(
onPopWithResult: (result) {
if (navigator.currentState?.canPop() ?? false) {
@@ -75,6 +121,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>(
@@ -135,8 +182,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;
@@ -231,6 +282,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
@@ -239,25 +312,133 @@ 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);
final draftRefreshFor = useRef<String?>(null);
// 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;
// Sign-in counts as attempted only once it is seen running, so a
// rerun before reconnectAccount reaches its sign-in stage still waits.
if (signingIn && draftStep.value == _DraftStep.signInRequested) {
draftStep.value = _DraftStep.signingIn;
}
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.signInRequested;
unawaited(
flow.reconnectAccount().catchError((Object error) {
debugPrint('agents: link sign-in failed: $error');
if (context.mounted) {
drop('Couldn’t start Builderlab sign-in. Reopen the link.');
}
}),
);
} else if (draftStep.value == _DraftStep.signingIn) {
drop(
'Sign in to Builderlab to create this agent. Reopen the link.',
);
}
return null;
}
// The setup check below needs this account's registry, which a
// sign-in started by the link doesn't reload by itself.
final subject = controller.account.account!.subject;
if (!controller.loadedForCurrentAccount) {
if (draftRefreshFor.value != subject) {
draftRefreshFor.value = subject;
unawaited(controller.run(controller.refresh));
} else {
drop('Couldn’t load your agents. Reopen the link to retry.');
}
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;
// The checks above hold for this account; the fetch can take minutes.
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);
} catch (error) {
if (error is! AgentCardException) {
debugPrint('agents: agent card failed: $error');
}
if (context.mounted) {
creating.value = false;
drop(
error is AgentCardException
? error.message
: 'Couldn’t open this agent card.',
);
}
return;
} finally {
client.close();
}
if (!context.mounted) return;
if (controller.account.account?.subject != subject) {
creating.value = false;
drop('Your Builderlab account changed. Reopen the link.');
} else if (controller.pending != null) {
creating.value = false;
drop(
'Finish your unfinished agent setup first, then reopen the link.',
);
} else {
await openCreate(resolved);
}
}());
return null;
},
[
controller.busy,
signingIn,
flow.stage,
community?.id,
controller.account.account?.subject,
controller.loadedForCurrentAccount,
],
);
final headerStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
@@ -412,3 +593,7 @@ class AgentsPage extends HookConsumerWidget {
);
}
}
/// Progress of opening a link's draft: waiting on gates, sign-in requested,
/// sign-in seen running, or done.
enum _DraftStep { wait, signInRequested, 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,51 @@ 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 to the first catalog that loads (after a failed
// load, the one Retry fetches), and only if it offers them.
final hintsApplied = useRef(false);
useEffect(() {
final entries = catalog.data;
if (draft == null ||
hintsApplied.value ||
catalog.connectionState != ConnectionState.done ||
entries == null) {
return null;
}
hintsApplied.value = true;
final match = entries
.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 card. 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

@@ -196,6 +196,10 @@ class RemoteAgentController extends ChangeNotifier {
return _keys.forSubject(subject);
}
/// Whether the signed-in account's saved state (its binding and any
/// unfinished setup) has been read since it signed in.
bool get loadedForCurrentAccount => _scope != null && _scope == _currentScope;
/// Empty until the signed-in account's own registry has loaded.
Set<String> get activeAgents => _scope == null || _scope != _currentScope
? const {}

View File

@@ -0,0 +1,44 @@
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, opaque to the app (today `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)';
}
/// 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;
// The id is opaque, but it becomes one path segment of the card's URL.
if (const {'', '.', '..'}.contains(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

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
@@ -9,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import '../hosted/hosted_relay_auth.dart';
import '../utils/bounded_fetch.dart';
import 'media_auth.dart';
/// Shared, keep-alive HTTP client for media fetches. One client per container
@@ -73,8 +73,8 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
this.onBytesLoaded,
this.isThumbnailFallback = false,
this.scale = 1.0,
this.stallTimeout = const Duration(seconds: 20),
this.maxFetchDuration = const Duration(minutes: 5),
this.stallTimeout = defaultStallTimeout,
this.maxFetchDuration = defaultMaxFetchDuration,
});
/// Maximum encoded image body retained before decoding (20 MiB).
@@ -195,63 +195,19 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
}
final uri = Uri.parse(url);
final http.Response response;
var wasAbandoned = false;
final BoundedResponse response;
try {
final stop = Completer<void>();
Timer? stall;
void restartStallTimer() {
stall?.cancel();
stall = Timer(stallTimeout, () {
if (!stop.isCompleted) stop.complete();
});
}
restartStallTimer();
final deadline = Timer(maxFetchDuration, () {
if (!stop.isCompleted) stop.complete();
});
unawaited(
abort.then((_) {
if (stop.isCompleted) return;
wasAbandoned = true;
stop.complete();
}),
response = await boundedGet(
client,
uri,
maxBytes: maxResponseBytes,
headers: auth.headersFor(url),
stallTimeout: stallTimeout,
maxFetchDuration: maxFetchDuration,
cancel: abort,
);
StreamIterator<List<int>>? body;
try {
final request = http.AbortableRequest(
'GET',
uri,
abortTrigger: stop.future,
)..headers.addAll(auth.headersFor(url));
final streamed = await client.send(request);
restartStallTimer();
final iterator = body = StreamIterator(streamed.stream);
final bytes = BytesBuilder(copy: false);
// An error body is never shown, and reading a slow one could hold
// this load for the whole fetch cap before the fallback or
// Retry-After applies. Leave it unread; finally cancels the stream.
while (streamed.statusCode == 200 && await iterator.moveNext()) {
restartStallTimer();
final chunk = iterator.current;
if (chunk.length > maxResponseBytes - bytes.length) {
throw const FormatException('Image response too large');
}
bytes.add(chunk);
}
response = http.Response.bytes(
bytes.takeBytes(),
streamed.statusCode,
headers: streamed.headers,
);
} finally {
stall?.cancel();
deadline.cancel();
if (!stop.isCompleted) stop.complete();
unawaited(body?.cancel());
}
} catch (_) {
} catch (error) {
final wasAbandoned = error is FetchCancelledException;
// Nobody wanted the image any more; the URL itself did not fail.
if (!wasAbandoned) {
_cooldownUntil[_cooldownKey] = debugNow().add(_defaultCooldown);
@@ -273,7 +229,7 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
uri: uri,
);
}
final bytes = response.bodyBytes;
final bytes = response.bytes;
if (bytes.isEmpty) {
_cooldownUntil[_cooldownKey] = debugNow().add(_defaultCooldown);
throw NetworkImageLoadException(statusCode: 200, uri: uri);
@@ -281,7 +237,7 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
return bytes;
}
Duration _cooldownFor(http.Response response) {
Duration _cooldownFor(BoundedResponse response) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
if (retryAfter != null && retryAfter > 0) {
final requested = Duration(seconds: retryAfter);

View File

@@ -0,0 +1,120 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
/// Longest wait for response headers or the next body chunk. A slow link that
/// keeps delivering bytes finishes; one that stops is aborted.
const defaultStallTimeout = Duration(seconds: 20);
/// Longest a fetch may take even while bytes keep arriving, so a server that
/// trickles bytes cannot hold a connection indefinitely.
const defaultMaxFetchDuration = Duration(minutes: 5);
/// A GET response read by [boundedGet]. [bytes] is empty unless the status
/// was 200: an error body is never read.
class BoundedResponse {
const BoundedResponse(this.statusCode, this.headers, this.bytes);
final int statusCode;
final Map<String, String> headers;
final Uint8List bytes;
}
/// The 200 body of a [boundedGet] exceeded its `maxBytes`.
class ResponseTooLargeException implements Exception {
const ResponseTooLargeException(this.uri);
final Uri uri;
@override
String toString() => 'ResponseTooLargeException: $uri';
}
/// The caller's `cancel` future completed before [boundedGet] finished.
class FetchCancelledException implements Exception {
const FetchCancelledException(this.uri);
final Uri uri;
@override
String toString() => 'FetchCancelledException: $uri';
}
/// GETs [uri] without closing [client], aborting the request when no headers
/// or body bytes arrive for [stallTimeout], when it runs past
/// [maxFetchDuration], or when [cancel] completes. A 200 body larger than
/// [maxBytes], declared or streamed, throws [ResponseTooLargeException]; other statuses return
/// without reading the body. A stall or deadline throws the client's abort
/// error ([http.RequestAbortedException]); a cancel throws
/// [FetchCancelledException].
Future<BoundedResponse> boundedGet(
http.Client client,
Uri uri, {
required int maxBytes,
Map<String, String> headers = const {},
bool followRedirects = true,
Duration stallTimeout = defaultStallTimeout,
Duration maxFetchDuration = defaultMaxFetchDuration,
Future<void>? cancel,
}) async {
final stop = Completer<void>();
var cancelled = false;
Timer? stall;
void restartStallTimer() {
stall?.cancel();
stall = Timer(stallTimeout, () {
if (!stop.isCompleted) stop.complete();
});
}
restartStallTimer();
final deadline = Timer(maxFetchDuration, () {
if (!stop.isCompleted) stop.complete();
});
unawaited(
cancel?.then((_) {
if (stop.isCompleted) return;
cancelled = true;
stop.complete();
}),
);
StreamIterator<List<int>>? body;
try {
final request = http.AbortableRequest('GET', uri, abortTrigger: stop.future)
..headers.addAll(headers)
..followRedirects = followRedirects;
final streamed = await client.send(request);
restartStallTimer();
if (streamed.statusCode == 200 &&
(streamed.contentLength ?? 0) > maxBytes) {
unawaited(streamed.stream.listen(null).cancel());
throw ResponseTooLargeException(uri);
}
final iterator = body = StreamIterator(streamed.stream);
final bytes = BytesBuilder(copy: false);
// An error body is never shown, and reading a slow one could hold the
// caller for the whole fetch cap. Leave it unread; finally cancels it.
while (streamed.statusCode == 200 && await iterator.moveNext()) {
restartStallTimer();
final chunk = iterator.current;
if (chunk.length > maxBytes - bytes.length) {
throw ResponseTooLargeException(uri);
}
bytes.add(chunk);
}
return BoundedResponse(
streamed.statusCode,
streamed.headers,
bytes.takeBytes(),
);
} catch (_) {
if (cancelled) throw FetchCancelledException(uri);
rethrow;
} finally {
stall?.cancel();
deadline.cancel();
if (!stop.isCompleted) stop.complete();
unawaited(body?.cancel());
}
}

View File

@@ -0,0 +1,296 @@
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/main.dart';
import 'package:buzz/shared/hosted/hosted_environment.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/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 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/remote_agents_fixture.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': 'retired-model',
},
'av_third': {
'v': 1,
'id': 'av_third',
'name': 'Third',
'avatarUrl': '$_cdn/av_third/r1/avatar.png',
},
'av_fourth': {
'v': 1,
'id': 'av_fourth',
'name': 'Fourth',
'avatarUrl': '$_cdn/av_fourth/r1/avatar.png',
},
};
/// Fakes only the CDN: unknown ids get S3's 403. [_cdnGate] holds responses.
final _cdnRequests = <Uri>[];
Completer<void>? _cdnGate;
http.Client _cdnClient() => MockClient((request) async {
_cdnRequests.add(request.url);
await _cdnGate?.future;
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();
expect(supportsRemoteAgents(h.community), isTrue);
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),
// The production root wiring from link dispatch to showAgents.
...buildRootProviderScope(
prefs: await SharedPreferences.getInstance(),
child: const SizedBox(),
).overrides,
],
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=..');
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.');
// A link arriving now must not discard the open form or its edits.
await receive(_link('av_v1'));
expect(find.byType(CreateAgentPage), findsOneWidget);
expect(find.text('Scout Two'), findsOneWidget);
expect(
find.text(
'Finish or close what’s open in Agents, then reopen the link.',
),
findsOneWidget,
);
expect(_cdnRequests, hasLength(1));
ScaffoldMessenger.of(
tester.element(find.byType(CreateAgentPage)),
).removeCurrentSnackBar();
await tester.pumpAndSettle();
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();
// If the model catalog fails to load, the card's model applies once
// Retry loads it.
h.registry.failCatalog = true;
await receive(_link('av_full'));
expect(find.byType(CreateAgentPage), findsOneWidget);
expect(find.text('Test model'), findsNothing);
h.registry.failCatalog = false;
await tester.ensureVisible(find.text('Retry models'));
await tester.tap(find.text('Retry models'));
await tester.pumpAndSettle();
expect(find.text('Test model'), findsOneWidget);
expect(find.text('high'), findsOneWidget);
await tester.tap(find.byTooltip('Cancel'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close agents'));
await tester.pumpAndSettle();
// If the Builderlab account changes while the card loads, the draft is
// dropped rather than opened for an account that wasn't checked.
for (final messenger in tester.stateList<ScaffoldMessengerState>(
find.byType(ScaffoldMessenger),
)) {
messenger.clearSnackBars();
}
final gate = _cdnGate = Completer<void>();
links.add(Uri.parse(_link('av_third')));
for (var i = 0; i < 5; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
expect(find.byType(AgentsPage), findsOneWidget);
h.registry.subject = 'another-owner';
_cdnGate = null;
gate.complete();
await tester.pumpAndSettle();
expect(find.byType(CreateAgentPage), findsNothing);
expect(
find.text('Your Builderlab account changed. Reopen the link.'),
findsOneWidget,
);
h.registry.subject = fakeSubject;
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close agents'));
await tester.pumpAndSettle();
// A card as published today (no instructions, a model the catalog
// lacks): 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);
},
// Remote agents exist only in Builderlab builds; CI runs this file in its
// configured-authentication-domains step.
skip: !HostedEnvironment.enabled,
);
}

View File

@@ -0,0 +1,111 @@
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/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/hosted/hosted_flow_provider.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 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/remote_agents_fixture.dart';
import 'agents_harness.dart';
/// Fakes only the browser sign-in. Like the real flow, completion clears
/// `reconnecting` in the same synchronous step that reports `complete`.
class _SignInFlow extends ChangeNotifier implements HostedFlow {
@override
HostedFlowStage stage = HostedFlowStage.idle;
@override
bool reconnecting = false;
int starts = 0;
@override
Future<void> reconnectAccount({bool changeAccount = false}) async {
starts++;
reconnecting = true;
stage = HostedFlowStage.signIn;
notifyListeners();
}
void complete() {
stage = HostedFlowStage.complete;
reconnecting = false;
notifyListeners();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
testWidgets(
'a link that needs sign-in still respects a saved unfinished setup',
(tester) async {
final h = await AgentsHarness.create();
// The login expired, but this account saved an unfinished setup.
h.registry.signedIn = false;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'remote-agents:${HostedEnvironment.origin.host}:$fakeSubject:pending',
jsonEncode({
'name': 'Saved',
'instructions': '',
'community': h.community.relayUrl,
'owner': fakeOwner.public,
'request': 'saved-request',
}),
);
final flow = _SignInFlow();
var cardFetches = 0;
await h.open(
tester,
directory: const [],
overrides: [
hostedFlowProvider.overrideWithValue(flow),
agentCardClientProvider.overrideWithValue(
() => MockClient((_) async {
cardFetches++;
return http.Response('{}', 404);
}),
),
],
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showAgents(
context,
draft: const AgentCreationDeepLink('av_card'),
),
child: const Text('Open link'),
),
),
),
);
await tester.tap(find.text('Open link'));
await tester.pumpAndSettle();
expect(flow.starts, 1);
// Sign-in lands. The registry is reloaded for this account before the
// card is considered, so the saved setup blocks it.
h.registry.signedIn = true;
flow.complete();
await tester.pumpAndSettle();
expect(find.byType(CreateAgentPage), findsNothing);
expect(cardFetches, 0);
expect(
find.text(
'Finish your unfinished agent setup first, then reopen the link.',
),
findsOneWidget,
);
expect(h.controller.pending?['name'], 'Saved');
},
skip: !HostedEnvironment.enabled,
);
}

View File

@@ -55,6 +55,7 @@ class FakeRegistry extends BuilderlabAccountClient {
},
];
Completer<void>? catalogGate;
bool failCatalog = false;
bool failListing = false;
bool rejectRegistration = false;
bool loseRegistration = false;
@@ -96,6 +97,7 @@ class FakeRegistry extends BuilderlabAccountClient {
switch (path) {
case '/v3/beekeeper/get-model-catalog':
await catalogGate?.future;
if (failCatalog) throw StateError('Catalog unavailable.');
return {'models': models};
case '/v3/beekeeper/list-agents':
if (failListing) throw StateError('Registry unavailable.');

View File

@@ -0,0 +1,251 @@
import 'dart:convert';
import 'package:buzz/features/agents/agent_creation_draft.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
group('buzz://agent/new links', () {
const accepted = {
'card id': (
'buzz://agent/new?card=av_MioTAG6dm7q3Zcm0UV2j8A',
'av_MioTAG6dm7q3Zcm0UV2j8A',
),
'any opaque id': (
'buzz://agent/new?card=card.7~x%2Fy%20z',
'card.7~x/y z',
),
};
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=',
'dot segment': 'buzz://agent/new?card=..',
'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', () {
const avatar = 'https://cdn.example/av_1/rev/avatar.png';
// A card as kgoose publishes it today (schema v1).
const published = <String, dynamic>{
'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',
'description': 'Deep-water scout.',
};
test('a published card opens with name and avatar only', () {
final draft = AgentCreationDraft.parseAgentCard(published, 'av_1');
expect(draft.name, 'Nitrox');
expect(draft.instructions, '');
expect(draft.avatar?.url, avatar);
expect(draft.modelId, isNull);
expect(draft.reasoningEffort, isNull);
});
test('a card carries instructions and model defaults', () {
final draft = AgentCreationDraft.parseAgentCard({
...published,
'v': 99,
'name': ' Scout ',
'avatarUrl': 'https://images.example/scout.png',
'instructions': 'Reply concisely.',
'model': 'sonnet-5-5',
'reasoningEffort': 'high',
// Fields Buzz doesn't know are ignored whatever their type, so
// Builderlab can add them without breaking older apps.
'description': {'unexpected': 'shape'},
'tags': ['ocean', 7],
'rarity': null,
'nested': {
'deep': [true, 1.5],
},
'system_prompt': 'ignored',
'model_id': 'ignored',
}, 'av_1');
expect(draft.name, 'Scout');
expect(draft.avatar?.url, 'https://images.example/scout.png');
expect(draft.instructions, 'Reply concisely.');
expect(draft.modelId, 'sonnet-5-5');
expect(draft.reasoningEffort, 'high');
});
test('blank optional fields are left for the user', () {
final draft = AgentCreationDraft.parseAgentCard({
...published,
'instructions': '',
'model': ' ',
}, 'av_1');
expect(draft.instructions, '');
expect(draft.modelId, isNull);
});
final invalid = <String, Map<String, dynamic>>{
'missing id': {...published}..remove('id'),
'id of another card': {...published, 'id': 'av_2'},
'missing name': {...published}..remove('name'),
'blank name': {...published, 'name': ' '},
'non-string name': {...published, 'name': 7},
'missing avatar': {...published}..remove('avatarUrl'),
'non-string avatar': {...published, 'avatarUrl': 7},
'http avatar': {...published, 'avatarUrl': 'http://cdn.example/a.png'},
'relative avatar': {...published, 'avatarUrl': '/av_1/a.png'},
'non-string instructions': {
...published,
'instructions': ['x'],
},
'non-string model': {...published, 'model': 5},
'non-string effort': {
...published,
'model': 'sonnet-5-5',
'reasoningEffort': true,
},
};
invalid.forEach((label, json) {
test(label, () {
expect(
() => AgentCreationDraft.parseAgentCard(json, 'av_1'),
throwsA(
isA<AgentCardException>().having(
(e) => e.message,
'message',
'This agent card is invalid.',
),
),
);
});
});
});
group('fetching agent.json', () {
const link = AgentCreationDeepLink('av_1');
final big = 'x' * (maxAgentCardBytes + 1);
Future<Object> outcome(MockClientStreamHandler handler) async {
try {
return await AgentCreationDraft.fetch(
link,
MockClient.streaming(handler),
);
} on AgentCardException catch (error) {
return error.message;
}
}
http.StreamedResponse respond(
int status, [
List<List<int>> chunks = const [],
int? length,
]) => http.StreamedResponse(
Stream.fromIterable(chunks),
status,
contentLength: length,
);
test('requests the build CDN without following redirects', () async {
late http.BaseRequest seen;
final result = await outcome((request, _) async {
seen = request;
return respond(200, [
utf8.encode(
jsonEncode({
'v': 1,
'id': 'av_1',
'name': 'Scout',
'avatarUrl': HostedEnvironment.avatarsCdnOrigin
.replace(path: '/av_1/r/avatar.png')
.toString(),
}),
),
]);
});
expect(result, isA<AgentCreationDraft>());
expect(
seen.url,
HostedEnvironment.avatarsCdnOrigin.replace(path: '/av_1/agent.json'),
);
expect(seen.followRedirects, isFalse);
});
test('percent-encodes the opaque id as one path segment', () async {
late Uri seen;
await AgentCreationDraft.fetch(
const AgentCreationDeepLink('a/b c'),
MockClient.streaming((request, _) async {
seen = request.url;
return respond(404);
}),
).then<void>((_) {}, onError: (_) {});
expect(seen.path, '/a%2Fb%20c/agent.json');
});
final failures = <String, (MockClientStreamHandler, String)>{
'403 from S3': (
(_, _) async => respond(403),
'This agent card isn’t available.',
),
'404': ((_, _) async => respond(404), 'This agent card isn’t available.'),
'redirect': (
(_, _) async => respond(302),
'Couldn’t load this agent card. Try again later.',
),
'500': (
(_, _) async => respond(500),
'Couldn’t load this agent card. Try again later.',
),
'network error': (
(_, _) async => throw http.ClientException('offline'),
'Couldn’t load this agent card. Check your connection and reopen the '
'link.',
),
'declared length over the cap': (
(_, _) async => respond(200, const [], maxAgentCardBytes + 1),
'This agent card is too large.',
),
'streamed body over the cap': (
(_, _) async =>
respond(200, [utf8.encode('{"name":"'), utf8.encode(big)]),
'This agent card is too large.',
),
'not JSON': (
(_, _) async => respond(200, [utf8.encode('<html>')]),
'This agent card is invalid.',
),
'JSON array': (
(_, _) async => respond(200, [utf8.encode('[]')]),
'This agent card is invalid.',
),
};
failures.forEach((label, c) {
test(label, () async => expect(await outcome(c.$1), c.$2));
});
});
}

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:buzz/shared/utils/bounded_fetch.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -177,7 +178,7 @@ void main() {
final error = await _waitError(
provider.resolve(ImageConfiguration.empty),
).timeout(const Duration(seconds: 2));
expect(error, isA<FormatException>());
expect(error, isA<ResponseTooLargeException>());
expect(cancelled, isTrue);
expect(aborted, isTrue);
expect(delivered, isFalse);