diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index ad968b92b7..8c5862b0c6 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -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 diff --git a/mobile/README.md b/mobile/README.md index 8dfae1a7cd..0834948104 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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=` 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/`), whose **Add to Buzz** button opens this link. + +The app fetches `//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": "", + "name": "Scout", + "avatarUrl": "https://///avatar.png", + "instructions": "Reply concisely.", + "model": "", + "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 diff --git a/mobile/lib/features/agents/agent_avatar_picker.dart b/mobile/lib/features/agents/agent_avatar_picker.dart index fc192a10d2..7ecbd29eb3 100644 --- a/mobile/lib/features/agents/agent_avatar_picker.dart +++ b/mobile/lib/features/agents/agent_avatar_picker.dart @@ -57,6 +57,26 @@ class AgentAvatarSelection { } } +/// Renders an emoji on a solid [color] to the 512px PNG that creation uploads. +Future 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 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, ); diff --git a/mobile/lib/features/agents/agent_creation_draft.dart b/mobile/lib/features/agents/agent_creation_draft.dart new file mode 100644 index 0000000000..1a0028c73f --- /dev/null +++ b/mobile/lib/features/agents/agent_creation_draft.dart @@ -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( + (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 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) { + 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 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'), + ); + } +} diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index 8430e93d9d..d57a4594bc 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -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? _openAgentsRoute; + +/// The open Agents stack's nested navigator, so a link can tell whether +/// anything is open above its list. +GlobalKey? _openAgentsNavigator; + /// Opens the active community's full-page Agents navigation stack. -Future 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 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( - MaterialPageRoute( - builder: (_) => _AgentsNavigation(controller: controller), - ), + final route = MaterialPageRoute( + 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( MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)), @@ -50,9 +92,10 @@ Future 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(), [ communityId, ]); + if (identical(ModalRoute.of(context), _openAgentsRoute)) { + _openAgentsNavigator = navigator; + } return NavigatorPopHandler( 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( @@ -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 onSelect; final RemoteAgentController controller; @@ -231,6 +282,28 @@ class AgentsPage extends HookConsumerWidget { } } + Future openCreate([AgentCreationDraft? initialDraft]) async { + if (community == null) return; + creating.value = true; + try { + await Navigator.of(context).push( + 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( - 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(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.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 } diff --git a/mobile/lib/features/agents/create_agent_page.dart b/mobile/lib/features/agents/create_agent_page.dart index bb53fb2b63..d6da24592e 100644 --- a/mobile/lib/features/agents/create_agent_page.dart +++ b/mobile/lib/features/agents/create_agent_page.dart @@ -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(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; diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 7867e29c41..74f7f8037f 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -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 { 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 { _dispatchNavigableLink(link); } + Future _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 _dispatchOnboarding(BuzzDeepLink link) async { _handlingOnboarding = true; var consumed = false; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index f793b6a5d3..c771ef56b8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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) => diff --git a/mobile/lib/shared/agents/agents_surface.dart b/mobile/lib/shared/agents/agents_surface.dart new file mode 100644 index 0000000000..2b22b69b51 --- /dev/null +++ b/mobile/lib/shared/agents/agents_surface.dart @@ -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 Function(BuildContext, AgentCreationDeepLink)?>( + (ref) => null, + ); diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 00adee54ef..d3fb6ea24a 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -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 get activeAgents => _scope == null || _scope != _currentScope ? const {} diff --git a/mobile/lib/shared/deeplink/agent_creation_link.dart b/mobile/lib/shared/deeplink/agent_creation_link.dart new file mode 100644 index 0000000000..ea4f78ef61 --- /dev/null +++ b/mobile/lib/shared/deeplink/agent_creation_link.dart @@ -0,0 +1,44 @@ +part of 'deep_link.dart'; + +/// `buzz://agent/new?card=`: 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=`; 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); +} diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index b4a67b10f0..bb7825335e 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -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) ?? diff --git a/mobile/lib/shared/hosted/hosted_environment.dart b/mobile/lib/shared/hosted/hosted_environment.dart index f0979342a2..8292738342 100644 --- a/mobile/lib/shared/hosted/hosted_environment.dart +++ b/mobile/lib/shared/hosted/hosted_environment.dart @@ -10,6 +10,14 @@ abstract final class HostedEnvironment { 'https://app.builderlabstaging.xyz', }; + /// Public CDN serving each published Builderlab agent card's + /// `/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); diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 96817cf76c..43ba776486 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -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 { 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 { } final uri = Uri.parse(url); - final http.Response response; - var wasAbandoned = false; + final BoundedResponse response; try { - final stop = Completer(); - 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>? 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 { 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 { 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); diff --git a/mobile/lib/shared/utils/bounded_fetch.dart b/mobile/lib/shared/utils/bounded_fetch.dart new file mode 100644 index 0000000000..eead71b407 --- /dev/null +++ b/mobile/lib/shared/utils/bounded_fetch.dart @@ -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 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 boundedGet( + http.Client client, + Uri uri, { + required int maxBytes, + Map headers = const {}, + bool followRedirects = true, + Duration stallTimeout = defaultStallTimeout, + Duration maxFetchDuration = defaultMaxFetchDuration, + Future? cancel, +}) async { + final stop = Completer(); + 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>? 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()); + } +} diff --git a/mobile/test/features/agents/agent_creation_link_journey_test.dart b/mobile/test/features/agents/agent_creation_link_journey_test.dart new file mode 100644 index 0000000000..e4e861db6a --- /dev/null +++ b/mobile/test/features/agents/agent_creation_link_journey_test.dart @@ -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> 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 = >{ + '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 = []; +Completer? _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('AccessDenied', 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(); + 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 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(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( + find.byType(ScaffoldMessenger), + )) { + messenger.clearSnackBars(); + } + final gate = _cdnGate = Completer(); + 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(_field('Instructions')).controller!.text, + isEmpty, + ); + expect(find.text('Retired model'), findsNothing); + expect(find.text('Choose'), findsNWidgets(2)); + expect( + tester + .widget(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, + ); +} diff --git a/mobile/test/features/agents/agent_creation_link_sign_in_test.dart b/mobile/test/features/agents/agent_creation_link_sign_in_test.dart new file mode 100644 index 0000000000..266a68d1ac --- /dev/null +++ b/mobile/test/features/agents/agent_creation_link_sign_in_test.dart @@ -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 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, + ); +} diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart index 40e1a3174c..24096090a7 100644 --- a/mobile/test/helpers/remote_agents_fixture.dart +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -55,6 +55,7 @@ class FakeRegistry extends BuilderlabAccountClient { }, ]; Completer? 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.'); diff --git a/mobile/test/shared/deeplink/agent_creation_link_test.dart b/mobile/test/shared/deeplink/agent_creation_link_test.dart new file mode 100644 index 0000000000..f13a5af77b --- /dev/null +++ b/mobile/test/shared/deeplink/agent_creation_link_test.dart @@ -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()), + ); + }); + }); + }); + + group('agent.json', () { + const avatar = 'https://cdn.example/av_1/rev/avatar.png'; + // A card as kgoose publishes it today (schema v1). + const published = { + '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 = >{ + '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().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 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> 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()); + 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((_) {}, onError: (_) {}); + expect(seen.path, '/a%2Fb%20c/agent.json'); + }); + + final failures = { + '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('')]), + '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)); + }); + }); +} diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 291139f844..91dbb7d912 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -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()); + expect(error, isA()); expect(cancelled, isTrue); expect(aborted, isTrue); expect(delivered, isFalse);