diff --git a/mobile/lib/features/agents/agent_model_picker.dart b/mobile/lib/features/agents/agent_model_picker.dart index 7433e78cae..6eb8570118 100644 --- a/mobile/lib/features/agents/agent_model_picker.dart +++ b/mobile/lib/features/agents/agent_model_picker.dart @@ -120,9 +120,9 @@ bool validAgentModelSelection( /// 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. +/// A draft's own model wins when the catalog offers it; then the service's +/// recommendation; otherwise the first Sonnet model, at medium effort when +/// supported. Anything the catalog doesn't offer is left for the user. (String?, String?) startingAgentModel( List catalog, { String? draftModel, @@ -135,6 +135,12 @@ bool validAgentModelSelection( drafted.efforts.contains(draftEffort) ? draftEffort : null, ); } + final recommended = catalog + .where((entry) => entry.recommendedEffort != null) + .firstOrNull; + if (recommended != null) { + return (recommended.id, recommended.recommendedEffort); + } final fallback = catalog .where((entry) => entry.id.toLowerCase().contains('sonnet')) .firstOrNull; diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index c9ba3040f0..4cfedd5bee 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -492,7 +492,10 @@ class RemoteAgentController extends ChangeNotifier { } final rows = result['models']; if (rows is! List) throw StateError('Could not load available models.'); - return rows.map((row) => RemoteAgentModel.fromJson(row)).toList(); + final recommended = result['recommended_selection']; + return rows + .map((row) => RemoteAgentModel.fromJson(row, recommended: recommended)) + .toList(); } /// Saves only an active agent owned by this account, then verifies readback. @@ -986,12 +989,19 @@ class RemoteAgentModel { required this.id, required this.name, required this.efforts, + this.recommendedEffort, }); final String id; final String name; final List efforts; - factory RemoteAgentModel.fromJson(dynamic value) { + /// Set when the service recommends this model, at this effort, for new + /// agents. Creation guidance only; it never replaces a saved choice. + final String? recommendedEffort; + + /// [recommended] is the catalog's optional `recommended_selection`; a + /// malformed one, or one naming a pair this model lacks, is ignored. + factory RemoteAgentModel.fromJson(dynamic value, {dynamic recommended}) { if (value is! Map || value['id'] is! String || (value['id'] as String).trim().isEmpty || @@ -1005,10 +1015,18 @@ class RemoteAgentModel { efforts.any((item) => item is! String || item.trim().isEmpty)) { throw StateError('Invalid model catalog.'); } + final id = value['id'] as String; + final recommendedEffort = + recommended is Map && recommended['model_id'] == id + ? recommended['reasoning_effort'] + : null; return RemoteAgentModel( - id: value['id'] as String, + id: id, name: value['display_name'] as String, efforts: List.from(efforts), + recommendedEffort: efforts.contains(recommendedEffort) + ? recommendedEffort as String + : null, ); } } diff --git a/mobile/test/features/agents/agent_model_picker_test.dart b/mobile/test/features/agents/agent_model_picker_test.dart index 302d104eff..31ef0708bd 100644 --- a/mobile/test/features/agents/agent_model_picker_test.dart +++ b/mobile/test/features/agents/agent_model_picker_test.dart @@ -30,6 +30,44 @@ void main() { expect(startingAgentModel([opus, highOnly]), ('claude-sonnet-5', null)); }); + test('the service recommendation wins over the Sonnet fallback', () { + final recommendedOpus = RemoteAgentModel.fromJson( + { + 'id': opus.id, + 'display_name': opus.name, + 'supported_reasoning_efforts': opus.efforts, + }, + recommended: {'model_id': opus.id, 'reasoning_effort': 'high'}, + ); + expect(startingAgentModel([recommendedOpus, sonnet]), (opus.id, 'high')); + expect( + startingAgentModel( + [recommendedOpus, sonnet], + draftModel: sonnet.id, + draftEffort: 'low', + ), + (sonnet.id, 'low'), + ); + }); + + test('ignores a recommendation the model does not offer', () { + for (final recommended in [ + {'model_id': opus.id, 'reasoning_effort': 'max'}, + {'model_id': 'gone', 'reasoning_effort': 'high'}, + {'model_id': opus.id}, + 'opus', + null, + ]) { + final model = RemoteAgentModel.fromJson({ + 'id': opus.id, + 'display_name': opus.name, + 'supported_reasoning_efforts': opus.efforts, + }, recommended: recommended); + expect(model.recommendedEffort, isNull, reason: '$recommended'); + expect(startingAgentModel([model, sonnet]), (sonnet.id, 'medium')); + } + }); + test('chooses nothing when no Sonnet is offered', () { expect(startingAgentModel([opus]), (null, null)); }); diff --git a/mobile/test/features/agents/create_agent_page_test.dart b/mobile/test/features/agents/create_agent_page_test.dart index b29ccc9336..6b1b41117a 100644 --- a/mobile/test/features/agents/create_agent_page_test.dart +++ b/mobile/test/features/agents/create_agent_page_test.dart @@ -279,6 +279,36 @@ void main() { isNotNull, ); }); + + testWidgets('a new agent starts on the service-recommended 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'], + }, + ]; + h.registry.recommendedSelection = { + 'model_id': 'premium-model', + 'reasoning_effort': '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('Premium model'), findsOneWidget); + expect(find.text('Sonnet'), findsNothing); + expect(find.text('high'), findsOneWidget); + }); } class _StalledPosterClient implements HttpClient { diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart index 7f7429ad02..2b5ecffed9 100644 --- a/mobile/test/helpers/remote_agents_fixture.dart +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -54,6 +54,7 @@ class FakeRegistry extends BuilderlabAccountClient { 'supported_reasoning_efforts': ['max'], }, ]; + Map? recommendedSelection; Completer? catalogGate; bool failCatalog = false; bool failListing = false; @@ -98,7 +99,10 @@ class FakeRegistry extends BuilderlabAccountClient { case '/v3/beekeeper/get-model-catalog': await catalogGate?.future; if (failCatalog) throw StateError('Catalog unavailable.'); - return {'models': models}; + return { + 'models': models, + 'recommended_selection': ?recommendedSelection, + }; case '/v3/beekeeper/list-agents': if (failListing) throw StateError('Registry unavailable.'); return {'status': 1, 'agents': listed};