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
8 changed files with 7505 additions and 46 deletions

View file

@ -0,0 +1,295 @@
import 'package:clock/clock.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';
// The person's assistants in an app's settings, the operator's grants and
// the use by month (Halkyon Learn's ADR 0021, step 2).
const _claude = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
);
const _own = OwnAssistant(
id: 'own-1',
name: 'My GPT',
protocol: LlmProtocol.openai,
model: 'gpt-5',
apiKey: 'sk-own',
);
const _setUp = AssistantsSetUp(
assistants: [
SetUpAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
priceInput: 3,
priceOutput: 15,
),
],
);
void main() {
group('AssistantsCubit', () {
late FakeAssistantGateway gateway;
late AssistantRepository repository;
late MemoryOwnAssistantStore store;
late AssistantsCubit cubit;
setUp(() {
gateway = FakeAssistantGateway()
..offer = const AssistantsOffer(assistants: [_claude], ownKeys: true);
repository = AssistantRepository(gateway, llm: FakeLlmClient());
store = MemoryOwnAssistantStore();
cubit = AssistantsCubit(repository, ownAssistants: store);
addTearDown(() async {
await cubit.close();
await repository.dispose();
});
});
test('reads what is offered, and the own assistants as they '
'change', () async {
await cubit.load();
expect(cubit.state.loading, isFalse);
expect(cubit.state.offer.assistants, [_claude]);
expect(cubit.state.showsAnything, isTrue);
expect(await cubit.saveOwn(_own), OwnAssistantSaved.kept);
await pumpEventQueue();
expect(cubit.state.own, [_own]);
await cubit.removeOwn('own-1');
await pumpEventQueue();
expect(cubit.state.own, isEmpty);
});
test('shows nothing where nothing is offered, own keys are not '
'allowed, and none are kept', () async {
gateway.offer = AssistantsOffer.none;
await cubit.load();
expect(cubit.state.showsAnything, isFalse);
});
test("a key isn't kept where the settings wouldn't be sealed", () async {
store.unsealed = true;
expect(await cubit.saveOwn(_own), OwnAssistantSaved.refused);
expect(store.assistants, isEmpty);
});
test('agreeing reads the offer anew', () async {
await cubit.load();
await cubit.agree('claude');
expect(cubit.state.offer.assistants.single.agreed, isTrue);
expect(cubit.state.agreeing, isNull);
expect(cubit.state.problem, isNull);
});
test('a refused agreement is the problem, the offer as it was', () async {
await cubit.load();
gateway.failing = const AssistantFailed(AssistantProblem.notAllowed);
await cubit.agree('claude');
expect(
cubit.state.problem,
const AssistantFailed(AssistantProblem.notAllowed),
);
expect(cubit.state.agreeing, isNull);
});
test("an offer that can't be read offers nothing", () async {
gateway.failing = const AssistantFailed(AssistantProblem.failed);
await cubit.load();
expect(
cubit.state.unreadable,
const AssistantFailed(AssistantProblem.failed),
);
expect(cubit.state.offer, AssistantsOffer.none);
});
test('an app keeping no own assistants keeps none', () async {
final without = AssistantsCubit(repository);
addTearDown(without.close);
expect(await without.saveOwn(_own), OwnAssistantSaved.failed);
});
});
group('AssistantGrantsCubit', () {
late FakeAssistantOperatorGateway gateway;
late FakeInstanceOperatorGateway accounts;
late AssistantOperatorRepository repository;
late AssistantGrantsCubit cubit;
setUp(() {
gateway = FakeAssistantOperatorGateway()
..set = _setUp
..accounts.addAll({'anna': 'anna'});
accounts = FakeInstanceOperatorGateway()
..operates = true
..add(
OperatedAccount(id: 'anna', handle: 'anna', since: DateTime(2026)),
);
repository = AssistantOperatorRepository(gateway, accounts);
cubit = AssistantGrantsCubit(repository);
addTearDown(() async {
await cubit.close();
await repository.dispose();
});
});
test('reads the assistants set and the grants', () async {
await cubit.load();
expect(cubit.state.setUp, _setUp);
expect(cubit.state.grants, isEmpty);
expect(cubit.state.showsAnything, isTrue);
});
test('shows nothing without an assistant set or a grant', () async {
gateway.set = const AssistantsSetUp();
await cubit.load();
expect(cubit.state.showsAnything, isFalse);
});
test('granting by handle reads the grants anew', () async {
await cubit.load();
final failed = await cubit.grant(
handle: 'anna',
assistant: 'claude',
dailyLimit: 1000000,
monthlyLimit: null,
);
expect(failed, isNull);
expect(cubit.state.grants.single.account, 'anna');
expect(cubit.state.busy, isNull);
});
test("a handle nobody has is the sheet's to say, not the "
"screen's", () async {
await cubit.load();
final failed = await cubit.grant(
handle: 'bob',
assistant: 'claude',
dailyLimit: null,
monthlyLimit: null,
);
expect(failed, const OperatorFailed(OperatorProblem.notFound));
expect(cubit.state.problem, isNull);
expect(cubit.state.grants, isEmpty);
});
test('changes the limits, and takes a grant back', () async {
await cubit.load();
await cubit.grant(
handle: 'anna',
assistant: 'claude',
dailyLimit: 1,
monthlyLimit: 2,
);
expect(
await cubit.changeLimits(
cubit.state.grants.single,
dailyLimit: 5,
monthlyLimit: null,
),
isNull,
);
expect(cubit.state.grants.single.dailyLimit, 5);
expect(cubit.state.grants.single.monthlyLimit, isNull);
await cubit.revoke(cubit.state.grants.single);
expect(cubit.state.grants, isEmpty);
});
test("taking back what isn't granted any more is the screen's "
'problem', () async {
await cubit.load();
await cubit.revoke(
AssistantGrant(
account: 'anna',
assistant: 'claude',
grantedAt: DateTime.utc(2026, 10, 25),
),
);
expect(
cubit.state.problem,
const OperatorFailed(OperatorProblem.notFound),
);
});
test("someone who doesn't operate the instance reads nothing", () async {
gateway.failing = const OperatorFailed(OperatorProblem.notAllowed);
await cubit.load();
expect(
cubit.state.unreadable,
const OperatorFailed(OperatorProblem.notAllowed),
);
expect(cubit.state.showsAnything, isFalse);
});
});
group('AssistantUseCubit', () {
test('offers this UTC month and the two before, and reads the one '
'shown', () async {
final asked = <DateTime>[];
final use = AssistantUse(
assistant: 'claude',
month: DateTime.utc(2026),
requests: 2,
);
final cubit = withClock(
Clock.fixed(DateTime.utc(2026, 1, 31, 23, 30)),
() => AssistantUseCubit((month) async {
asked.add(month);
return [use];
}),
);
addTearDown(cubit.close);
expect(cubit.state.months, [
DateTime.utc(2026),
DateTime.utc(2025, 12),
DateTime.utc(2025, 11),
]);
await cubit.show();
expect(cubit.state.use, [use]);
expect(cubit.state.loading, isFalse);
await cubit.show(DateTime.utc(2025, 11));
expect(cubit.state.month, DateTime.utc(2025, 11));
expect(asked, [DateTime.utc(2026), DateTime.utc(2025, 11)]);
});
test("says when the use couldn't be read", () async {
final cubit = AssistantUseCubit(
(month) async => throw const AssistantFailed(AssistantProblem.failed),
);
addTearDown(cubit.close);
await cubit.show();
expect(cubit.state.failed, isTrue);
expect(cubit.state.use, isEmpty);
});
});
}

View file

@ -0,0 +1,355 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:serverpod_client/serverpod_client.dart';
// The conversation as features speak it (halkyon_llm's) and as the
// module's endpoint carries it (Halkyon Learn's ADR 0021, point 7): every
// part goes and comes back as it was, and the instance's refusals arrive as
// the app's failures.
final _image = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47]);
LlmRequest _request() => LlmRequest(
system: 'You help with flashcards.',
maxOutputTokens: 800,
personId: 'never sent from here',
tools: const [
LlmTool(
name: 'find_cards',
description: 'Finds cards',
inputSchema: {
'type': 'object',
'properties': {
'query': {'type': 'string'},
},
},
),
],
messages: [
LlmMessage.user([
const LlmText('What does 猫 mean?'),
LlmImage(mediaType: 'image/png', bytes: _image),
]),
LlmMessage.assistant(const [
LlmProviderBlock(
protocol: LlmProtocol.anthropic,
block: {'type': 'thinking', 'thinking': '…', 'signature': 'sig'},
),
LlmText('Looking it up.', extra: {'phase': 'commentary'}),
LlmToolCall(
id: 'call_1',
name: 'find_cards',
input: {'query': '猫'},
extra: {'id': 'fc_1'},
),
]),
LlmMessage.user(const [
LlmToolResult(callId: 'call_1', content: 'No card holds it.'),
LlmToolResult(callId: 'call_2', content: 'Timed out', isError: true),
]),
],
);
void main() {
group('questionOf', () {
test(
'carries the system prompt, the tools, the ceiling and every part',
() {
final question = questionOf('claude', _request());
expect(question.assistant, 'claude');
expect(question.system, 'You help with flashcards.');
expect(question.maxOutputTokens, 800);
expect(question.tools.single.name, 'find_cards');
expect(jsonDecode(question.tools.single.inputSchema), {
'type': 'object',
'properties': {
'query': {'type': 'string'},
},
});
final [asked, thought, results] = question.messages;
expect(asked.role, protocol.AssistantRole.user);
expect(asked.parts.first.text, 'What does 猫 mean?');
expect(asked.parts.last.kind, protocol.AssistantPartKind.image);
expect(asked.parts.last.mediaType, 'image/png');
expect(
Uint8List.sublistView(asked.parts.last.bytes!),
orderedEquals(_image),
);
expect(thought.role, protocol.AssistantRole.assistant);
expect(thought.parts[0].kind, protocol.AssistantPartKind.providerBlock);
expect(thought.parts[0].protocol, 'anthropic');
expect(jsonDecode(thought.parts[1].extra!), {'phase': 'commentary'});
expect(thought.parts[2].callId, 'call_1');
expect(jsonDecode(thought.parts[2].json!), {'query': '猫'});
expect(results.parts.last.isError, isTrue);
},
);
test("leaves out the person's ID: the instance gives its own (D30)", () {
final json = jsonEncode(questionOf('claude', _request()).toJson());
expect(json, isNot(contains('never sent from here')));
});
});
test('a turn goes and comes back as it was, every part', () {
final request = _request();
for (final message in request.messages) {
final back = llmMessageOf(messageOf(message));
expect(back.role, message.role);
expect(
jsonEncode(messageOf(back).toJson()),
jsonEncode(messageOf(message).toJson()),
);
}
});
group('llmMessageOf refuses', () {
test('a turn without parts', () {
expect(
() => llmMessageOf(
protocol.AssistantMessage(
role: protocol.AssistantRole.assistant,
parts: [],
),
),
throwsFormatException,
);
});
test("a part out of its role's turn", () {
expect(
() => llmMessageOf(
protocol.AssistantMessage(
role: protocol.AssistantRole.assistant,
parts: [
protocol.AssistantPart(
kind: protocol.AssistantPartKind.toolResult,
callId: 'call_1',
text: 'Done',
),
],
),
),
throwsFormatException,
);
});
test('a part without what its kind needs, or of a protocol unknown', () {
for (final part in [
protocol.AssistantPart(kind: protocol.AssistantPartKind.text),
protocol.AssistantPart(
kind: protocol.AssistantPartKind.providerBlock,
protocol: 'gopher',
json: '{}',
),
protocol.AssistantPart(
kind: protocol.AssistantPartKind.toolCall,
callId: 'call_1',
name: 'find_cards',
json: '[1]',
),
]) {
expect(
() => llmMessageOf(
protocol.AssistantMessage(
role: protocol.AssistantRole.assistant,
parts: [part],
),
),
throwsFormatException,
reason: part.kind.name,
);
}
});
});
group('eventsOf', () {
protocol.AssistantAnswer finished() => protocol.AssistantAnswer(
message: protocol.AssistantMessage(
role: protocol.AssistantRole.assistant,
parts: [
protocol.AssistantPart(
kind: protocol.AssistantPartKind.text,
text: 'Cat.',
),
],
),
stop: protocol.AssistantStop.end,
inputTokens: 12,
outputTokens: 3,
);
test(
'turns the pieces into text, then the whole turn with its tokens',
() async {
final events = await eventsOf(
Stream.fromIterable([
protocol.AssistantAnswer(delta: 'Ca'),
protocol.AssistantAnswer(delta: ''),
protocol.AssistantAnswer(delta: 't.'),
finished(),
]),
).toList();
expect(events, hasLength(3));
expect((events[0] as LlmTextDelta).text, 'Ca');
expect((events[1] as LlmTextDelta).text, 't.');
final last = events[2] as LlmFinished;
expect(last.message.text, 'Cat.');
expect(last.stop, LlmStop.end);
expect(last.usage, const LlmUsage(inputTokens: 12, outputTokens: 3));
},
);
test('fails where the answer ends without its turn', () {
expect(
eventsOf(Stream.fromIterable([protocol.AssistantAnswer(delta: 'Ca')])),
emitsInOrder([
isA<LlmTextDelta>(),
emitsError(const AssistantFailed(AssistantProblem.failed)),
emitsDone,
]),
);
});
test("fails where the turn isn't one, once", () {
expect(
eventsOf(
Stream.fromIterable([
protocol.AssistantAnswer(
message: protocol.AssistantMessage(
role: protocol.AssistantRole.assistant,
parts: [],
),
),
]),
),
emitsInOrder([
emitsError(const AssistantFailed(AssistantProblem.providerFailed)),
emitsDone,
]),
);
});
test(
"turns the instance's refusal into the app's, with when it resets",
() {
final resets = DateTime.utc(2026, 10, 26);
expect(
eventsOf(
Stream.error(
protocol.AssistantRefusedException(
refusal: protocol.AssistantRefusal.limitReached,
resetsAt: resets,
),
),
),
emitsInOrder([
emitsError(
AssistantFailed(AssistantProblem.limitReached, resetsAt: resets),
),
emitsDone,
]),
);
},
);
});
group('failureOf', () {
test('names every refusal of the instance as the app does', () {
for (final (refusal, problem) in [
(protocol.AssistantRefusal.notGranted, AssistantProblem.notGranted),
(protocol.AssistantRefusal.notAgreed, AssistantProblem.notAgreed),
(protocol.AssistantRefusal.limitReached, AssistantProblem.limitReached),
(
protocol.AssistantRefusal.limitOutdated,
AssistantProblem.limitOutdated,
),
(
protocol.AssistantRefusal.imagesNotRead,
AssistantProblem.imagesNotRead,
),
(protocol.AssistantRefusal.malformed, AssistantProblem.malformed),
(protocol.AssistantRefusal.tooLarge, AssistantProblem.tooLarge),
(
protocol.AssistantRefusal.tooManyAtOnce,
AssistantProblem.tooManyAtOnce,
),
(protocol.AssistantRefusal.providerKey, AssistantProblem.providerKey),
(protocol.AssistantRefusal.providerBusy, AssistantProblem.providerBusy),
(
protocol.AssistantRefusal.providerRefused,
AssistantProblem.providerRefused,
),
(
protocol.AssistantRefusal.providerFailed,
AssistantProblem.providerFailed,
),
]) {
expect(
failureOf(protocol.AssistantRefusedException(refusal: refusal)),
AssistantFailed(problem),
reason: refusal.name,
);
}
});
test('keeps how long to wait', () {
expect(
failureOf(
protocol.AssistantRefusedException(
refusal: protocol.AssistantRefusal.providerBusy,
retryAfterSeconds: 30,
),
),
const AssistantFailed(
AssistantProblem.providerBusy,
retryAfter: Duration(seconds: 30),
),
);
});
test('names a session without the scope, or none, not allowed', () {
expect(
failureOf(ServerpodClientUnauthorized()),
const AssistantFailed(AssistantProblem.notAllowed),
);
expect(
failureOf(ServerpodClientForbidden()),
const AssistantFailed(AssistantProblem.notAllowed),
);
});
test('names anything else failed', () {
expect(
failureOf(const SocketLikeException()),
const AssistantFailed(AssistantProblem.failed),
);
});
});
test("ownKeyFailureOf names a personal key's provider's failures", () {
for (final (kind, problem) in [
(LlmFailureKind.key, AssistantProblem.providerKey),
(LlmFailureKind.rateLimited, AssistantProblem.providerBusy),
(LlmFailureKind.unavailable, AssistantProblem.providerBusy),
(LlmFailureKind.request, AssistantProblem.providerRefused),
(LlmFailureKind.provider, AssistantProblem.providerFailed),
(LlmFailureKind.malformed, AssistantProblem.providerFailed),
(LlmFailureKind.network, AssistantProblem.failed),
]) {
expect(
ownKeyFailureOf(LlmFailure(kind: kind, detail: 'detail')).problem,
problem,
reason: kind.name,
);
}
});
}
/// A failure to reach the instance, as the network gives one.
final class const SocketLikeException() implements Exception;

View file

@ -0,0 +1,289 @@
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';
// Who answers a person's question (Halkyon Learn's ADR 0021, points 7 and
// 10): an assistant granted, through the instance, or their own key,
// directly; and the operator granting assistants by handle (points 2, 6).
const _claude = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
);
const _own = OwnAssistant(
id: 'own-1',
name: 'My GPT',
protocol: LlmProtocol.openai,
model: 'gpt-5',
apiKey: 'sk-own',
);
LlmRequest _request() => LlmRequest(
messages: [
LlmMessage.user(const [LlmText('What does 猫 mean?')]),
],
maxOutputTokens: 400,
system: 'You help with flashcards.',
personId: 'person-1',
);
void main() {
group('AssistantRepository', () {
late FakeAssistantGateway gateway;
late FakeLlmClient llm;
late AssistantRepository repository;
setUp(() {
gateway = FakeAssistantGateway()
..offer = const AssistantsOffer(assistants: [_claude], ownKeys: true);
llm = FakeLlmClient();
repository = AssistantRepository(gateway, llm: llm);
addTearDown(repository.dispose);
});
test('asks a granted assistant through the instance, by its ID, and '
'never its provider directly', () async {
final finished = LlmFinished(
message: LlmMessage.assistant(const [LlmText('Cat.')]),
stop: LlmStop.end,
usage: const LlmUsage(inputTokens: 10, outputTokens: 2),
);
gateway.answers.add([const LlmTextDelta('Cat.'), finished]);
final events = await repository
.ask(const GrantedAssistant(_claude), _request())
.toList();
expect(events.last, same(finished));
expect(gateway.asked.single.$1, 'claude');
expect(llm.sent, isEmpty);
});
test("asks a person's own assistant directly, with their key, without "
'their ID, and never through the instance', () async {
llm.replyText('Cat.');
final events = await repository
.ask(const PersonalAssistant(_own), _request())
.toList();
expect((events.last as LlmFinished).message.text, 'Cat.');
final (endpoint, request) = llm.sent.single;
expect(endpoint.protocol, LlmProtocol.openai);
expect(endpoint.host, 'api.openai.com');
expect(endpoint.model, 'gpt-5');
expect(endpoint.apiKey, 'sk-own');
expect(request.personId, isNull);
expect(request.system, 'You help with flashcards.');
expect(request.maxOutputTokens, 400);
expect(gateway.calls, isEmpty);
});
test("a personal key's provider failing ends the answer as the app's "
'failure', () async {
llm.fail(LlmFailureKind.key);
await expectLater(
repository.ask(const PersonalAssistant(_own), _request()),
emitsError(const AssistantFailed(AssistantProblem.providerKey)),
);
});
test("an own assistant's address that isn't one fails before anything "
'is sent', () async {
const plain = OwnAssistant(
id: 'own-2',
name: 'Plain',
protocol: LlmProtocol.openai,
baseUrl: 'ftp://models.example.org',
model: 'local',
apiKey: 'sk-plain',
);
await expectLater(
repository.ask(const PersonalAssistant(plain), _request()),
emitsError(const AssistantFailed(AssistantProblem.providerFailed)),
);
expect(llm.sent, isEmpty);
});
test('agreeing records it and tells what shows the assistants', () async {
final changed = expectLater(repository.changes, emits(null));
await repository.agree('claude');
await changed;
expect((await repository.offered()).assistants.single.agreed, isTrue);
});
test('agreeing to an assistant not granted fails, and changes '
'nothing', () async {
var changes = 0;
repository.changes.listen((_) => changes++);
await expectLater(
repository.agree('gpt'),
throwsA(const AssistantFailed(AssistantProblem.notGranted)),
);
await pumpEventQueue();
expect(changes, 0);
});
test("dispose closes the personal keys' HTTP client", () async {
await repository.dispose();
expect(llm.closed, isTrue);
});
});
group('AssistantOperatorRepository', () {
const anna = 'account-anna';
const annabel = 'account-annabel';
late FakeAssistantOperatorGateway gateway;
late FakeInstanceOperatorGateway accounts;
late AssistantOperatorRepository repository;
setUp(() {
gateway = FakeAssistantOperatorGateway()
..set = const AssistantsSetUp(
assistants: [
SetUpAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
priceInput: 3,
priceOutput: 15,
),
],
)
..accounts.addAll({anna: 'Anna', annabel: 'annabel'});
accounts = FakeInstanceOperatorGateway()
..operates = true
..add(OperatedAccount(id: anna, handle: 'Anna', since: DateTime(2026)))
..add(
OperatedAccount(
id: annabel,
handle: 'annabel',
since: DateTime(2026),
),
);
repository = AssistantOperatorRepository(gateway, accounts);
addTearDown(repository.dispose);
});
test('grants by the exact handle, an @ before it and its case left '
'out, and tells the screen', () async {
final changed = expectLater(repository.changes, emits(null));
await repository.grant(
handle: ' @anna ',
assistant: 'claude',
dailyLimit: 1000000,
monthlyLimit: null,
);
await changed;
final grant = gateway.granted.single;
expect(grant.account, anna);
expect(grant.unit, AssistantLimitUnit.money);
expect(grant.dailyLimit, 1000000);
expect(grant.monthlyLimit, isNull);
});
test('refuses a handle nobody has, or only the start of one, and grants '
'nothing', () async {
for (final handle in ['bob', 'ann', '@', '']) {
await expectLater(
repository.grant(
handle: handle,
assistant: 'claude',
dailyLimit: null,
monthlyLimit: null,
),
throwsA(const OperatorFailed(OperatorProblem.notFound)),
reason: handle,
);
}
expect(gateway.granted, isEmpty);
expect(
accounts.calls.where((call) => call == 'accounts '),
isEmpty,
reason: 'nothing typed is never looked up',
);
});
test("the instance's refusals pass through, and change nothing", () async {
var changes = 0;
repository.changes.listen((_) => changes++);
await expectLater(
repository.changeLimits(
account: anna,
assistant: 'gpt',
dailyLimit: null,
monthlyLimit: null,
),
throwsA(const OperatorFailed(OperatorProblem.assistantNotSet)),
);
await expectLater(
repository.changeLimits(
account: anna,
assistant: 'claude',
dailyLimit: -1,
monthlyLimit: null,
),
throwsA(const OperatorFailed(OperatorProblem.invalidLimit)),
);
await pumpEventQueue();
expect(changes, 0);
});
test('changing limits keeps the agreement; taking one back removes '
'it', () async {
await repository.changeLimits(
account: anna,
assistant: 'claude',
dailyLimit: 1,
monthlyLimit: 2,
);
gateway.granted[0] = AssistantGrant(
account: anna,
handle: 'Anna',
assistant: 'claude',
grantedAt: gateway.granted[0].grantedAt,
unit: AssistantLimitUnit.money,
dailyLimit: 1,
monthlyLimit: 2,
agreedAt: DateTime.utc(2026, 10, 25),
);
await repository.changeLimits(
account: anna,
assistant: 'claude',
dailyLimit: 5,
monthlyLimit: null,
);
expect(gateway.granted.single.agreedAt, DateTime.utc(2026, 10, 25));
expect(gateway.granted.single.dailyLimit, 5);
await repository.revoke(account: anna, assistant: 'claude');
expect(gateway.granted, isEmpty);
});
test('asks the figures of the UTC month a moment is in', () async {
await repository.figures(DateTime.utc(2026, 10, 31, 23, 30));
await repository.figures(DateTime.utc(2026, 11));
expect(gateway.calls, [
'figures ${DateTime.utc(2026, 10).toIso8601String()}',
'figures ${DateTime.utc(2026, 11).toIso8601String()}',
]);
});
});
}

View file

@ -0,0 +1,344 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart'
hide AssistantLimitUnit, AssistantsSetUp;
import 'package:halkyon_base_client/halkyon_base_client.dart'
as protocol
show AssistantLimitUnit, AssistantsSetUp;
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// An instance's assistants as the network shows them: Claude granted to
/// the person, priced; or each call refuses as [refusals] says, by its
/// endpoint and method. Asking streams over a WebSocket, which this can't
/// fake: `eventsOf` is tested on its own.
final class _Instance() {
static final anna = UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001',
);
/// What each call refuses with: an HTTP status, or the module's
/// exception.
final refusals = <String, Object>{};
/// What each call sent, by its endpoint and method.
final bodies = <String, String>{};
static final figures = [
AssistantMonthFigures(
account: anna,
handle: 'anna',
assistant: 'claude',
month: DateTime.utc(2026, 10),
requests: 3,
inputTokens: 1200,
outputTokens: 300,
costMicros: 8100,
),
];
static Object? _answer(String call) => switch (call) {
'halkyon_base.assistant/offered' => AssistantsOffered(
assistants: [
AssistantOffer(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
images: true,
agreedAt: DateTime.utc(2026, 10, 25, 9),
limitUnit: protocol.AssistantLimitUnit.costMicros,
dailyLimit: 1000000,
usedToday: 8100,
usedThisMonth: 8100,
outdated: false,
),
],
ownKeys: true,
currency: 'EUR',
).toJson(),
'halkyon_base.assistantOperator/setUp' => protocol.AssistantsSetUp(
assistants: [
AssistantInfo(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
images: true,
priceInput: 3,
priceOutput: 15,
),
AssistantInfo(
id: 'local',
name: 'Local',
protocol: 'openai',
host: 'models.example.org',
model: 'qwen',
images: false,
),
],
currency: 'EUR',
ownKeys: false,
).toJson(),
'halkyon_base.assistantOperator/grants' => [
AssistantGrantOverview(
account: anna,
handle: 'anna',
assistant: 'local',
assistantSet: false,
grantedAt: DateTime.utc(2026, 10, 20),
limitUnit: protocol.AssistantLimitUnit.tokens,
monthlyLimit: 2000000,
outdated: true,
).toJson(),
],
'halkyon_base.assistant/figures' ||
'halkyon_base.assistantOperator/figures' => [
for (final each in figures) each.toJson(),
],
_ => null,
};
http.Client client() => MockClient((request) async {
final call = request.url.path.substring(1);
bodies[call] = request.body;
if (refusals[call] case final refusal?) {
return switch (refusal) {
final int status => http.Response('', status),
final AssistantRefusedException refused => http.Response(
jsonEncode({
'className': 'halkyon_base.AssistantRefusedException',
'data': refused.toJson(),
}),
400,
),
final OperatorRefusedException refused => http.Response(
jsonEncode({
'className': 'halkyon_base.OperatorRefusedException',
'data': refused.toJson(),
}),
400,
),
_ => throw ArgumentError.value(refusal),
};
}
return switch (call) {
'halkyon_base.assistant/agree' ||
'halkyon_base.assistantOperator/grant' ||
'halkyon_base.assistantOperator/revoke' => http.Response('null', 200),
_ when _answer(call) != null => http.Response(
jsonEncode(_answer(call)),
200,
),
_ => http.Response('', 404),
};
});
}
/// What a person's and an operator's apps ask of the module's assistants
/// (Halkyon Learn's ADR 0021, step 2), and its refusals in the app's terms.
void main() {
late _Instance instance;
late BaseClient app;
setUp(() {
instance = _Instance();
app = BaseClient(
'https://learn.example.org/',
httpClientOverride: instance.client(),
);
addTearDown(app.close);
});
group('the person', () {
late ServerpodAssistantGateway gateway;
setUp(() => gateway = ServerpodAssistantGateway(app.modules));
test('reads what is offered, priced in money, with what is left', () async {
final offer = await gateway.offered();
expect(
offer,
AssistantsOffer(
assistants: [
OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
images: true,
agreedAt: DateTime.utc(2026, 10, 25, 9),
unit: AssistantLimitUnit.money,
dailyLimit: 1000000,
usedToday: 8100,
usedThisMonth: 8100,
),
],
ownKeys: true,
currency: 'EUR',
),
);
expect(offer.assistants.single.leftToday, 991900);
expect(offer.assistants.single.leftThisMonth, isNull);
});
test("agrees by the assistant's ID, and reads its own figures", () async {
await gateway.agree('claude');
expect(
jsonDecode(instance.bodies['halkyon_base.assistant/agree']!),
{'assistant': 'claude'},
);
expect(await gateway.figures(), [
AssistantUse(
assistant: 'claude',
month: DateTime.utc(2026, 10),
requests: 3,
inputTokens: 1200,
outputTokens: 300,
costMicros: 8100,
account: '${_Instance.anna}',
handle: 'anna',
),
]);
});
test("the instance's refusals, in the app's terms", () async {
final resets = DateTime.utc(2026, 10, 26);
instance.refusals['halkyon_base.assistant/agree'] =
AssistantRefusedException(
refusal: AssistantRefusal.notGranted,
resetsAt: resets,
);
await expectLater(
gateway.agree('gpt'),
throwsA(
AssistantFailed(AssistantProblem.notGranted, resetsAt: resets),
),
);
});
test('without the scope or a session, not allowed; out of reach, '
'failed', () async {
for (final (status, problem) in [
(401, AssistantProblem.notAllowed),
(403, AssistantProblem.notAllowed),
(500, AssistantProblem.failed),
]) {
instance.refusals['halkyon_base.assistant/offered'] = status;
await expectLater(
gateway.offered(),
throwsA(AssistantFailed(problem)),
reason: '$status',
);
}
});
});
group('the operator', () {
late ServerpodAssistantOperatorGateway gateway;
setUp(() => gateway = ServerpodAssistantOperatorGateway(app.modules));
test('reads the assistants set, priced or not, without keys', () async {
final setUp = await gateway.setUp();
expect(setUp.currency, 'EUR');
expect(setUp.ownKeys, isFalse);
expect(setUp.named('claude')?.unit, AssistantLimitUnit.money);
expect(setUp.named('local')?.unit, AssistantLimitUnit.tokens);
expect(setUp.named('gpt'), isNull);
});
test('reads grants, one of an assistant no longer set', () async {
expect(await gateway.grants(), [
AssistantGrant(
account: '${_Instance.anna}',
handle: 'anna',
assistant: 'local',
assistantSet: false,
grantedAt: DateTime.utc(2026, 10, 20),
monthlyLimit: 2000000,
outdated: true,
),
]);
});
test('grants and takes back by account ID, and asks a month in '
'UTC', () async {
final anna = '${_Instance.anna}';
await gateway.grant(
account: anna,
assistant: 'claude',
dailyLimit: 1000000,
monthlyLimit: null,
);
await gateway.revoke(account: anna, assistant: 'claude');
await gateway.figures(DateTime.utc(2026, 10));
expect(
jsonDecode(instance.bodies['halkyon_base.assistantOperator/grant']!),
{
'account': anna,
'assistant': 'claude',
'dailyLimit': 1000000,
'monthlyLimit': null,
},
);
expect(
jsonDecode(instance.bodies['halkyon_base.assistantOperator/revoke']!),
{'account': anna, 'assistant': 'claude'},
);
expect(
jsonDecode(instance.bodies['halkyon_base.assistantOperator/figures']!),
{'month': '2026-10-01T00:00:00.000Z'},
);
});
test("an account ID that isn't one fails, and nothing is sent", () async {
await expectLater(
gateway.revoke(account: 'anna', assistant: 'claude'),
throwsA(const OperatorFailed(OperatorProblem.failed)),
);
expect(instance.bodies, isEmpty);
});
test("the instance's refusals, in the app's terms", () async {
for (final (refusal, problem) in [
(OperatorRefusal.notFound, OperatorProblem.notFound),
(OperatorRefusal.assistantNotSet, OperatorProblem.assistantNotSet),
(OperatorRefusal.invalidLimit, OperatorProblem.invalidLimit),
]) {
instance.refusals['halkyon_base.assistantOperator/grant'] =
OperatorRefusedException(reason: refusal);
await expectLater(
gateway.grant(
account: '${_Instance.anna}',
assistant: 'claude',
dailyLimit: null,
monthlyLimit: null,
),
throwsA(OperatorFailed(problem)),
reason: refusal.name,
);
}
});
test("someone who doesn't operate the instance is not allowed", () async {
for (final status in [401, 403]) {
instance.refusals['halkyon_base.assistantOperator/grants'] = status;
await expectLater(
gateway.grants(),
throwsA(const OperatorFailed(OperatorProblem.notAllowed)),
reason: '$status',
);
}
});
});
}

View file

@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
// A limit as the operator types it into the grant's sheet (Halkyon Learn's
// ADR 0021, D38): money in millionths, tokens whole.
void main() {
const money = AssistantLimitUnit.money;
const tokens = AssistantLimitUnit.tokens;
test('reads money as millionths, with a point or a comma', () {
expect(limitFromText('5', money), 5000000);
expect(limitFromText('2.50', money), 2500000);
expect(limitFromText('2,5', money), 2500000);
expect(limitFromText('0.000001', money), 1);
expect(limitFromText(' 1 000.25 ', money), 1000250000);
});
test('reads tokens whole, grouped by thousands or not', () {
expect(limitFromText('1000000', tokens), 1000000);
expect(limitFromText('1,000,000', tokens), 1000000);
expect(limitFromText('1.000.000', tokens), 1000000);
expect(limitFromText('1_000_000', tokens), 1000000);
expect(limitFromText('250', tokens), 250);
});
test('reads nothing typed as no limit', () {
expect(limitFromText('', money), isNull);
expect(limitFromText(' ', tokens), isNull);
});
test("refuses what isn't an amount", () {
for (final (text, unit) in [
('-1', money),
('1.5.0', money),
('0.0000001', money),
('five', money),
('1e6', money),
('1234567890123', money),
('1.5', tokens),
('1,00', tokens),
('1,000.000', tokens),
('-5', tokens),
('1234567890123', tokens),
]) {
expect(
() => limitFromText(text, unit),
throwsFormatException,
reason: '$text in ${unit.name}',
);
}
});
test('writes a limit as it is read back', () {
expect(limitText(null, money), '');
expect(limitText(5000000, money), '5');
expect(limitText(2500000, money), '2.5');
expect(limitText(1, money), '0.000001');
expect(limitText(1000000, tokens), '1000000');
for (final micros in [0, 1, 990000, 12345678]) {
expect(limitFromText(limitText(micros, money), money), micros);
}
});
}

View file

@ -0,0 +1,327 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The operator's screen's Assistants card (Halkyon Learn's ADR 0021,
/// points 2, 6 and 8): grants by handle with their limits, granting,
/// changing and taking back, and everyone's use by month.
void main() {
late TestAppDependencies dependencies;
late FakeAssistantOperatorGateway gateway;
const claude = SetUpAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
priceInput: 3,
priceOutput: 15,
);
const local = SetUpAssistant(
id: 'local',
name: 'Local',
protocol: 'openai',
host: 'models.example.org',
model: 'qwen',
);
setUp(() {
dependencies = TestAppDependencies();
dependencies.operatorGateway
..operates = true
..add(
OperatedAccount(
id: 'anna',
handle: 'anna',
since: DateTime.utc(2026, 10),
isOperator: true,
),
)
..add(
OperatedAccount(
id: 'ben',
handle: 'ben',
since: DateTime.utc(2026, 10, 2),
),
);
gateway = dependencies.assistantOperatorGateway
..set = const AssistantsSetUp(
assistants: [claude, local],
currency: 'EUR',
)
..accounts.addAll({'anna': 'anna', 'ben': 'ben'});
});
Future<void> show(WidgetTester tester) async {
tester.view.physicalSize = const Size(412, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(const InstanceOperatorScreen(), dependencies: dependencies),
);
await tester.pumpAndSettle();
}
/// [finder]'s match in the sheet, where the screen behind has its own.
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 of assistants where none is set and none '
'granted', (tester) async {
gateway.set = const AssistantsSetUp();
await show(tester);
expect(find.text('Assistants'), findsNothing);
expect(find.text('Grant an assistant'), findsNothing);
});
testWidgets("shows nothing of them where they can't be read", (
tester,
) async {
gateway.failing = const OperatorFailed(OperatorProblem.notAllowed);
await show(tester);
expect(find.text('Assistants'), findsNothing);
expect(find.text('Find an account'), findsOneWidget);
});
testWidgets('lists each grant with its assistant, limits and whether its '
'person agreed', (tester) async {
gateway.granted.addAll([
AssistantGrant(
account: 'ben',
handle: 'ben',
assistant: 'claude',
grantedAt: DateTime.utc(2026, 10, 20),
unit: AssistantLimitUnit.money,
dailyLimit: 1000000,
monthlyLimit: 10000000,
),
AssistantGrant(
account: 'anna',
handle: 'anna',
assistant: 'local',
grantedAt: DateTime.utc(2026, 10, 20),
agreedAt: DateTime.utc(2026, 10, 21),
),
]);
await show(tester);
expect(find.text('Assistants'), findsOneWidget);
expect(
find.text('Claude · €1.00 a day · €10.00 a month · Not agreed yet'),
findsOneWidget,
);
expect(find.text('Local · No limits · Agreed'), findsOneWidget);
});
testWidgets("grants by handle, in its pricing's unit, and lists it", (
tester,
) async {
await show(tester);
await tap(tester, find.text('Grant an assistant'));
await tester.enterText(
inSheet(find.widgetWithText(TextField, 'Handle')),
'ben',
);
await tester.enterText(
find.widgetWithText(TextField, 'Limit a day'),
'2,5',
);
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
final grant = gateway.granted.single;
expect(grant.account, 'ben');
expect(grant.dailyLimit, 2500000);
expect(grant.monthlyLimit, isNull);
expect(find.text('Claude · €2.50 a day · Not agreed yet'), findsOneWidget);
await tap(tester, find.text('Grant an assistant'));
await tap(tester, find.text('Local'));
await tester.enterText(
inSheet(find.widgetWithText(TextField, 'Handle')),
'@Ben',
);
await tester.enterText(
find.widgetWithText(TextField, 'Limit a month'),
'1.000.000',
);
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
expect(gateway.granted.last.assistant, 'local');
expect(gateway.granted.last.monthlyLimit, 1000000);
expect(
find.text('Local · 1,000,000 tokens a month · Not agreed yet'),
findsOneWidget,
);
});
testWidgets("a limit that isn't one is said so, and nothing is sent", (
tester,
) async {
await show(tester);
await tap(tester, find.text('Grant an assistant'));
await tester.enterText(
inSheet(find.widgetWithText(TextField, 'Handle')),
'ben',
);
await tester.enterText(
find.widgetWithText(TextField, 'Limit a month'),
'ten',
);
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
expect(find.text('Not a number this takes'), findsOneWidget);
expect(gateway.calls.where((call) => call.startsWith('grant ')), isEmpty);
});
testWidgets('a handle nobody has is said so in the sheet, and nothing is '
'granted', (tester) async {
await show(tester);
await tap(tester, find.text('Grant an assistant'));
await tester.enterText(
inSheet(find.widgetWithText(TextField, 'Handle')),
'zed',
);
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
expect(find.text('No account has this handle.'), findsOneWidget);
expect(gateway.granted, isEmpty);
});
testWidgets('an account found offers its grant, its handle filled in, an '
'operator too', (tester) async {
await show(tester);
await tester.enterText(find.widgetWithText(TextField, 'Handle'), 'an');
await tester.pumpAndSettle();
await tap(tester, find.byTooltip('Show menu'));
expect(find.text('Suspend'), findsNothing, reason: 'an operator');
await tap(tester, find.text('Grant an assistant').last);
expect(
find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('anna'),
),
findsOneWidget,
);
await tap(tester, find.widgetWithText(FilledButton, 'Grant'));
expect(gateway.granted.single.account, 'anna');
});
testWidgets('changes the limits; taking it back asks first', (
tester,
) async {
gateway.granted.add(
AssistantGrant(
account: 'ben',
handle: 'ben',
assistant: 'claude',
grantedAt: DateTime.utc(2026, 10, 20),
unit: AssistantLimitUnit.money,
dailyLimit: 1000000,
),
);
await show(tester);
await tap(tester, find.byTooltip('Show menu').last);
await tap(tester, find.text('Change limits'));
expect(find.widgetWithText(TextField, '1'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'Limit a day'), '');
await tap(tester, find.widgetWithText(FilledButton, 'Save'));
expect(gateway.granted.single.dailyLimit, isNull);
expect(find.text('Claude · No limits · Not agreed yet'), findsOneWidget);
await tap(tester, find.byTooltip('Show menu').last);
await tap(tester, find.text('Take back').last);
expect(find.text('Take back Claude?'), findsOneWidget);
await tap(tester, find.widgetWithText(TextButton, 'Cancel'));
expect(gateway.granted, hasLength(1));
await tap(tester, find.byTooltip('Show menu').last);
await tap(tester, find.text('Take back').last);
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
expect(gateway.granted, isEmpty);
expect(find.text('Nobody has an assistant yet.'), findsOneWidget);
});
testWidgets('a grant of an assistant no longer set says so, and can only '
'be taken back', (tester) async {
gateway.granted.add(
AssistantGrant(
account: 'ben',
handle: 'ben',
assistant: 'gpt',
assistantSet: false,
grantedAt: DateTime.utc(2026, 10, 20),
),
);
await show(tester);
expect(
find.text('gpt · No longer set up · Not agreed yet'),
findsOneWidget,
);
await tap(tester, find.byTooltip('Show menu').last);
expect(find.text('Change limits'), findsNothing);
expect(
find.text('Take back'),
findsNWidgets(2),
reason: "the operators' card's own, and the menu's",
);
});
testWidgets("everyone's use by UTC month, never what was asked", (
tester,
) async {
gateway.use[utcMonthOf(DateTime.now())] = [
AssistantUse(
assistant: 'claude',
month: utcMonthOf(DateTime.now()),
requests: 3,
inputTokens: 1200,
outputTokens: 300,
costMicros: 8100,
account: 'ben',
handle: 'ben',
),
];
await show(tester);
await tap(tester, find.text('Use by month'));
expect(find.text('Use of assistants'), findsOneWidget);
expect(find.text('@ben'), findsOneWidget);
expect(
find.text('Claude · 3 requests · 1,500 tokens · €0.01'),
findsOneWidget,
);
expect(find.byType(SegmentedButton<DateTime>), findsOneWidget);
final before = utcMonthOf(DateTime.now());
await tap(
tester,
find
.descendant(
of: find.byType(SegmentedButton<DateTime>),
matching: find.byType(Text),
)
.last,
);
expect(gateway.calls.last, isNot('figures ${before.toIso8601String()}'));
expect(find.text('Nothing used in this month.'), findsOneWidget);
});
}

View file

@ -0,0 +1,348 @@
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);
});
}

View file

@ -171,6 +171,8 @@ void main() {
(OperatorRefusal.notFound, OperatorProblem.notFound),
(OperatorRefusal.isOperator, OperatorProblem.isOperator),
(OperatorRefusal.lastOperator, OperatorProblem.lastOperator),
(OperatorRefusal.assistantNotSet, OperatorProblem.assistantNotSet),
(OperatorRefusal.invalidLimit, OperatorProblem.invalidLimit),
]) {
instance.refusals['halkyon_base.instanceOperator/suspend'] =
OperatorRefusedException(reason: refusal);