From 638f5ecac79b3bb8e5b0241f6076fb6f7554f350 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 2 Nov 2026 09:47:11 -0800 Subject: [PATCH] fix(mobile): keep open Agents work and saved setups safe from card links - A card link arriving while a form or sheet is open in Agents shows a snackbar instead of replacing the open work. - Reload the registry for the signed-in account before the unfinished setup check, so a link that needed sign-in still respects a saved setup. - Unexpected card responses show a retryable message. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- mobile/README.md | 20 +++- .../features/agents/agent_creation_draft.dart | 6 +- mobile/lib/features/agents/agents_page.dart | 44 ++++++- .../channels/deep_link_dispatcher.dart | 2 +- mobile/lib/shared/agents/remote_agents.dart | 4 + .../agent_creation_link_journey_test.dart | 17 +++ .../agent_creation_link_sign_in_test.dart | 111 ++++++++++++++++++ .../deeplink/agent_creation_link_test.dart | 4 +- 8 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 mobile/test/features/agents/agent_creation_link_sign_in_test.dart diff --git a/mobile/README.md b/mobile/README.md index 5cda9e0e1e..0834948104 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -38,11 +38,12 @@ agent. A Builderlab agent card's QR leads to its share page 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 instructions can be -any length while the QR stays small. +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.", @@ -55,16 +56,23 @@ any length while the QR stays small. 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`, are ignored. The card id is opaque to the app. + `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, and is dropped with a message if - an unfinished agent setup exists (resume or discard that first) or the card - can't be loaded. + 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`. diff --git a/mobile/lib/features/agents/agent_creation_draft.dart b/mobile/lib/features/agents/agent_creation_draft.dart index db3e66a6d1..1a0028c73f 100644 --- a/mobile/lib/features/agents/agent_creation_draft.dart +++ b/mobile/lib/features/agents/agent_creation_draft.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; @@ -82,8 +83,9 @@ class AgentCreationDraft { throw const AgentCardException('This agent card isn’t available.'); } if (response.statusCode != 200) { - throw AgentCardException( - 'Couldn’t load this agent card (HTTP ${response.statusCode}).', + debugPrint('agents: agent card HTTP ${response.statusCode}'); + throw const AgentCardException( + 'Couldn’t load this agent card. Try again later.', ); } final Object? json; diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index 9a9af8db68..05c49066aa 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -37,6 +37,10 @@ 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. /// /// A [draft] from a `buzz://agent/new` link opens [CreateAgentPage] pre-filled @@ -47,23 +51,39 @@ Future showAgents( }) async { final container = ProviderScope.containerOf(context); final controller = container.read(remoteAgentControllerProvider); - unawaited(controller.run(controller.refresh)); 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. + // 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 route = MaterialPageRoute( builder: (_) => _AgentsNavigation(controller: controller, draft: draft), ); _openAgentsRoute = route; final channel = await navigator.push(route); - if (identical(_openAgentsRoute, route)) _openAgentsRoute = null; + if (identical(_openAgentsRoute, route)) { + _openAgentsRoute = null; + _openAgentsNavigator = null; + } if (channel != null && context.mounted) { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)), @@ -84,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) { @@ -288,6 +311,7 @@ class AgentsPage extends HookConsumerWidget { // 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); @@ -333,6 +357,18 @@ class AgentsPage extends HookConsumerWidget { } 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; @@ -345,7 +381,6 @@ class AgentsPage extends HookConsumerWidget { } draftStep.value = _DraftStep.done; // The checks above hold for this account; the fetch can take minutes. - final subject = controller.account.account?.subject; final client = ref.read(agentCardClientProvider)(); unawaited(() async { // Hold Create (+) off while the card loads, like an open form does. @@ -393,6 +428,7 @@ class AgentsPage extends HookConsumerWidget { flow.stage, community?.id, controller.account.account?.subject, + controller.loadedForCurrentAccount, ], ); diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 33056fab53..74f7f8037f 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -138,7 +138,7 @@ class _DeepLinkDispatcherState extends ConsumerState { if (mounted) { ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( const SnackBar( - content: Text('Couldn’t open this agent draft. Reopen the link.'), + content: Text('Couldn’t open this agent card. Reopen the link.'), ), kind: ToastKind.error, ); diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 0abfd4ba81..fd212fa5ad 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -179,6 +179,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/test/features/agents/agent_creation_link_journey_test.dart b/mobile/test/features/agents/agent_creation_link_journey_test.dart index f5bc7c17b9..e4e861db6a 100644 --- a/mobile/test/features/agents/agent_creation_link_journey_test.dart +++ b/mobile/test/features/agents/agent_creation_link_journey_test.dart @@ -142,6 +142,23 @@ void main() { // 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(); 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/shared/deeplink/agent_creation_link_test.dart b/mobile/test/shared/deeplink/agent_creation_link_test.dart index f9d0b66f6f..f13a5af77b 100644 --- a/mobile/test/shared/deeplink/agent_creation_link_test.dart +++ b/mobile/test/shared/deeplink/agent_creation_link_test.dart @@ -215,11 +215,11 @@ void main() { '404': ((_, _) async => respond(404), 'This agent card isn’t available.'), 'redirect': ( (_, _) async => respond(302), - 'Couldn’t load this agent card (HTTP 302).', + 'Couldn’t load this agent card. Try again later.', ), '500': ( (_, _) async => respond(500), - 'Couldn’t load this agent card (HTTP 500).', + 'Couldn’t load this agent card. Try again later.', ), 'network error': ( (_, _) async => throw http.ClientException('offline'),