mobile: start new agents on Sonnet at medium effort (#9034)

## Summary

The mobile create-agent screen opened with model and thinking effort
blank, so the easiest path was picking from the full catalog, expensive
models included. A new agent now starts on the first Sonnet model the
catalog offers, at medium effort when that model supports it. People can
still change either one.

- An agent link that suggests a model still wins when Builderlab offers
that model.
- A setup resumed after a failure keeps the choice it saved.
- If the catalog has no Sonnet model, both start blank like today. If
the Sonnet model has no medium effort, only effort is left blank.

Builderlab doesn't send a default or pricing, so the app matches on
"sonnet" in the model id rather than pinning a version. That keeps
working when the catalog moves to a newer Sonnet.

## Screenshots

Captured from the widget test harness with a catalog listing Opus first,
then Sonnet. The avatar preview is blank because the test doesn't load
network images.

| Narrow, light | Narrow, dark | Wide, light |
| --- | --- | --- |
| ![Create agent, narrow
light](https://raw.githubusercontent.com/block/buzz/8cb0af35dfc38de81ee942b2c3475009549d6370/default-model--create-agent-narrow-light.png)
| ![Create agent, narrow
dark](https://raw.githubusercontent.com/block/buzz/8cb0af35dfc38de81ee942b2c3475009549d6370/default-model--create-agent-narrow-dark.png)
| ![Create agent, wide
light](https://raw.githubusercontent.com/block/buzz/8cb0af35dfc38de81ee942b2c3475009549d6370/default-model--create-agent-wide-light.png)
|

## Testing

- New unit tests cover the default, missing medium effort, no Sonnet,
draft model wins, and a draft model the catalog lacks.
- A new widget test checks the form opens on Sonnet at medium and only
needs a name before Create.
- Full mobile suite passes locally with the CI dart-defines
(`BUZZ_PUSH_GATEWAY_URL`, `BUZZ_AUTH_DOMAINS`) at 256063f95.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
morgmart
2026-11-03 22:14:12 -08:00
committed by GitHub
parent f04fa0904c
commit 0dd778f800
4 changed files with 157 additions and 14 deletions

View File

@@ -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<RemoteAgentModel> 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);
}

View File

@@ -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(

View File

@@ -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'),
);
});
}

View File

@@ -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<IconButton>(
find.ancestor(
of: find.byTooltip('Create'),
matching: find.byType(IconButton),
),
)
.onPressed,
isNotNull,
);
});
}
class _StalledPosterClient implements HttpClient {