mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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>
142 lines
4.8 KiB
Dart
142 lines
4.8 KiB
Dart
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'),
|
||
);
|
||
}
|
||
}
|