diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index ba683a8a62..ad968b92b7 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -148,6 +148,8 @@ jobs: test/features/onboarding/onboarding_entry_routes_test.dart test/shared/hosted/hosted_environment_test.dart test/shared/hosted/self_hosted_entry_test.dart + test/features/agents/agents_signed_out_prompt_test.dart + test/features/agents/agents_restore_prompt_test.dart test/shared/hosted/relay_auth_isolation_test.dart test/features/onboarding/hosted_app_gate_environment_test.dart test/features/onboarding/hosted_journeys_test.dart diff --git a/mobile/lib/features/agents/agent_identity_sections.dart b/mobile/lib/features/agents/agent_identity_sections.dart index 063fcc1c60..53c0f12729 100644 --- a/mobile/lib/features/agents/agent_identity_sections.dart +++ b/mobile/lib/features/agents/agent_identity_sections.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/community/community_provider.dart'; import '../../shared/agents/remote_agents.dart'; +import '../../shared/community/community_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list_card.dart'; import 'agent_instructions.dart'; @@ -22,12 +22,15 @@ class AgentIdentitySections extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(controller); + useListenable(controller.account.subjectChanges); + final community = ref.watch(activeCommunityProvider).asData?.value; + final unknown = agentLocationUnknown(community, controller); final remote = controller.agents.any( (agent) => agent['agent_pubkey'] == pubkey, ); // Remote instructions come from the registry row, listed for the active // community's owner only (as the agents page decides). - final owner = ref.watch(activeCommunityProvider).asData?.value?.pubkey; + final owner = community?.pubkey; final row = owner != null && controller.owner == owner ? controller.agents .where((agent) => agent['agent_pubkey'] == pubkey) @@ -58,7 +61,11 @@ class AgentIdentitySections extends HookConsumerWidget { child: SizedBox( width: double.infinity, child: Text( - remote + unknown && !remote && controller.busy + ? 'Checking whether this agent runs on Builderbot…' + : unknown && !remote + ? 'Sign in to Builderlab to see whether this agent runs on Builderbot or your desktop.' + : remote ? 'This agent runs on Builderbot. Your desktop doesn’t need to be online for it to work.' : 'This agent is managed on your desktop. Keep Buzz open and your desktop online for it to work.', style: context.textTheme.bodyMedium, diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index 7715b47aab..8430e93d9d 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -97,6 +97,11 @@ class _AgentsNavigation extends HookConsumerWidget { (agent) => agent['agent_pubkey'] == pubkey, ) ? 'Builderbot agent' + : agentLocationUnknown( + ref.read(activeCommunityProvider).asData?.value, + controller, + ) + ? null : 'Manage on desktop', primaryActionsSpacing: Grid.md, contextualActions: (_) => @@ -205,6 +210,8 @@ class AgentsPage extends HookConsumerWidget { }); final profiles = ref.watch(userCacheProvider); final creating = useState(false); + final needsSignIn = agentLocationUnknown(community, controller); + final signInNeeded = builderlabSignInNeeded(community, controller); final failed = registered.hasError || ownedDirectory.hasError || @@ -323,7 +330,19 @@ class AgentsPage extends HookConsumerWidget { child: const Text('Retry'), ), ), - if (!loading && !failed && keys.isEmpty) + if (signInNeeded) + ListTile( + title: const Text( + 'Sign in to Builderlab to manage remote agents', + ), + trailing: TextButton( + onPressed: signingIn + ? null + : () => flow.reconnectAccount(), + child: const Text('Sign in'), + ), + ), + if (!loading && !failed && !needsSignIn && keys.isEmpty) const Padding( padding: EdgeInsets.all(Grid.sm), child: Text( @@ -379,6 +398,8 @@ class AgentsPage extends HookConsumerWidget { ? null : remote != null ? 'Builderbot agent' + : needsSignIn + ? null : 'Manage on desktop', ); }, diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 0abfd4ba81..00adee54ef 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -32,6 +32,23 @@ bool supportsRemoteAgents(Community? community) => community.pubkey != null && HostedEnvironment.supportsRelay(Uri.parse(community.relayUrl)); +/// Whether a signed-out Builderlab account hides where this community's agents +/// run: remote agents are unlistable until the user signs in, so an agent that +/// is not in the registry is not necessarily desktop-managed. +bool agentLocationUnknown( + Community? community, + RemoteAgentController controller, +) => supportsRemoteAgents(community) && controller.account.account == null; + +/// Whether to ask the user to sign in to Builderlab: the agent location is +/// unknown and no load is in flight. A cold start's saved-session restore +/// runs inside the controller's refresh, so prompting then would start a +/// needless browser sign-in for a session that is about to be adopted. +bool builderlabSignInNeeded( + Community? community, + RemoteAgentController controller, +) => agentLocationUnknown(community, controller) && !controller.busy; + /// Restores a saved Builderlab session once, so a cold start with a stored /// session shows remote agents without waiting for another sign-in path. final builderlabRestoreProvider = FutureProvider((ref) async { diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart index 7ef98dc972..0f9b21bf43 100644 --- a/mobile/test/features/agents/agents_harness.dart +++ b/mobile/test/features/agents/agents_harness.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/remote_agents_fixture.dart'; @@ -196,6 +197,7 @@ class AgentsHarness { WidgetTester tester, { required List directory, Widget? home, + List overrides = const [], }) async { registered = [...directory, oldAppAgentKeys.public]; final prefs = await SharedPreferences.getInstance(); @@ -226,6 +228,7 @@ class AgentsHarness { AgentDirectoryEntry(pubkey: pubkey), ], ), + ...overrides, ], child: MaterialApp( theme: AppTheme.light(), diff --git a/mobile/test/features/agents/agents_restore_prompt_test.dart b/mobile/test/features/agents/agents_restore_prompt_test.dart new file mode 100644 index 0000000000..955d91b445 --- /dev/null +++ b/mobile/test/features/agents/agents_restore_prompt_test.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'agents_harness.dart'; + +void main() { + testWidgets( + 'Agents does not ask to sign in while a saved session is restoring', + (tester) async { + final h = await AgentsHarness.create(); + h.registry.signedIn = false; + final restore = Completer(); + h.registry.restoreGate = restore; + await h.open(tester, directory: []); + + const prompt = 'Sign in to Builderlab to manage remote agents'; + expect(h.controller.busy, isTrue); + expect(find.text(prompt), findsNothing); + expect(find.widgetWithText(TextButton, 'Sign in'), findsNothing); + expect(find.text('You haven’t added any agents yet.'), findsNothing); + + // No saved session after all: now the prompt is the way forward. + restore.complete(); + await tester.pumpAndSettle(); + expect(h.controller.busy, isFalse); + expect(find.text(prompt), findsOneWidget); + // Remote agents may exist but are unlistable until sign-in, so an + // empty listing must not claim there are none. + expect(find.text('You haven’t added any agents yet.'), findsNothing); + }, + skip: !HostedEnvironment.enabled, + ); +} diff --git a/mobile/test/features/agents/agents_signed_out_prompt_test.dart b/mobile/test/features/agents/agents_signed_out_prompt_test.dart new file mode 100644 index 0000000000..f662cc067c --- /dev/null +++ b/mobile/test/features/agents/agents_signed_out_prompt_test.dart @@ -0,0 +1,76 @@ +import 'package:buzz/features/profile/user_profile_sheet.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.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:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'agents_harness.dart'; + +class _Flow extends HostedFlow { + _Flow(AgentsHarness h) + : super( + account: h.registry, + store: AccountIdentityStore(), + browser: BrowserAuth(), + identity: HostedIdentityService( + account: h.registry, + store: AccountIdentityStore(), + ), + enterCommunity: (_, _, _, _) async {}, + ); + int reconnects = 0; + + @override + Future reconnectAccount({bool changeAccount = false}) async { + reconnects++; + } +} + +void main() { + testWidgets( + 'signed out of Builderlab, Agents asks to sign in instead of claiming desktop', + (tester) async { + final h = await AgentsHarness.create(); + final flow = _Flow(h); + addTearDown(() async { + flow.dispose(); + await flow.browser.dispose(); + }); + h.registry.signedIn = false; + await h.open( + tester, + directory: [existingDesktopKeys.public], + overrides: [hostedFlowProvider.overrideWithValue(flow)], + ); + + const prompt = 'Sign in to Builderlab to manage remote agents'; + expect(find.text(prompt), findsOneWidget); + expect(find.byKey(ValueKey(existingDesktopKeys.public)), findsOneWidget); + expect(find.text('Manage on desktop'), findsNothing); + + // The Management section cannot claim the agent runs on the desktop. + await tester.tap(find.byKey(ValueKey(existingDesktopKeys.public))); + await tester.pumpAndSettle(); + expect(find.byType(UserProfileSheet), findsOneWidget); + expect(find.textContaining('Keep Buzz open'), findsNothing); + expect(find.textContaining('Sign in to Builderlab to see'), findsOne); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Sign in')); + await tester.pump(); + expect(flow.reconnects, 1); + + // Signing in restores the desktop labelling for unregistered agents. + h.registry.signedIn = true; + await tester.pumpAndSettle(); + expect(find.text(prompt), findsNothing); + expect(find.text('Manage on desktop'), findsOneWidget); + }, + skip: !HostedEnvironment.enabled, + ); +} diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart index 25b70d5324..40e1a3174c 100644 --- a/mobile/test/helpers/remote_agents_fixture.dart +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -68,8 +68,16 @@ class FakeRegistry extends BuilderlabAccountClient { BuilderlabAccount? get account => signedIn ? BuilderlabAccount(subject: subject, expiresAt: DateTime.utc(2030)) : null; + + /// Holds [restore] open so a test can observe a cold-start restoration. + Completer? restoreGate; + @override - Future restore() async => account; + Future restore() async { + await restoreGate?.future; + return account; + } + @override Future> currentIdentity() async => { 'identity': {'pubkey_hex': bound},