fix(mobile): read agent.json by its schema keys only

Drop the snake_case and synonym fallbacks. The card reader takes name,
avatarUrl, instructions, model and reasoningEffort; any other key is
ignored and a missing or non-string field is left for the user.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-01 18:51:24 -08:00
parent 897bcafd2c
commit 13a6b3003d
4 changed files with 25 additions and 67 deletions

View File

@@ -51,10 +51,9 @@ any length while the QR stays small.
}
```
- Every field is optional and left for the user when missing. Cards published
so far have only `name` and `avatarUrl`. snake_case and a few synonyms
(`system_prompt`, `model_id`, `effort`, a `model` object with an `id`) are
also read. Other fields are ignored.
- `name` and `avatarUrl` are on every published card; `instructions`, `model`
and `reasoningEffort` are optional. A missing field, or one that isn't a
non-blank string, is left for the user. Other keys are ignored.
- The avatar is used only if it is an `https` URL on the same CDN; otherwise the
form picks its usual default. It stays editable.
- `model` and `reasoningEffort` are pre-selected only if the live model catalog

View File

@@ -105,32 +105,18 @@ class AgentCreationDraft {
return parseAgentCard(json, cdn);
}
/// Reads a card's draft. Only `name`, `avatarUrl`, `instructions`, `model`
/// and `reasoningEffort` are defined; cards published before instructions
/// and model existed have just the first two. Any field may be missing or
/// spelled in snake_case or a common synonym, and is then left for the user.
/// An avatar is used only from [cdn], the host the card itself came from.
/// Reads a card's draft from the `agent.json` schema's `name`,
/// `avatarUrl`, `instructions`, `model` and `reasoningEffort`. Cards
/// published before instructions and model existed have just the first two.
/// A missing field, or one that isn't a non-blank string, is left for the
/// user; other keys are ignored. An avatar is used only from [cdn], the host
/// the card itself came from.
static AgentCreationDraft parseAgentCard(Map<String, dynamic> json, Uri cdn) {
final model = json['model'];
final avatarUrl = _string(json, const [
'avatarUrl',
'avatar_url',
'avatar',
'pictureUrl',
'picture_url',
'picture',
]);
final avatarUrl = _string(json, 'avatarUrl');
final avatar = Uri.tryParse(avatarUrl ?? '');
return AgentCreationDraft(
name: _string(json, const ['name', 'displayName', 'display_name']) ?? '',
instructions:
_string(json, const [
'instructions',
'systemPrompt',
'system_prompt',
'prompt',
]) ??
'',
name: _string(json, 'name') ?? '',
instructions: _string(json, 'instructions') ?? '',
avatar:
avatar != null &&
avatar.scheme == 'https' &&
@@ -139,30 +125,15 @@ class AgentCreationDraft {
avatar.userInfo.isEmpty
? AgentAvatarSelection.url(avatarUrl!)
: null,
// `model` may also be an object such as {"id": ..., "reasoningEffort": ...}.
modelId: model is Map<String, dynamic>
? _string(model, const ['id', 'modelId', 'model_id', 'name'])
: _string(json, const ['model', 'modelId', 'model_id']),
reasoningEffort:
_string(json, _effortKeys) ??
(model is Map<String, dynamic> ? _string(model, _effortKeys) : null),
modelId: _string(json, 'model'),
reasoningEffort: _string(json, 'reasoningEffort'),
);
}
static const _effortKeys = [
'reasoningEffort',
'reasoning_effort',
'effort',
'thinking',
];
/// The first non-blank string among [keys], trimmed.
static String? _string(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
/// [key]'s value if it is a non-blank string, trimmed.
static String? _string(Map<String, dynamic> json, String key) {
final value = json[key];
return value is String && value.trim().isNotEmpty ? value.trim() : null;
}
static Future<List<int>> _readBounded(Stream<List<int>> stream) async {

View File

@@ -47,7 +47,7 @@ final _cards = <String, Map<String, Object?>>{
'collection': 'gloopie',
'name': 'Owl',
'avatarUrl': '$_cdn/av_v1/r1/avatar.png',
'model_id': 'retired-model',
'model': 'retired-model',
},
'av_third': {'name': 'Third'},
'av_fourth': {'name': 'Fourth'},
@@ -161,7 +161,7 @@ void main() {
await tester.pumpAndSettle();
// A card as published today (no instructions, a model the catalog
// lacks, under another key): the draft opens with empty instructions
// lacks): the draft opens with empty instructions
// and the model left for the user, so Create stays disabled.
await receive(_link('av_v1'));
expect(find.byType(CreateAgentPage), findsOneWidget);

View File

@@ -96,31 +96,19 @@ void main() {
model: 'sonnet-5-5',
effort: 'high',
),
'snake_case and synonyms': (
'other key names are ignored': (
json: {
'display_name': 'Scout',
'name': 'Scout',
'avatar_url': avatar,
'system_prompt': 'Be kind.',
'model_id': 'opus-5-5',
'reasoning_effort': 'low',
},
name: 'Scout',
instructions: 'Be kind.',
avatar: avatar,
model: 'opus-5-5',
effort: 'low',
),
'model object': (
json: {
'name': 'Scout',
'prompt': 'Hi.',
'model': {'id': 'opus-5-5', 'effort': 'medium'},
},
name: 'Scout',
instructions: 'Hi.',
instructions: '',
avatar: null,
model: 'opus-5-5',
effort: 'medium',
model: null,
effort: null,
),
'wrong types and blanks are left for the user': (
json: {