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 <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-11-03 16:30:16 -08:00
parent 9f88eb9c45
commit 256063f955
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), (entry) => entry.id == model && entry.efforts.contains(effort),
) == ) ==
true; 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_avatar_hero.dart';
import 'agent_character_artwork.dart'; import 'agent_character_artwork.dart';
import 'dart:math'; import 'dart:math';
import 'agent_model_picker.dart'; import 'agent_model_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@@ -57,26 +60,25 @@ class CreateAgentPage extends HookConsumerWidget {
final draft = controller.pending == null ? initialDraft : null; final draft = controller.pending == null ? initialDraft : null;
final model = useState(controller.pending?['model_id'] as String?); final model = useState(controller.pending?['model_id'] as String?);
final effort = useState(controller.pending?['reasoning_effort'] as String?); final effort = useState(controller.pending?['reasoning_effort'] as String?);
// Draft model hints apply to the first catalog that loads (after a failed // A fresh setup starts from the first catalog that loads (after a failed
// load, the one Retry fetches), and only if it offers them. // load, the one Retry fetches): the draft's model if offered, otherwise
final hintsApplied = useRef(false); // the default. A resumed setup keeps its saved choice.
final startApplied = useRef(controller.pending != null);
useEffect(() { useEffect(() {
final entries = catalog.data; final entries = catalog.data;
if (draft == null || if (startApplied.value ||
hintsApplied.value ||
catalog.connectionState != ConnectionState.done || catalog.connectionState != ConnectionState.done ||
entries == null) { entries == null) {
return null; return null;
} }
hintsApplied.value = true; startApplied.value = true;
final match = entries final (startModel, startEffort) = startingAgentModel(
.where((entry) => entry.id == draft.modelId) entries,
.firstOrNull; draftModel: draft?.modelId,
if (match == null) return null; draftEffort: draft?.reasoningEffort,
model.value = match.id; );
effort.value = match.efforts.contains(draft.reasoningEffort) model.value = startModel;
? draft.reasoningEffort effort.value = startEffort;
: null;
return null; return null;
}, [catalog.connectionState]); }, [catalog.connectionState]);
final name = useTextEditingController( 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 'dart:io';
import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/preset_agent_avatar.dart'; import 'package:buzz/shared/widgets/preset_agent_avatar.dart';
import 'package:buzz/shared/agents/agent_avatar_catalog.dart'; import 'package:buzz/shared/agents/agent_avatar_catalog.dart';
import 'package:buzz/features/agents/agent_avatar_editor_page.dart'; import 'package:buzz/features/agents/agent_avatar_editor_page.dart';
import 'dart:async'; import 'dart:async';
import 'package:buzz/shared/agents/remote_agents.dart'; import 'package:buzz/shared/agents/remote_agents.dart';
@@ -228,6 +230,55 @@ void main() {
expect(h.registry.calls, contains('publish-policy')); 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 { class _StalledPosterClient implements HttpClient {