mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
mobile: start new agents on the server-recommended model (#9151)
## Summary Builderlab's model catalog can now recommend a model and thinking effort for new agents (`recommended_selection` on `/v3/beekeeper/get-model-catalog`). The mobile create-agent screen now starts on that recommendation, so the default can change on the server without an app release. New agents start on the first of these that applies: 1. The model an agent link suggests, when the catalog offers it (unchanged). 2. The server's recommended model and effort. 3. The first Sonnet model at medium effort, as added in #9034. Used while a deployment sends no recommendation. 4. Blank. A setup resumed after a failure still keeps its saved choice. A recommendation that isn't in the returned catalog, or that is malformed, is ignored. Existing agents' saved models are never changed. The server only sends a recommendation when the deployment's catalog config sets `recommended_selection`. Until then, the app keeps falling back to Sonnet. ## Testing - Unit tests cover the recommendation beating the Sonnet fallback, a draft model beating the recommendation, and recommendations that should be ignored (unsupported effort, unknown model, missing effort, malformed, absent). - A new widget test checks that the form opens on the recommended model and effort instead of Sonnet. - `just mobile-check` and the full mobile suite (`flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`) pass locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<RemoteAgentModel> 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;
|
||||
|
||||
@@ -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<String> 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<String>.from(efforts),
|
||||
recommendedEffort: efforts.contains(recommendedEffort)
|
||||
? recommendedEffort as String
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -54,6 +54,7 @@ class FakeRegistry extends BuilderlabAccountClient {
|
||||
'supported_reasoning_efforts': ['max'],
|
||||
},
|
||||
];
|
||||
Map<String, dynamic>? recommendedSelection;
|
||||
Completer<void>? 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};
|
||||
|
||||
Reference in New Issue
Block a user