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 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-02 09:47:11 -08:00
parent a22a7b43ed
commit 638f5ecac7
8 changed files with 193 additions and 15 deletions

View File

@@ -38,11 +38,12 @@ agent. A Builderlab agent card's QR leads to its share page
The app fetches `<avatars CDN>/<id>/agent.json` from the CDN for this build's The app fetches `<avatars CDN>/<id>/agent.json` from the CDN for this build's
Builderlab environment (`HostedEnvironment.avatarsCdnOrigin`), never from a Builderlab environment (`HostedEnvironment.avatarsCdnOrigin`), never from a
host named by the link. The link carries only the id, so instructions can be host named by the link. The link carries only the id, so the QR stays small
any length while the QR stays small. however long the instructions are.
```json ```json
{ {
"id": "<id>",
"name": "Scout", "name": "Scout",
"avatarUrl": "https://<avatars CDN>/<id>/<revision>/avatar.png", "avatarUrl": "https://<avatars CDN>/<id>/<revision>/avatar.png",
"instructions": "Reply concisely.", "instructions": "Reply concisely.",
@@ -55,16 +56,23 @@ any length while the QR stays small.
required. `instructions`, `model` and `reasoningEffort` are optional and left required. `instructions`, `model` and `reasoningEffort` are optional and left
for the user when absent. A missing required field or a known field of the 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 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`): - 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 it is aborted after 20 seconds without a byte or 5 minutes overall, and a card
over 2 MB is rejected. over 2 MB is rejected.
- `model` and `reasoningEffort` are pre-selected only if the live model catalog - `model` and `reasoningEffort` are pre-selected only if the live model catalog
offers them (the effort only for that model). offers them (the effort only for that model).
- The link waits for sign-in and community load. It needs Builderlab login and a - 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 hosted community that supports remote agents. It reloads the signed-in
an unfinished agent setup exists (resume or discard that first) or the card account's agents first, and is dropped with a message if an unfinished agent
can't be loaded. 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 The parser is `lib/shared/deeplink/agent_creation_link.dart`; the card reader
is `lib/features/agents/agent_creation_draft.dart`. is `lib/features/agents/agent_creation_draft.dart`.

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -82,8 +83,9 @@ class AgentCreationDraft {
throw const AgentCardException('This agent card isn’t available.'); throw const AgentCardException('This agent card isn’t available.');
} }
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw AgentCardException( debugPrint('agents: agent card HTTP ${response.statusCode}');
'Couldn’t load this agent card (HTTP ${response.statusCode}).', throw const AgentCardException(
'Couldn’t load this agent card. Try again later.',
); );
} }
final Object? json; final Object? json;

View File

@@ -37,6 +37,10 @@ import 'bestie_agent_action.dart';
Route<Channel>? _openAgentsRoute; 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. /// Opens the active community's full-page Agents navigation stack.
/// ///
/// A [draft] from a `buzz://agent/new` link opens [CreateAgentPage] pre-filled /// A [draft] from a `buzz://agent/new` link opens [CreateAgentPage] pre-filled
@@ -47,23 +51,39 @@ Future<void> showAgents(
}) async { }) async {
final container = ProviderScope.containerOf(context); final container = ProviderScope.containerOf(context);
final controller = container.read(remoteAgentControllerProvider); final controller = container.read(remoteAgentControllerProvider);
unawaited(controller.run(controller.refresh));
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
// A link replaces an Agents stack that is already open instead of stacking // 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; final open = _openAgentsRoute;
if (draft != null && if (draft != null &&
open != null && open != null &&
open.isActive && open.isActive &&
open.navigator == navigator) { 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); navigator.removeRoute(open);
} }
unawaited(controller.run(controller.refresh));
final route = MaterialPageRoute<Channel>( final route = MaterialPageRoute<Channel>(
builder: (_) => _AgentsNavigation(controller: controller, draft: draft), builder: (_) => _AgentsNavigation(controller: controller, draft: draft),
); );
_openAgentsRoute = route; _openAgentsRoute = route;
final channel = await navigator.push(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) { if (channel != null && context.mounted) {
await Navigator.of(context).push<void>( await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)), MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)),
@@ -84,6 +104,9 @@ class _AgentsNavigation extends HookConsumerWidget {
final navigator = useMemoized(() => GlobalKey<NavigatorState>(), [ final navigator = useMemoized(() => GlobalKey<NavigatorState>(), [
communityId, communityId,
]); ]);
if (identical(ModalRoute.of(context), _openAgentsRoute)) {
_openAgentsNavigator = navigator;
}
return NavigatorPopHandler<Channel>( return NavigatorPopHandler<Channel>(
onPopWithResult: (result) { onPopWithResult: (result) {
if (navigator.currentState?.canPop() ?? false) { 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 // 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. // registry so an unfinished setup is known before anything is offered.
final draftStep = useRef(draft == null ? _DraftStep.done : _DraftStep.wait); final draftStep = useRef(draft == null ? _DraftStep.done : _DraftStep.wait);
final draftRefreshFor = useRef<String?>(null);
// Looked up during build: effects cannot read inherited widgets, and // Looked up during build: effects cannot read inherited widgets, and
// snack bars cannot show while a frame is building. // snack bars cannot show while a frame is building.
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
@@ -333,6 +357,18 @@ class AgentsPage extends HookConsumerWidget {
} }
return null; 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) { if (controller.listingFailed) {
drop('Couldn’t load your agents. Reopen the link to retry.'); drop('Couldn’t load your agents. Reopen the link to retry.');
return null; return null;
@@ -345,7 +381,6 @@ class AgentsPage extends HookConsumerWidget {
} }
draftStep.value = _DraftStep.done; draftStep.value = _DraftStep.done;
// The checks above hold for this account; the fetch can take minutes. // The checks above hold for this account; the fetch can take minutes.
final subject = controller.account.account?.subject;
final client = ref.read(agentCardClientProvider)(); final client = ref.read(agentCardClientProvider)();
unawaited(() async { unawaited(() async {
// Hold Create (+) off while the card loads, like an open form does. // Hold Create (+) off while the card loads, like an open form does.
@@ -393,6 +428,7 @@ class AgentsPage extends HookConsumerWidget {
flow.stage, flow.stage,
community?.id, community?.id,
controller.account.account?.subject, controller.account.account?.subject,
controller.loadedForCurrentAccount,
], ],
); );

View File

@@ -138,7 +138,7 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
if (mounted) { if (mounted) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar( 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, kind: ToastKind.error,
); );

View File

@@ -179,6 +179,10 @@ class RemoteAgentController extends ChangeNotifier {
return _keys.forSubject(subject); 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. /// Empty until the signed-in account's own registry has loaded.
Set<String> get activeAgents => _scope == null || _scope != _currentScope Set<String> get activeAgents => _scope == null || _scope != _currentScope
? const {} ? const {}

View File

@@ -142,6 +142,23 @@ void main() {
// Everything stays editable, then Create submits the edited values. // Everything stays editable, then Create submits the edited values.
await tester.enterText(_field('Name'), 'Scout Two'); await tester.enterText(_field('Name'), 'Scout Two');
await tester.enterText(_field('Instructions'), 'Be formal.'); 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.ensureVisible(find.text('Thinking'));
await tester.tap(find.text('Thinking')); await tester.tap(find.text('Thinking'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();

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

@@ -215,11 +215,11 @@ void main() {
'404': ((_, _) async => respond(404), 'This agent card isn’t available.'), '404': ((_, _) async => respond(404), 'This agent card isn’t available.'),
'redirect': ( 'redirect': (
(_, _) async => respond(302), (_, _) async => respond(302),
'Couldn’t load this agent card (HTTP 302).', 'Couldn’t load this agent card. Try again later.',
), ),
'500': ( '500': (
(_, _) async => respond(500), (_, _) async => respond(500),
'Couldn’t load this agent card (HTTP 500).', 'Couldn’t load this agent card. Try again later.',
), ),
'network error': ( 'network error': (
(_, _) async => throw http.ClientException('offline'), (_, _) async => throw http.ClientException('offline'),