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
|
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`.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.'),
|
'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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user