ADR 0021, step 2's app half. halkyon_app asks the module's assistant endpoints (ServerpodAssistantGateway, ServerpodAssistantOperatorGateway) and a person's own provider directly (halkyon_llm), behind AssistantRepository: an AssistantChoice is a GrantedAssistant, asked through the instance, which counts it, or a PersonalAssistant with the person's own key, which the instance never sees. assistant_messages.dart turns a conversation into the endpoint's models and back. AssistantsCard, for an app's settings: what is granted and left of its limits, agreed to once on a sheet saying what goes where, the person's use by UTC month, the instance's privacy page, and their own assistants where the operator allows own keys (kept ones stay listed, unasked, where it doesn't). AssistantGrantsCard on InstanceOperatorScreen: grants by handle with their limits in the pricing's unit, changed and taken back after asking, everyone's use by month; a found account's menu grants too. Nothing shows where nothing is offered or can be read. package:halkyon_app/assistant_models.dart holds the models in pure Dart. Learn keeps own assistants in the person's settings (ownAssistants, schema 12, SettingsOwnAssistants); SettingsRepository.update refuses (OwnAssistantRefused) to write a key where the settings would not be sealed (SettingsGateway.seals). Learn depends on halkyon_llm directly. Todo wires the gateways; a member offers no assistant. No feature asks an assistant yet (step 3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
348 lines
10 KiB
Dart
348 lines
10 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:halkyon_app/halkyon_app.dart';
|
|
import 'package:halkyon_app/testing.dart';
|
|
import 'package:halkyon_llm/halkyon_llm.dart';
|
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
/// An app's settings' Assistants card (Halkyon Learn's ADR 0021, points 6,
|
|
/// 9 and 10): what is granted and left, agreeing to what goes where once,
|
|
/// the person's use, and assistants with their own key where allowed.
|
|
void main() {
|
|
late TestAppDependencies dependencies;
|
|
late FakeAssistantGateway gateway;
|
|
late MemoryOwnAssistantStore store;
|
|
|
|
const claude = OfferedAssistant(
|
|
id: 'claude',
|
|
name: 'Claude',
|
|
protocol: 'anthropic',
|
|
host: 'api.anthropic.com',
|
|
model: 'claude-sonnet-5-5',
|
|
images: true,
|
|
unit: AssistantLimitUnit.money,
|
|
dailyLimit: 1000000,
|
|
usedToday: 8100,
|
|
);
|
|
|
|
setUp(() {
|
|
dependencies = TestAppDependencies();
|
|
gateway = dependencies.assistantGateway
|
|
..offer = const AssistantsOffer(
|
|
assistants: [claude],
|
|
ownKeys: true,
|
|
currency: 'EUR',
|
|
);
|
|
store = MemoryOwnAssistantStore();
|
|
});
|
|
|
|
Future<void> show(WidgetTester tester, {bool keepsOwn = true}) async {
|
|
tester.view.physicalSize = const Size(412, 1600);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
await tester.pumpWidget(
|
|
testApp(
|
|
Scaffold(
|
|
body: SingleChildScrollView(
|
|
child: AssistantsCard(ownAssistants: keepsOwn ? store : null),
|
|
),
|
|
),
|
|
dependencies: dependencies,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Finder inSheet(Finder finder) =>
|
|
find.descendant(of: find.byType(SheetScaffold), matching: finder);
|
|
|
|
Future<void> tap(WidgetTester tester, Finder finder) async {
|
|
await tester.ensureVisible(finder);
|
|
await tester.tap(finder);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
testWidgets('shows nothing where nothing is offered and own keys are not '
|
|
'allowed', (tester) async {
|
|
gateway.offer = AssistantsOffer.none;
|
|
await show(tester);
|
|
|
|
expect(find.text('Assistants'), findsNothing);
|
|
});
|
|
|
|
testWidgets("shows nothing where the offer can't be read", (tester) async {
|
|
gateway.failing = const AssistantFailed(AssistantProblem.failed);
|
|
await show(tester);
|
|
|
|
expect(find.text('Assistants'), findsNothing);
|
|
});
|
|
|
|
testWidgets('shows who answers and what is left of its limits', (
|
|
tester,
|
|
) async {
|
|
await show(tester);
|
|
|
|
expect(find.text('Assistants'), findsOneWidget);
|
|
expect(find.text('Claude'), findsOneWidget);
|
|
expect(
|
|
find.text(
|
|
'claude-sonnet-5-5 at api.anthropic.com\n€0.99 of €1.00 left today\n'
|
|
'Read and agree',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Your use'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a limit in tokens names them once', (tester) async {
|
|
gateway.offer = const AssistantsOffer(
|
|
assistants: [
|
|
OfferedAssistant(
|
|
id: 'local',
|
|
name: 'Local',
|
|
protocol: 'openai',
|
|
host: 'models.example.org',
|
|
model: 'qwen',
|
|
monthlyLimit: 2000000,
|
|
usedThisMonth: 350000,
|
|
),
|
|
],
|
|
);
|
|
await show(tester);
|
|
|
|
expect(
|
|
find.text(
|
|
'qwen at models.example.org\n1,650,000 of 2,000,000 tokens left this '
|
|
'month\nRead and agree',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('says what goes where before agreeing, and agrees only when '
|
|
'asked to (D40)', (tester) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Claude'));
|
|
expect(find.text('Before you ask Claude'), findsOneWidget);
|
|
expect(
|
|
find.text(
|
|
'It answers with the model claude-sonnet-5-5 at '
|
|
'api.anthropic.com.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('It reads the images you add.'), findsOneWidget);
|
|
expect(
|
|
find.text(
|
|
'Whoever runs test.example.org sees how much you use it (requests, '
|
|
'tokens, cost), never what you ask.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(gateway.calls, isNot(contains('agree claude')));
|
|
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
|
|
|
|
expect(gateway.calls, contains('agree claude'));
|
|
expect(find.text('Before you ask Claude'), findsNothing);
|
|
expect(find.textContaining('Read and agree'), findsNothing);
|
|
});
|
|
|
|
testWidgets('once agreed, the sheet says since when and asks nothing', (
|
|
tester,
|
|
) async {
|
|
gateway.offer = AssistantsOffer(
|
|
assistants: [
|
|
OfferedAssistant(
|
|
id: claude.id,
|
|
name: claude.name,
|
|
protocol: claude.protocol,
|
|
host: claude.host,
|
|
model: claude.model,
|
|
agreedAt: DateTime.utc(2026, 10, 25, 12),
|
|
),
|
|
],
|
|
);
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Claude'));
|
|
|
|
expect(find.text('You agreed on Oct 25, 2026.'), findsOneWidget);
|
|
expect(find.widgetWithText(FilledButton, 'Agree'), findsNothing);
|
|
expect(find.text('It reads the images you add.'), findsNothing);
|
|
});
|
|
|
|
testWidgets('a refused agreement is said so', (tester) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Claude'));
|
|
gateway.failing = const AssistantFailed(AssistantProblem.notGranted);
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
|
|
|
|
expect(
|
|
find.text("This assistant isn't granted to you any more."),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets("the person's use, by UTC month", (tester) async {
|
|
final month = utcMonthOf(DateTime.now());
|
|
gateway.use = [
|
|
AssistantUse(
|
|
assistant: 'claude',
|
|
month: month,
|
|
requests: 1,
|
|
inputTokens: 900,
|
|
outputTokens: 100,
|
|
costMicros: 4500,
|
|
),
|
|
];
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Your use'));
|
|
|
|
expect(inSheet(find.text('Claude')), findsOneWidget);
|
|
expect(
|
|
inSheet(find.text('1 request · 1,000 tokens · €0.00')),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('adds an assistant with an own key, kept in the settings', (
|
|
tester,
|
|
) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
|
'My GPT',
|
|
);
|
|
await tap(tester, find.text('OpenAI Responses'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
|
'gpt-5',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Key')),
|
|
'sk-own',
|
|
);
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
|
|
|
final own = store.assistants.single;
|
|
expect(own.name, 'My GPT');
|
|
expect(own.protocol, LlmProtocol.openaiResponses);
|
|
expect(own.baseUrl, isNull);
|
|
expect(own.model, 'gpt-5');
|
|
expect(own.apiKey, 'sk-own');
|
|
expect(find.text('My GPT'), findsOneWidget);
|
|
expect(find.text('gpt-5 at api.openai.com'), findsOneWidget);
|
|
expect(find.text('sk-own'), findsNothing, reason: 'never shown');
|
|
});
|
|
|
|
testWidgets("an address that isn't https is refused before anything is "
|
|
'kept', (tester) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
|
'Remote',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Address')),
|
|
'http://models.example.org/v1',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
|
'qwen',
|
|
);
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
|
|
|
expect(
|
|
find.text('Only https, or http on your own network'),
|
|
findsOneWidget,
|
|
);
|
|
expect(store.assistants, isEmpty);
|
|
});
|
|
|
|
testWidgets("a key isn't kept where this device can't seal the "
|
|
'settings', (tester) async {
|
|
store.unsealed = true;
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
|
'Mine',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
|
'claude-sonnet-5-5',
|
|
);
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
|
|
|
expect(
|
|
find.text(
|
|
"The key wasn't kept: this device can't encrypt your settings yet. "
|
|
"Open your account's keys here first.",
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(store.assistants, isEmpty);
|
|
});
|
|
|
|
testWidgets('removing an own assistant asks first', (tester) async {
|
|
await store.save(
|
|
const OwnAssistant(
|
|
id: 'own-1',
|
|
name: 'My GPT',
|
|
protocol: LlmProtocol.openai,
|
|
model: 'gpt-5',
|
|
apiKey: 'sk-own',
|
|
),
|
|
);
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('My GPT'));
|
|
await tap(tester, find.text('Remove'));
|
|
expect(find.text('Remove My GPT?'), findsOneWidget);
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Remove'));
|
|
|
|
expect(store.assistants, isEmpty);
|
|
expect(find.text('My GPT'), findsNothing);
|
|
});
|
|
|
|
testWidgets("where own keys aren't allowed, those kept are said to go "
|
|
'unasked, and none can be added (D6)', (tester) async {
|
|
gateway.offer = AssistantsOffer.none;
|
|
await store.save(
|
|
const OwnAssistant(
|
|
id: 'own-1',
|
|
name: 'My GPT',
|
|
protocol: LlmProtocol.openai,
|
|
model: 'gpt-5',
|
|
apiKey: 'sk-own',
|
|
),
|
|
);
|
|
await show(tester);
|
|
|
|
expect(
|
|
find.text(
|
|
"Whoever runs test.example.org doesn't allow own keys here, so the "
|
|
"app doesn't ask these.",
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Add an assistant with your own key'), findsNothing);
|
|
});
|
|
|
|
testWidgets('an app keeping no own assistants offers none', (tester) async {
|
|
await show(tester, keepsOwn: false);
|
|
|
|
expect(find.text('Claude'), findsOneWidget);
|
|
expect(find.text('With your own key'), findsNothing);
|
|
expect(find.text('Add an assistant with your own key'), findsNothing);
|
|
});
|
|
}
|