feat(app,learn,todo): the assistants in Settings and on the operator's screen

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 18:59:41 +01:00
commit 36899cdc3b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
@ -438,4 +439,142 @@ void main() {
);
});
});
group('Assistants (ADR 0021, step 2)', () {
FakeAssistantGateway offering({required bool ownKeys}) =>
FakeAssistantGateway()
..offer = AssistantsOffer(
assistants: const [
OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
),
],
ownKeys: ownKeys,
);
Future<void> show(WidgetTester tester, Dependencies dependencies) async {
tester.view.physicalSize = const Size(900, 3000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(tester, dependencies, location: RoutePaths.settings);
}
testWidgets('what is granted shows after the web search', (tester) async {
await show(
tester,
testDependencies(assistantGateway: offering(ownKeys: false)),
);
expect(find.text('Assistants'), findsOneWidget);
expect(find.text('Claude'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Assistants')).dy,
greaterThan(tester.getTopLeft(find.text('Web search')).dy),
);
expect(find.text('With your own key'), findsNothing);
});
testWidgets('nothing shows where nothing is offered or allowed', (
tester,
) async {
await show(tester, testDependencies());
expect(find.text('Assistants'), findsNothing);
});
testWidgets("an own key is kept in the person's settings, which follow "
'them to every device', (tester) async {
final settings = FakeSettingsGateway();
await show(
tester,
testDependencies(
assistantGateway: offering(ownKeys: true),
settingsGateway: settings,
),
);
await tester.tap(find.text('Add an assistant with your own key'));
await tester.pumpAndSettle();
final sheet = find.byType(SheetScaffold);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Name'),
),
'Mine',
);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Model'),
),
'claude-opus-5-5',
);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Key'),
),
'sk-ant-mine',
);
await tester.tap(
find.descendant(
of: sheet,
matching: find.widgetWithText(FilledButton, 'Save'),
),
);
await tester.pumpAndSettle();
expect(find.text('Mine'), findsOneWidget);
final kept = (await SettingsRepository(settings).read()).ownAssistants;
expect(kept.single.apiKey, 'sk-ant-mine');
});
testWidgets("an own key is refused where this device can't seal the "
'settings', (tester) async {
final settings = FakeSettingsGateway()..sealing = false;
await show(
tester,
testDependencies(
assistantGateway: offering(ownKeys: true),
settingsGateway: settings,
),
);
await tester.tap(find.text('Add an assistant with your own key'));
await tester.pumpAndSettle();
final sheet = find.byType(SheetScaffold);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Name'),
),
'Mine',
);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Model'),
),
'claude-opus-5-5',
);
await tester.tap(
find.descendant(
of: sheet,
matching: find.widgetWithText(FilledButton, 'Save'),
),
);
await tester.pumpAndSettle();
expect(
find.textContaining("this device can't encrypt your settings"),
findsOneWidget,
);
expect(settings.writes, isEmpty);
});
});
}