From 256063f9552ffd8e2b706db6e8e8cdcf4a5fad7f Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:30:16 -0800 Subject: [PATCH] mobile: start new agents on Sonnet at medium effort The create screen opened with model and effort blank, so the easy path was picking from the full catalog, expensive models included. A fresh setup now starts on the first Sonnet model the catalog offers, at medium effort when supported. A draft link's own model still wins when offered, a resumed setup keeps its saved choice, and anything the catalog lacks is left for the user to choose. Co-authored-by: Carl Co-Authored-By: Claude Opus 5.5 Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../features/agents/agent_model_picker.dart | 24 +++++++ .../features/agents/create_agent_page.dart | 30 +++++---- .../agents/agent_model_picker_test.dart | 66 +++++++++++++++++++ .../agents/create_agent_page_test.dart | 51 ++++++++++++++ 4 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 mobile/test/features/agents/agent_model_picker_test.dart diff --git a/mobile/lib/features/agents/agent_model_picker.dart b/mobile/lib/features/agents/agent_model_picker.dart index ed65143b13..7433e78cae 100644 --- a/mobile/lib/features/agents/agent_model_picker.dart +++ b/mobile/lib/features/agents/agent_model_picker.dart @@ -117,3 +117,27 @@ bool validAgentModelSelection( (entry) => entry.id == model && entry.efforts.contains(effort), ) == true; + +/// The model and effort a new agent starts with, so the easy path is a +/// capable but affordable choice rather than a blank (or the priciest) one. +/// A draft's own model wins when the catalog offers it; otherwise the first +/// Sonnet model, at medium effort when supported. Anything the catalog +/// doesn't offer is left for the user to choose. +(String?, String?) startingAgentModel( + List catalog, { + String? draftModel, + String? draftEffort, +}) { + final drafted = catalog.where((entry) => entry.id == draftModel).firstOrNull; + if (drafted != null) { + return ( + drafted.id, + drafted.efforts.contains(draftEffort) ? draftEffort : null, + ); + } + final fallback = catalog + .where((entry) => entry.id.toLowerCase().contains('sonnet')) + .firstOrNull; + if (fallback == null) return (null, null); + return (fallback.id, fallback.efforts.contains('medium') ? 'medium' : null); +} diff --git a/mobile/lib/features/agents/create_agent_page.dart b/mobile/lib/features/agents/create_agent_page.dart index d6da24592e..01738f9fe2 100644 --- a/mobile/lib/features/agents/create_agent_page.dart +++ b/mobile/lib/features/agents/create_agent_page.dart @@ -1,7 +1,10 @@ import 'agent_avatar_hero.dart'; import 'agent_character_artwork.dart'; + import 'dart:math'; + import 'agent_model_picker.dart'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -57,26 +60,25 @@ class CreateAgentPage extends HookConsumerWidget { final draft = controller.pending == null ? initialDraft : null; final model = useState(controller.pending?['model_id'] as String?); final effort = useState(controller.pending?['reasoning_effort'] as String?); - // Draft model hints apply to the first catalog that loads (after a failed - // load, the one Retry fetches), and only if it offers them. - final hintsApplied = useRef(false); + // A fresh setup starts from the first catalog that loads (after a failed + // load, the one Retry fetches): the draft's model if offered, otherwise + // the default. A resumed setup keeps its saved choice. + final startApplied = useRef(controller.pending != null); useEffect(() { final entries = catalog.data; - if (draft == null || - hintsApplied.value || + if (startApplied.value || catalog.connectionState != ConnectionState.done || entries == null) { return null; } - hintsApplied.value = true; - final match = entries - .where((entry) => entry.id == draft.modelId) - .firstOrNull; - if (match == null) return null; - model.value = match.id; - effort.value = match.efforts.contains(draft.reasoningEffort) - ? draft.reasoningEffort - : null; + startApplied.value = true; + final (startModel, startEffort) = startingAgentModel( + entries, + draftModel: draft?.modelId, + draftEffort: draft?.reasoningEffort, + ); + model.value = startModel; + effort.value = startEffort; return null; }, [catalog.connectionState]); final name = useTextEditingController( diff --git a/mobile/test/features/agents/agent_model_picker_test.dart b/mobile/test/features/agents/agent_model_picker_test.dart new file mode 100644 index 0000000000..302d104eff --- /dev/null +++ b/mobile/test/features/agents/agent_model_picker_test.dart @@ -0,0 +1,66 @@ +import 'package:buzz/features/agents/agent_model_picker.dart'; +import 'package:buzz/shared/agents/remote_agents.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const opus = RemoteAgentModel( + id: 'kgoose-claude-opus-4-6', + name: 'Opus', + efforts: ['low', 'medium', 'high'], + ); + const sonnet = RemoteAgentModel( + id: 'kgoose-claude-sonnet-4-6', + name: 'Sonnet', + efforts: ['low', 'medium', 'high'], + ); + + test('defaults to Sonnet at medium effort', () { + expect(startingAgentModel([opus, sonnet]), ( + 'kgoose-claude-sonnet-4-6', + 'medium', + )); + }); + + test('leaves effort for the user when medium is not offered', () { + const highOnly = RemoteAgentModel( + id: 'claude-sonnet-5', + name: 'Sonnet 5', + efforts: ['high'], + ); + expect(startingAgentModel([opus, highOnly]), ('claude-sonnet-5', null)); + }); + + test('chooses nothing when no Sonnet is offered', () { + expect(startingAgentModel([opus]), (null, null)); + }); + + test('a draft model the catalog offers wins over the default', () { + expect( + startingAgentModel( + [opus, sonnet], + draftModel: opus.id, + draftEffort: 'high', + ), + (opus.id, 'high'), + ); + expect( + startingAgentModel( + [opus, sonnet], + draftModel: opus.id, + draftEffort: 'max', + ), + (opus.id, null), + ); + }); + + test('a draft model the catalog lacks falls back to the default', () { + expect( + startingAgentModel( + [opus, sonnet], + draftModel: 'gone', + draftEffort: 'high', + ), + (sonnet.id, 'medium'), + ); + }); +} diff --git a/mobile/test/features/agents/create_agent_page_test.dart b/mobile/test/features/agents/create_agent_page_test.dart index 3798ba18d9..b29ccc9336 100644 --- a/mobile/test/features/agents/create_agent_page_test.dart +++ b/mobile/test/features/agents/create_agent_page_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; + import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/preset_agent_avatar.dart'; import 'package:buzz/shared/agents/agent_avatar_catalog.dart'; import 'package:buzz/features/agents/agent_avatar_editor_page.dart'; + import 'dart:async'; import 'package:buzz/shared/agents/remote_agents.dart'; @@ -228,6 +230,55 @@ void main() { expect(h.registry.calls, contains('publish-policy')); }, ); + + testWidgets('a new agent starts on the affordable default model', ( + tester, + ) async { + final h = await AgentsHarness.create(); + h.registry.models = [ + { + 'id': 'premium-model', + 'display_name': 'Premium model', + 'supported_reasoning_efforts': ['low', 'medium', 'high'], + }, + { + 'id': 'kgoose-claude-sonnet-4-6', + 'display_name': 'Sonnet', + 'supported_reasoning_efforts': ['low', 'medium', 'high'], + }, + ]; + await h.open(tester, directory: [existing]); + if (!supportsRemoteAgents(h.community)) return; + await tester.tap(find.byTooltip('Add agent')); + await tester.pumpAndSettle(); + + expect(find.text('Sonnet'), findsOneWidget); + expect(find.text('Premium model'), findsNothing); + expect(find.text('medium'), findsOneWidget); + + // Naming it is all that's left before Create. + await tester.enterText( + find.descendant( + of: find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == 'Name', + ), + matching: find.byType(TextField), + ), + 'Scout', + ); + await tester.pump(); + expect( + tester + .widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(IconButton), + ), + ) + .onPressed, + isNotNull, + ); + }); } class _StalledPosterClient implements HttpClient {