Files
buzz/mobile/test/helpers/remote_agents_fixture.dart
Wes 1a30e51689 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>
2026-11-05 18:00:58 -07:00

201 lines
6.2 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/agents/remote_agents.dart';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
final fakeOwner = nostr.Keys('0' * 63 + '1');
final fakeAgentKeys = nostr.Keys('0' * 63 + '2');
final fakeAgent = fakeAgentKeys.public;
const fakeSubject = 'remote-owner';
/// Fakes only the Builderlab network boundary.
class FakeRegistry extends BuilderlabAccountClient {
FakeRegistry({this.bound, bool signedIn = true}) : _signedIn = signedIn {
_changes.value = signedIn ? fakeSubject : null;
}
String? bound;
bool _signedIn;
String _subjectValue = fakeSubject;
final _changes = ValueNotifier<String?>(null);
bool get signedIn => _signedIn;
set signedIn(bool value) {
_signedIn = value;
_changes.value = value ? _subjectValue : null;
}
String get subject => _subjectValue;
set subject(String value) {
_subjectValue = value;
if (_signedIn) _changes.value = value;
}
@override
ValueListenable<String?> get subjectChanges => _changes;
bool failInstructions = false;
bool ignoreModelUpdate = false;
List<Map<String, dynamic>> models = [
{
'id': 'test-model',
'display_name': 'Test model',
'supported_reasoning_efforts': ['low', 'high'],
},
{
'id': 'other-model',
'display_name': 'Other model',
'supported_reasoning_efforts': ['max'],
},
];
Map<String, dynamic>? recommendedSelection;
Completer<void>? catalogGate;
bool failCatalog = false;
bool failListing = false;
bool rejectRegistration = false;
bool loseRegistration = false;
bool refuseRegistration = false;
Object deleteStatus = 1;
final calls = <String>[];
final bodies = <String, Map<String, dynamic>>{};
List<Map<String, dynamic>> listed = [];
@override
BuilderlabAccount? get account => signedIn
? BuilderlabAccount(subject: subject, expiresAt: DateTime.utc(2030))
: null;
/// Holds [restore] open so a test can observe a cold-start restoration.
Completer<void>? restoreGate;
@override
Future<BuilderlabAccount?> restore() async {
await restoreGate?.future;
return account;
}
@override
Future<Map<String, dynamic>> currentIdentity() async => {
'identity': {'pubkey_hex': bound},
};
@override
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body, {
String? expectedSubject,
}) async {
if (expectedSubject != null && account?.subject != expectedSubject) {
throw AccountFailure.differentAccount;
}
calls.add(path.split('/').last);
bodies[path] = body;
switch (path) {
case '/v3/beekeeper/get-model-catalog':
await catalogGate?.future;
if (failCatalog) throw StateError('Catalog unavailable.');
return {
'models': models,
'recommended_selection': ?recommendedSelection,
};
case '/v3/beekeeper/list-agents':
if (failListing) throw StateError('Registry unavailable.');
return {'status': 1, 'agents': listed};
case '/v3/beekeeper/register-agent':
if (refuseRegistration) throw AccountFailure.rejected;
if (rejectRegistration) {
return {'status': 'REGISTER_AGENT_STATUS_NAME_TAKEN'};
}
listed.add({
'agent_pubkey': fakeAgent,
'agent_id': 'agent',
'agent_name': body['agent_name'],
'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE',
});
// The server committed the identity, but the response never arrived.
if (loseRegistration) throw StateError('Connection lost.');
return {'status': 1, 'agent_pubkey': fakeAgent, 'agent_id': 'agent'};
case '/v3/beekeeper/attest-agent':
return {'status': 'ATTEST_AGENT_STATUS_ACTIVE'};
case '/v3/beekeeper/update-agent':
if (failInstructions) throw StateError('instructions unavailable');
if (body.containsKey('model_id') && !ignoreModelUpdate) {
listed = [
for (final row in listed)
if (row['agent_pubkey'] == body['agent_pubkey'])
{
...row,
'model_id': body['model_id'],
'reasoning_effort': body['reasoning_effort'],
'model_selection_status': 1,
}
else
row,
];
}
return {};
case '/v3/beekeeper/delete-agent':
expect(body, {'agent_id': 'remote-id'});
return {'status': deleteStatus};
default:
throw StateError('Unexpected request: $path');
}
}
}
/// Fakes only relay publication; registry logic stays real.
class RelayFakeController extends RemoteAgentController {
RelayFakeController(FakeRegistry account, {super.onCreated})
: super(
account: account,
keys: AccountIdentityStore(),
relayAuth: HostedRelayAuth(account),
);
bool rejectRemoval = false;
FakeRegistry get registry => account as FakeRegistry;
@override
Future<void> publishPolicy(String relay, Map<String, dynamic> setup) async {
registry.calls.add('publish-policy');
}
@override
Future<void> removePolicy(String relay, String pubkey) async {
registry.calls.add('remove-registration');
if (rejectRemoval) throw StateError('Relay rejected deletion');
}
}
/// A signed agent profile carrying [owner]'s attestation under [conditions].
NostrEvent signedAgentProfile(
nostr.Keys agent,
nostr.Keys owner, {
String conditions = '',
String content = '{}',
}) {
final pubkey = agent.public;
final digest = sha256
.convert(utf8.encode('nostr:agent-auth:$pubkey:$conditions'))
.toString();
final auth = [
'auth',
owner.public,
conditions,
nostr.Schnorr.sign(secretKey: owner.secret, message: digest),
];
return NostrEvent.fromJson(
nostr.Event.from(
kind: 0,
content: content,
secretKey: agent.secret,
createdAt: 100,
tags: [auth],
).toMap(),
);
}