mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
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:
@@ -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
|
||||
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": "<id>",
|
||||
"name": "Scout",
|
||||
"avatarUrl": "https://<avatars CDN>/<id>/<revision>/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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,6 +37,10 @@ import 'bestie_agent_action.dart';
|
||||
|
||||
Route<Channel>? _openAgentsRoute;
|
||||
|
||||
/// The open Agents stack's nested navigator, so a link can tell whether
|
||||
/// anything is open above its list.
|
||||
GlobalKey<NavigatorState>? _openAgentsNavigator;
|
||||
|
||||
/// Opens the active community's full-page Agents navigation stack.
|
||||
///
|
||||
/// A [draft] from a `buzz://agent/new` link opens [CreateAgentPage] pre-filled
|
||||
@@ -47,23 +51,39 @@ Future<void> 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<Channel>(
|
||||
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<void>(
|
||||
MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)),
|
||||
@@ -84,6 +104,9 @@ class _AgentsNavigation extends HookConsumerWidget {
|
||||
final navigator = useMemoized(() => GlobalKey<NavigatorState>(), [
|
||||
communityId,
|
||||
]);
|
||||
if (identical(ModalRoute.of(context), _openAgentsRoute)) {
|
||||
_openAgentsNavigator = navigator;
|
||||
}
|
||||
return NavigatorPopHandler<Channel>(
|
||||
onPopWithResult: (result) {
|
||||
if (navigator.currentState?.canPop() ?? false) {
|
||||
@@ -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<String?>(null);
|
||||
// Looked up during build: effects cannot read inherited widgets, and
|
||||
// snack bars cannot show while a frame is building.
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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<String> get activeAgents => _scope == null || _scope != _currentScope
|
||||
? const {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user