feat(app): the operator's overview of screens, grants to several people at once with one budget each, assistants by provider

Halkyon Learn's ADR 0021, D57 to D63, on halkyon_base's budgets and
effort:

- The operator's screen is an overview (Waiting, Operators, Accounts,
  Assistants, each with its count) whose rows open screens of their own,
  read 50 at a time as they scroll (OperatorListCubit, OperatorPage), so
  hundreds of accounts push nothing else down (D62).
- A handle is picked among the accounts there are (AccountSuggestionField):
  appointing an operator and granting take no other (D61).
- AssistantGrantsScreen replaces AssistantGrantsCard: the assistants set
  up by provider, with their efforts and prices; the people granted, found
  by their handle's start; one sheet granting several assistants, a
  provider's at once, to several people, each with one budget in money and
  tokens a day and a month, or changing one person's (D58, D59).
- Assistants are grouped by provider wherever they are listed, each under
  its name with a dot in its colour (ProviderLabel, D60).
- AssistantsCard shows what is left of the person's budget for all their
  assistants together; the months of the figures run oldest first (D57).
- AssistantChoice says the efforts a question may ask at and the ceiling
  on output each asks for (D63); a person's own key gets none and keeps
  4,096 tokens.

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-26 16:05:49 +01:00
commit cc87120bc8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 5348 additions and 2709 deletions

View file

@ -4,8 +4,9 @@ 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).
// The person's assistants in an app's settings, the operator granting them
// to several people at once with one budget each (D58), and the use by
// month, the oldest on the left (Halkyon Learn's ADR 0021, step 2, D57).
const _claude = OfferedAssistant(
id: 'claude',
@ -34,6 +35,13 @@ const _setUp = AssistantsSetUp(
priceInput: 3,
priceOutput: 15,
),
SetUpAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
],
);
@ -128,129 +136,163 @@ void main() {
});
});
group('AssistantGrantsCubit', () {
group('AssistantGrantCubit', () {
late FakeAssistantOperatorGateway gateway;
late FakeInstanceOperatorGateway accounts;
late AssistantOperatorRepository repository;
late AssistantGrantsCubit cubit;
final anna = OperatedAccount(
id: 'anna',
handle: 'anna',
since: DateTime(2026),
);
final ben = OperatedAccount(
id: 'ben',
handle: 'ben',
since: DateTime(2026),
);
const budget = AssistantBudget(dailyCostMicros: 1000000, monthlyTokens: 9);
AssistantGrantCubit cubitOf({
List<OperatedAccount> accounts = const [],
AssistantGrantee? grantee,
}) {
final cubit = AssistantGrantCubit(
repository,
setUp: _setUp,
accounts: accounts,
grantee: grantee,
);
addTearDown(cubit.close);
return cubit;
}
setUp(() {
gateway = FakeAssistantOperatorGateway()
..set = _setUp
..accounts.addAll({'anna': 'anna'});
..accounts.addAll({'anna': 'anna', 'ben': 'ben'});
accounts = FakeInstanceOperatorGateway()
..operates = true
..add(
OperatedAccount(id: 'anna', handle: 'anna', since: DateTime(2026)),
);
..add(anna)
..add(ben);
repository = AssistantOperatorRepository(gateway, accounts);
cubit = AssistantGrantsCubit(repository);
addTearDown(() async {
await cubit.close();
await repository.dispose();
});
addTearDown(repository.dispose);
});
test('reads the assistants set and the grants', () async {
await cubit.load();
test('grants each person each assistant chosen, all with one '
'budget', () async {
final cubit = cubitOf(accounts: [anna])
..add(ben)
..chooseAll(const ['claude', 'opus'], on: true);
expect(cubit.state.canSave, isTrue);
expect(cubit.state.setUp, _setUp);
expect(cubit.state.grants, isEmpty);
expect(cubit.state.showsAnything, isTrue);
expect(await cubit.save(budget), isTrue);
expect(gateway.calls.last, 'grant anna ben claude opus');
expect(gateway.people['ben']!.budget, budget);
expect(cubit.state.busy, isFalse);
});
test('shows nothing without an assistant set or a grant', () async {
gateway.set = const AssistantsSetUp();
await cubit.load();
test('a person is added once, and taken off again', () {
final cubit = cubitOf()
..add(anna)
..add(anna)
..add(ben)
..remove('anna');
expect(cubit.state.showsAnything, isFalse);
expect(cubit.state.accounts, [ben]);
});
test('granting by handle reads the grants anew', () async {
await cubit.load();
final failed = await cubit.grant(
test("names the people whose budget a grant replaces, as they're "
'added', () async {
await repository.grant(
accounts: ['ben'],
assistants: ['claude'],
budget: budget,
);
final cubit = cubitOf(accounts: [anna]);
await pumpEventQueue();
expect(cubit.state.replaced, isEmpty);
cubit.add(ben);
await pumpEventQueue();
expect([for (final each in cubit.state.replaced) each.account], ['ben']);
cubit.remove('ben');
await pumpEventQueue();
expect(cubit.state.replaced, isEmpty);
});
test('chooses assistants one by one, or a whole provider', () {
final cubit = cubitOf()..choose('claude', on: true);
expect(cubit.state.chosen, {'claude'});
cubit.chooseAll(const ['claude', 'opus'], on: true);
expect(cubit.state.chosen, {'claude', 'opus'});
cubit.chooseAll(const ['claude', 'opus'], on: false);
expect(cubit.state.chosen, isEmpty);
});
test('saves nothing without a person and an assistant', () async {
final nobody = cubitOf()..choose('claude', on: true);
final nothing = cubitOf(accounts: [anna]);
for (final cubit in [nobody, nothing]) {
expect(cubit.state.canSave, isFalse);
expect(await cubit.save(budget), isFalse);
}
expect(gateway.calls.where((call) => call.startsWith('grant ')), isEmpty);
});
test("the instance's refusal is the sheet's, and nothing is "
'granted', () async {
final cubit = cubitOf(accounts: [anna])..choose('claude', on: true);
expect(
await cubit.save(const AssistantBudget(dailyTokens: -1)),
isFalse,
);
expect(
cubit.state.refused,
const OperatorFailed(OperatorProblem.invalidLimit),
);
expect(cubit.state.busy, isFalse);
expect(gateway.people, isEmpty);
});
test("changing starts from the person's assistants still set, and "
'gives them exactly those chosen', () async {
final grantee = AssistantGrantee(
account: 'anna',
handle: 'anna',
assistant: 'claude',
dailyLimit: 1000000,
monthlyLimit: null,
grants: [
AssistantGrant(assistant: 'claude', grantedAt: DateTime(2026)),
AssistantGrant(
assistant: 'gone',
assistantSet: false,
grantedAt: DateTime(2026),
),
],
budget: budget,
);
final cubit = cubitOf(grantee: grantee);
expect(cubit.state.chosen, {'claude'});
expect(failed, isNull);
expect(cubit.state.grants.single.account, 'anna');
expect(cubit.state.busy, isNull);
});
cubit
..choose('claude', on: false)
..choose('opus', on: true);
expect(await cubit.save(AssistantBudget.none), isTrue);
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);
expect(gateway.calls.last, 'change anna opus');
expect(gateway.people['anna']!.budget, AssistantBudget.none);
});
});
group('AssistantUseCubit', () {
test('offers this UTC month and the two before, and reads the one '
'shown', () async {
test('offers this UTC month and the two before, the oldest first, and '
'reads the one shown', () async {
final asked = <DateTime>[];
final use = AssistantUse(
assistant: 'claude',
@ -267,10 +309,11 @@ void main() {
addTearDown(cubit.close);
expect(cubit.state.months, [
DateTime.utc(2026),
DateTime.utc(2025, 12),
DateTime.utc(2025, 11),
DateTime.utc(2025, 12),
DateTime.utc(2026),
]);
expect(cubit.state.month, DateTime.utc(2026));
await cubit.show();
expect(cubit.state.use, [use]);
expect(cubit.state.loading, isFalse);

View file

@ -266,8 +266,8 @@ void main() {
(protocol.AssistantRefusal.notAgreed, AssistantProblem.notAgreed),
(protocol.AssistantRefusal.limitReached, AssistantProblem.limitReached),
(
protocol.AssistantRefusal.limitOutdated,
AssistantProblem.limitOutdated,
protocol.AssistantRefusal.invalidEffort,
AssistantProblem.invalidEffort,
),
(
protocol.AssistantRefusal.imagesNotRead,

View file

@ -5,7 +5,9 @@ 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).
// directly; and the operator granting assistants to several people at once,
// with one budget each (points 2, 6, D58), suggesting only handles there are
// (D61).
const _claude = OfferedAssistant(
id: 'claude',
@ -30,6 +32,7 @@ LlmRequest _request() => LlmRequest(
maxOutputTokens: 400,
system: 'You help with flashcards.',
personId: 'person-1',
effort: LlmEffort.high,
);
void main() {
@ -61,6 +64,7 @@ void main() {
expect(events.last, same(finished));
expect(gateway.asked.single.$1, 'claude');
expect(gateway.asked.single.$2.effort, LlmEffort.high);
expect(llm.sent, isEmpty);
});
@ -79,6 +83,7 @@ void main() {
expect(endpoint.model, 'gpt-5');
expect(endpoint.apiKey, 'sk-own');
expect(request.personId, isNull);
expect(request.effort, isNull, reason: 'own keys choose no effort');
expect(request.system, 'You help with flashcards.');
expect(request.maxOutputTokens, 400);
expect(gateway.calls, isEmpty);
@ -140,13 +145,57 @@ void main() {
});
});
group('the effort a question may ask at, and its ceiling (D63)', () {
const thinking = OfferedAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
effort: LlmEffort.low,
efforts: [LlmEffort.low, LlmEffort.medium, LlmEffort.high],
);
test("a granted assistant offers the operator's range, its default "
'first', () {
const choice = GrantedAssistant(thinking);
expect(choice.efforts, [LlmEffort.low, LlmEffort.medium, LlmEffort.high]);
expect(choice.effort, LlmEffort.low);
});
test('a question asks for the ceiling of its effort, else of the '
"assistant's", () {
const choice = GrantedAssistant(thinking);
expect(choice.outputCeilingAt(LlmEffort.high), 16384);
expect(choice.outputCeilingAt(null), LlmEffort.low.outputCeiling);
});
test("one without an effort asks for the models' default ceiling", () {
const choice = GrantedAssistant(_claude);
expect(choice.efforts, isEmpty);
expect(choice.effort, isNull);
expect(choice.outputCeilingAt(null), LlmEffort.defaultOutputCeiling);
});
test('an own key offers no effort and asks for the small ceiling it '
'always had, whatever its model allows', () {
const choice = PersonalAssistant(_own);
expect(choice.efforts, isEmpty);
expect(choice.effort, isNull);
expect(choice.outputCeilingAt(LlmEffort.max), 4096);
});
});
group('AssistantOperatorRepository', () {
const anna = 'account-anna';
const annabel = 'account-annabel';
const ben = 'account-ben';
late FakeAssistantOperatorGateway gateway;
late FakeInstanceOperatorGateway accounts;
late AssistantOperatorRepository repository;
const budget = AssistantBudget(dailyCostMicros: 1000000);
setUp(() {
gateway = FakeAssistantOperatorGateway()
..set = const AssistantsSetUp(
@ -160,9 +209,16 @@ void main() {
priceInput: 3,
priceOutput: 15,
),
SetUpAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
],
)
..accounts.addAll({anna: 'Anna', annabel: 'annabel'});
..accounts.addAll({anna: 'Anna', annabel: 'annabel', ben: 'ben'});
accounts = FakeInstanceOperatorGateway()
..operates = true
..add(OperatedAccount(id: anna, handle: 'Anna', since: DateTime(2026)))
@ -172,50 +228,84 @@ void main() {
handle: 'annabel',
since: DateTime(2026),
),
);
)
..add(
OperatedAccount(
id: 'account-anders',
handle: 'anders',
since: DateTime(2026),
waitingSince: DateTime(2026),
),
)
..add(OperatedAccount(id: 'account-none', 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 {
test('grants several people several assistants at once, each the same '
'budget, and tells the screens', () async {
final changed = expectLater(repository.changes, emits(null));
await repository.grant(
handle: ' @anna ',
assistant: 'claude',
dailyLimit: 1000000,
monthlyLimit: null,
accounts: [anna, ben],
assistants: ['claude', 'opus'],
budget: budget,
);
await changed;
final grant = gateway.granted.single;
expect(grant.account, anna);
expect(grant.unit, AssistantLimitUnit.money);
expect(grant.dailyLimit, 1000000);
expect(grant.monthlyLimit, isNull);
for (final account in [anna, ben]) {
final grantee = gateway.people[account]!;
expect(
[for (final grant in grantee.grants) grant.assistant],
['claude', 'opus'],
);
expect(grantee.budget, budget);
}
});
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);
test('suggests approved accounts whose handle starts as typed, an @ and '
'its case left out, at most as many as asked', () async {
expect(
accounts.calls.where((call) => call == 'accounts '),
isEmpty,
reason: 'nothing typed is never looked up',
[for (final account in await repository.grantable(' @AN ')) account.id],
[anna, annabel],
reason: 'anders waits, and an account without a handle is none',
);
expect(await repository.grantable('an', count: 1), hasLength(1));
expect(await repository.grantable('bob'), isEmpty);
});
test('suggests nothing for nothing typed, and asks nothing', () async {
for (final typed in ['', ' ', '@']) {
expect(await repository.grantable(typed), isEmpty, reason: typed);
}
expect(accounts.calls, isEmpty);
});
test('reads the people a grant would replace the budget of; none asked, '
'none read', () async {
await repository.grant(
accounts: [anna],
assistants: ['claude'],
budget: budget,
);
final asked = gateway.calls.length;
expect(await repository.granteesOf(const []), isEmpty);
expect(gateway.calls, hasLength(asked));
expect(
[
for (final grantee in await repository.granteesOf([anna, ben]))
grantee.account,
],
[anna],
);
});
test('reads the people granted by the start of their handle, an @ left '
'out, a page from where the last ended', () async {
await repository.grantees(' @an', offset: 50);
expect(gateway.calls.single, 'grantees an 50');
});
test("the instance's refusals pass through, and change nothing", () async {
@ -223,57 +313,72 @@ void main() {
repository.changes.listen((_) => changes++);
await expectLater(
repository.changeLimits(
account: anna,
assistant: 'gpt',
dailyLimit: null,
monthlyLimit: null,
repository.grant(
accounts: [anna],
assistants: ['gpt'],
budget: AssistantBudget.none,
),
throwsA(const OperatorFailed(OperatorProblem.assistantNotSet)),
);
await expectLater(
repository.changeLimits(
repository.change(
account: anna,
assistant: 'claude',
dailyLimit: -1,
monthlyLimit: null,
assistants: ['claude'],
budget: const AssistantBudget(monthlyTokens: -1),
),
throwsA(const OperatorFailed(OperatorProblem.invalidLimit)),
);
await expectLater(
repository.grant(
accounts: [anna, 'account-gone'],
assistants: ['claude'],
budget: AssistantBudget.none,
),
throwsA(const OperatorFailed(OperatorProblem.notFound)),
);
await expectLater(
repository.revoke(ben),
throwsA(const OperatorFailed(OperatorProblem.notFound)),
);
await pumpEventQueue();
expect(changes, 0);
expect(gateway.people, isEmpty);
});
test('changing limits keeps the agreement; taking one back removes '
'it', () async {
await repository.changeLimits(
account: anna,
assistant: 'claude',
dailyLimit: 1,
monthlyLimit: 2,
test('changing keeps the agreements of the assistants kept; taking back '
'removes every one and the budget', () async {
await repository.grant(
accounts: [anna],
assistants: ['claude', 'opus'],
budget: budget,
);
gateway.granted[0] = AssistantGrant(
final agreed = DateTime.utc(2026, 10, 25);
gateway.people[anna] = AssistantGrantee(
account: anna,
handle: 'Anna',
assistant: 'claude',
grantedAt: gateway.granted[0].grantedAt,
unit: AssistantLimitUnit.money,
dailyLimit: 1,
monthlyLimit: 2,
agreedAt: DateTime.utc(2026, 10, 25),
grants: [
for (final grant in gateway.people[anna]!.grants)
AssistantGrant(
assistant: grant.assistant,
grantedAt: grant.grantedAt,
agreedAt: agreed,
),
],
budget: budget,
);
await repository.changeLimits(
await repository.change(
account: anna,
assistant: 'claude',
dailyLimit: 5,
monthlyLimit: null,
assistants: ['opus'],
budget: AssistantBudget.none,
);
expect(gateway.granted.single.agreedAt, DateTime.utc(2026, 10, 25));
expect(gateway.granted.single.dailyLimit, 5);
final changed = gateway.people[anna]!;
expect(changed.grants.single.assistant, 'opus');
expect(changed.grants.single.agreedAt, agreed);
expect(changed.budget, AssistantBudget.none);
await repository.revoke(account: anna, assistant: 'claude');
expect(gateway.granted, isEmpty);
await repository.revoke(anna);
expect(gateway.people, isEmpty);
});
test('asks the figures of the UTC month a moment is in', () async {

View file

@ -3,15 +3,16 @@ 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;
hide AssistantGrantee, AssistantUse, AssistantsSetUp;
import 'package:halkyon_base_client/halkyon_base_client.dart'
as protocol
show AssistantLimitUnit, AssistantsSetUp;
show AssistantGrantee, AssistantUse, AssistantsSetUp;
import 'package:halkyon_llm/halkyon_llm.dart';
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
/// the person, with a budget partly used; 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() {
@ -50,15 +51,15 @@ final class _Instance() {
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,
effort: AssistantEffort.medium,
efforts: [AssistantEffort.low, AssistantEffort.medium],
),
],
ownKeys: true,
currency: 'EUR',
budget: AssistantLimits(dailyCostMicros: 1000000, monthlyTokens: 50000),
usedToday: protocol.AssistantUse(costMicros: 8100, tokens: 1500),
usedThisMonth: protocol.AssistantUse(costMicros: 8100, tokens: 1500),
).toJson(),
'halkyon_base.assistantOperator/setUp' => protocol.AssistantsSetUp(
assistants: [
@ -71,6 +72,8 @@ final class _Instance() {
images: true,
priceInput: 3,
priceOutput: 15,
effort: AssistantEffort.medium,
maxEffort: AssistantEffort.high,
),
AssistantInfo(
id: 'local',
@ -83,17 +86,29 @@ final class _Instance() {
],
currency: 'EUR',
ownKeys: false,
people: 1,
).toJson(),
'halkyon_base.assistantOperator/grants' => [
AssistantGrantOverview(
'halkyon_base.assistantOperator/grantees' ||
'halkyon_base.assistantOperator/granteesOf' => [
protocol.AssistantGrantee(
account: anna,
handle: 'anna',
assistant: 'local',
assistantSet: false,
grantedAt: DateTime.utc(2026, 10, 20),
limitUnit: protocol.AssistantLimitUnit.tokens,
monthlyLimit: 2000000,
outdated: true,
assistants: [
AssistantGranted(
assistant: 'claude',
assistantSet: true,
grantedAt: DateTime.utc(2026, 10, 20),
agreedAt: DateTime.utc(2026, 10, 21),
),
AssistantGranted(
assistant: 'local',
assistantSet: false,
grantedAt: DateTime.utc(2026, 10, 20),
),
],
budget: AssistantLimits(monthlyTokens: 2000000),
usedToday: protocol.AssistantUse(costMicros: 0, tokens: 300),
usedThisMonth: protocol.AssistantUse(costMicros: 900, tokens: 1500),
).toJson(),
],
'halkyon_base.assistant/figures' ||
@ -129,6 +144,7 @@ final class _Instance() {
return switch (call) {
'halkyon_base.assistant/agree' ||
'halkyon_base.assistantOperator/grant' ||
'halkyon_base.assistantOperator/change' ||
'halkyon_base.assistantOperator/revoke' => http.Response('null', 200),
_ when _answer(call) != null => http.Response(
jsonEncode(_answer(call)),
@ -159,7 +175,8 @@ void main() {
setUp(() => gateway = ServerpodAssistantGateway(app.modules));
test('reads what is offered, priced in money, with what is left', () async {
test('reads what is offered, with its efforts, and the budget with '
'what is left', () async {
final offer = await gateway.offered();
expect(
@ -174,18 +191,34 @@ void main() {
model: 'claude-sonnet-5-5',
images: true,
agreedAt: DateTime.utc(2026, 10, 25, 9),
unit: AssistantLimitUnit.money,
dailyLimit: 1000000,
usedToday: 8100,
usedThisMonth: 8100,
effort: LlmEffort.medium,
efforts: [LlmEffort.low, LlmEffort.medium],
),
],
ownKeys: true,
currency: 'EUR',
budget: const AssistantBudget(
dailyCostMicros: 1000000,
monthlyTokens: 50000,
),
usedToday: const BudgetUse(costMicros: 8100, tokens: 1500),
usedThisMonth: const BudgetUse(costMicros: 8100, tokens: 1500),
),
);
expect(offer.assistants.single.leftToday, 991900);
expect(offer.assistants.single.leftThisMonth, isNull);
expect(offer.left, [
(
unit: AssistantLimitUnit.money,
daily: true,
limit: 1000000,
left: 991900,
),
(
unit: AssistantLimitUnit.tokens,
daily: false,
limit: 50000,
left: 48500,
),
]);
});
test("agrees by the assistant's ID, and reads its own figures", () async {
@ -246,54 +279,102 @@ void main() {
setUp(() => gateway = ServerpodAssistantOperatorGateway(app.modules));
test('reads the assistants set, priced or not, without keys', () async {
test('reads the assistants set, priced or not, with their efforts, '
'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.people, 1);
expect(setUp.named('claude')?.priced, isTrue);
expect(setUp.named('claude')?.effort, LlmEffort.medium);
expect(setUp.named('claude')?.maxEffort, LlmEffort.high);
expect(setUp.named('local')?.priced, isFalse);
expect(setUp.named('local')?.effort, isNull);
expect(setUp.named('gpt'), isNull);
});
test('reads grants, one of an assistant no longer set', () async {
expect(await gateway.grants(), [
AssistantGrant(
test('reads the people granted, a page by the start of their handle, '
'with their budget and use', () async {
final grantees = await gateway.grantees(startingWith: 'an', offset: 50);
expect(grantees, [
AssistantGrantee(
account: '${_Instance.anna}',
handle: 'anna',
assistant: 'local',
assistantSet: false,
grantedAt: DateTime.utc(2026, 10, 20),
monthlyLimit: 2000000,
outdated: true,
grants: [
AssistantGrant(
assistant: 'claude',
grantedAt: DateTime.utc(2026, 10, 20),
agreedAt: DateTime.utc(2026, 10, 21),
),
AssistantGrant(
assistant: 'local',
assistantSet: false,
grantedAt: DateTime.utc(2026, 10, 20),
),
],
budget: const AssistantBudget(monthlyTokens: 2000000),
usedToday: const BudgetUse(tokens: 300),
usedThisMonth: const BudgetUse(costMicros: 900, tokens: 1500),
),
]);
expect(
jsonDecode(
instance.bodies['halkyon_base.assistantOperator/grantees']!,
),
{'startingWith': 'an', 'offset': 50},
);
await gateway.granteesOf(['${_Instance.anna}']);
expect(
jsonDecode(
instance.bodies['halkyon_base.assistantOperator/granteesOf']!,
),
{
'accounts': ['${_Instance.anna}'],
},
);
});
test('grants and takes back by account ID, and asks a month in '
'UTC', () async {
test('grants several at once, changes 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,
accounts: [anna],
assistants: ['claude', 'local'],
budget: const AssistantBudget(dailyCostMicros: 1000000),
);
await gateway.revoke(account: anna, assistant: 'claude');
await gateway.change(
account: anna,
assistants: ['claude'],
budget: AssistantBudget.none,
);
await gateway.revoke(anna);
await gateway.figures(DateTime.utc(2026, 10));
expect(
jsonDecode(instance.bodies['halkyon_base.assistantOperator/grant']!),
{
'accounts': [anna],
'assistants': ['claude', 'local'],
'budget': {
'__className__': 'halkyon_base.AssistantLimits',
'dailyCostMicros': 1000000,
},
},
);
expect(
jsonDecode(instance.bodies['halkyon_base.assistantOperator/change']!),
{
'account': anna,
'assistant': 'claude',
'dailyLimit': 1000000,
'monthlyLimit': null,
'assistants': ['claude'],
'budget': {'__className__': 'halkyon_base.AssistantLimits'},
},
);
expect(
jsonDecode(instance.bodies['halkyon_base.assistantOperator/revoke']!),
{'account': anna, 'assistant': 'claude'},
{'account': anna},
);
expect(
jsonDecode(instance.bodies['halkyon_base.assistantOperator/figures']!),
@ -303,7 +384,7 @@ void main() {
test("an account ID that isn't one fails, and nothing is sent", () async {
await expectLater(
gateway.revoke(account: 'anna', assistant: 'claude'),
gateway.revoke('anna'),
throwsA(const OperatorFailed(OperatorProblem.failed)),
);
expect(instance.bodies, isEmpty);
@ -319,10 +400,9 @@ void main() {
OperatorRefusedException(reason: refusal);
await expectLater(
gateway.grant(
account: '${_Instance.anna}',
assistant: 'claude',
dailyLimit: null,
monthlyLimit: null,
accounts: ['${_Instance.anna}'],
assistants: ['claude'],
budget: AssistantBudget.none,
),
throwsA(OperatorFailed(problem)),
reason: refusal.name,
@ -332,9 +412,9 @@ void main() {
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;
instance.refusals['halkyon_base.assistantOperator/grantees'] = status;
await expectLater(
gateway.grants(),
gateway.grantees(startingWith: '', offset: 0),
throwsA(const OperatorFailed(OperatorProblem.notAllowed)),
reason: '$status',
);

View file

@ -1,327 +0,0 @@
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,427 @@
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';
/// The operator's assistants (Halkyon Learn's ADR 0021, points 2, 6 and 8,
/// D57 to D63): those set up by provider, granted to several people at
/// once with one budget each, by handles suggested as they are typed; the
/// people granted, changed and taken back; and everyone's use by month,
/// the oldest on the left.
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,
effort: LlmEffort.medium,
maxEffort: LlmEffort.high,
);
const opus = SetUpAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
priceInput: 5,
priceOutput: 25,
);
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),
),
)
..add(
OperatedAccount(
id: 'bob',
handle: 'bob',
since: DateTime.utc(2026, 10, 3),
waitingSince: DateTime.utc(2026, 10, 3),
),
);
gateway = dependencies.assistantOperatorGateway
..set = const AssistantsSetUp(
assistants: [claude, opus, local],
currency: 'EUR',
)
..accounts.addAll({'anna': 'anna', 'ben': 'ben'});
});
/// The operator's screen, its assistants opened.
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();
await tester.tap(find.text('Assistants'));
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();
}
/// Adds the person [typed] suggests in the sheet.
Future<void> addPerson(WidgetTester tester, String typed) async {
await tester.enterText(inSheet(find.byType(TextField)).first, typed);
await tester.pumpAndSettle();
await tap(tester, inSheet(find.widgetWithText(ListTile, '@$typed')));
}
Future<void> type(WidgetTester tester, String field, String text) async {
await tester.enterText(
inSheet(find.widgetWithText(TextField, field)),
text,
);
await tester.pumpAndSettle();
}
Finder saveButton() => inSheet(find.byType(FilledButton));
testWidgets('lists those set up by provider, with their efforts and '
'whether they have prices', (tester) async {
await show(tester);
expect(find.text('Anthropic'), findsOneWidget);
expect(find.text('models.example.org'), findsOneWidget);
expect(
find.text(
'claude-sonnet-5-5 at api.anthropic.com · Effort medium, up to high',
),
findsOneWidget,
);
expect(find.text('claude-opus-5-5 at api.anthropic.com'), findsOneWidget);
expect(
find.text(
'qwen at models.example.org · No prices: counts its tokens only',
),
findsOneWidget,
);
expect(find.text('Nobody has an assistant yet.'), findsOneWidget);
});
testWidgets('grants several people a whole provider at once, with one '
'budget each, and lists them', (tester) async {
await show(tester);
await tap(tester, find.text('Grant assistants'));
expect(tester.widget<FilledButton>(saveButton()).onPressed, isNull);
await addPerson(tester, 'anna');
await addPerson(tester, 'ben');
expect(inSheet(find.widgetWithText(InputChip, '@anna')), findsOneWidget);
expect(inSheet(find.widgetWithText(InputChip, '@ben')), findsOneWidget);
await tap(
tester,
inSheet(find.widgetWithText(CheckboxListTile, 'Anthropic')),
);
await type(tester, 'Money a day', '1,5');
await type(tester, 'Tokens a month', '2,000,000');
await tap(tester, saveButton());
for (final account in ['anna', 'ben']) {
final grantee = gateway.people[account]!;
expect(
[for (final grant in grantee.grants) grant.assistant],
[
'claude',
'opus',
],
);
expect(
grantee.budget,
const AssistantBudget(dailyCostMicros: 1500000, monthlyTokens: 2000000),
);
}
expect(find.byType(SheetScaffold), findsNothing);
expect(find.text('@ben'), findsOneWidget);
expect(
find.text(
'Claude, Opus\n'
'€1.50 of €1.50 left today\n'
'2,000,000 of 2,000,000 tokens left this month\n'
'2 not agreed to yet',
),
findsNWidgets(2),
);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('3 assistants · 2 people'), findsOneWidget);
});
testWidgets('suggests only handles there are of accounts approved '
'(D61)', (tester) async {
await show(tester);
await tap(tester, find.text('Grant assistants'));
await tester.enterText(inSheet(find.byType(TextField)).first, 'b');
await tester.pumpAndSettle();
expect(inSheet(find.text('@ben')), findsOneWidget);
expect(inSheet(find.text('@bob')), findsNothing, reason: 'bob waits');
await tester.enterText(inSheet(find.byType(TextField)).first, 'zed');
await tester.pumpAndSettle();
expect(
inSheet(find.text("No account's handle starts like this.")),
findsOneWidget,
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(inSheet(find.byType(InputChip)), findsNothing);
});
testWidgets('a handle typed whole is added by Enter', (tester) async {
await show(tester);
await tap(tester, find.text('Grant assistants'));
await tester.enterText(inSheet(find.byType(TextField)).first, '@Ben');
await tester.pumpAndSettle();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(inSheet(find.widgetWithText(InputChip, '@ben')), findsOneWidget);
});
testWidgets('names whose budget a grant replaces before it does', (
tester,
) async {
gateway.people['ben'] = AssistantGrantee(
account: 'ben',
handle: 'ben',
grants: [AssistantGrant(assistant: 'local', grantedAt: DateTime(2026))],
budget: const AssistantBudget(dailyCostMicros: 1000000),
);
await show(tester);
await tap(tester, find.text('Grant assistants'));
await addPerson(tester, 'anna');
expect(inSheet(find.textContaining('replaces')), findsNothing);
await addPerson(tester, 'ben');
expect(
inSheet(find.text('Granting replaces the budget this person has:')),
findsOneWidget,
);
expect(inSheet(find.text('@ben: €1.00 a day')), 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 assistants'));
await addPerson(tester, 'ben');
await tap(tester, inSheet(find.widgetWithText(CheckboxListTile, 'Local')));
await type(tester, 'Tokens a day', 'a lot');
await tap(tester, saveButton());
expect(inSheet(find.text('Not a number this takes')), findsOneWidget);
expect(gateway.people, isEmpty);
});
testWidgets("changes a person's assistants and budget, and takes every "
'one back after asking', (tester) async {
gateway.people['ben'] = AssistantGrantee(
account: 'ben',
handle: 'ben',
grants: [
AssistantGrant(
assistant: 'claude',
grantedAt: DateTime(2026),
agreedAt: DateTime(2026),
),
],
budget: const AssistantBudget(dailyTokens: 1000),
);
await show(tester);
await tap(tester, find.text('@ben'));
expect(inSheet(find.text('@ben')), findsOneWidget);
expect(
tester
.widget<CheckboxListTile>(
inSheet(find.widgetWithText(CheckboxListTile, 'Claude')),
)
.value,
isTrue,
);
expect(
tester
.widget<TextField>(
inSheet(find.widgetWithText(TextField, 'Tokens a day')),
)
.controller!
.text,
'1000',
);
await tap(tester, inSheet(find.widgetWithText(CheckboxListTile, 'Claude')));
await tap(tester, inSheet(find.widgetWithText(CheckboxListTile, 'Local')));
await type(tester, 'Tokens a day', '');
await tap(tester, saveButton());
expect(gateway.calls, contains('change ben local'));
expect(find.text('Local\nNo limits\n1 not agreed to yet'), findsOneWidget);
await tap(tester, find.byTooltip('Show menu'));
await tap(tester, find.text('Take back').last);
expect(find.text('Take back every assistant?'), findsOneWidget);
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
expect(gateway.people, isEmpty);
expect(find.text('Nobody has an assistant yet.'), findsOneWidget);
});
testWidgets('one granted an assistant no longer set says so, and changing '
'takes it back', (tester) async {
gateway.people['ben'] = AssistantGrantee(
account: 'ben',
handle: 'ben',
grants: [
AssistantGrant(assistant: 'claude', grantedAt: DateTime(2026)),
AssistantGrant(
assistant: 'gpt',
assistantSet: false,
grantedAt: DateTime(2026),
),
],
);
await show(tester);
expect(find.textContaining('gpt (no longer set up)'), findsOneWidget);
await tap(tester, find.text('@ben'));
expect(
inSheet(find.text('Saving takes back what is no longer set up: gpt.')),
findsOneWidget,
);
});
testWidgets('finds the people granted by the start of their handle', (
tester,
) async {
for (final account in ['anna', 'ben']) {
gateway.people[account] = AssistantGrantee(
account: account,
handle: account,
grants: [AssistantGrant(assistant: 'local', grantedAt: DateTime(2026))],
);
}
await show(tester);
expect(find.text('@anna'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'People'), 'be');
await tester.pumpAndSettle();
expect(find.text('@anna'), findsNothing);
expect(find.text('@ben'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'People'), 'zed');
await tester.pumpAndSettle();
expect(
find.text('Nobody with an assistant has a handle starting like this.'),
findsOneWidget,
);
});
testWidgets('an account found offers its grant, the person in the sheet '
'already', (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();
await tap(tester, find.text('Accounts'));
await tester.enterText(find.byType(TextField), 'ben');
await tester.pumpAndSettle();
await tap(tester, find.byTooltip('Show menu'));
await tap(tester, find.text('Grant assistants'));
expect(inSheet(find.widgetWithText(InputChip, '@ben')), findsOneWidget);
});
testWidgets("everyone's use by UTC month, the oldest on the left, never "
'what was asked', (tester) async {
final now = utcMonthOf(DateTime.now());
gateway.use[now] = [
AssistantUse(
assistant: 'claude',
month: 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,
);
final months = find.descendant(
of: find.byType(SegmentedButton<DateTime>),
matching: find.byType(Text),
);
expect(
tester
.widget<SegmentedButton<DateTime>>(
find.byType(SegmentedButton<DateTime>),
)
.selected,
{now},
);
await tap(tester, months.first);
expect(
gateway.calls.last,
'figures ${DateTime.utc(now.year, now.month - 2).toIso8601String()}',
);
expect(find.text('Nothing used in this month.'), findsOneWidget);
});
}

View file

@ -6,8 +6,9 @@ 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.
/// 9 and 10, D58, D60): what is granted, by provider, and left of the
/// budget for them all, 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;
@ -20,9 +21,6 @@ void main() {
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
images: true,
unit: AssistantLimitUnit.money,
dailyLimit: 1000000,
usedToday: 8100,
);
setUp(() {
@ -32,6 +30,9 @@ void main() {
assistants: [claude],
ownKeys: true,
currency: 'EUR',
budget: AssistantBudget(dailyCostMicros: 1000000),
usedToday: BudgetUse(costMicros: 8100, tokens: 900),
usedThisMonth: BudgetUse(costMicros: 8100, tokens: 900),
);
store = MemoryOwnAssistantStore();
});
@ -77,48 +78,89 @@ void main() {
expect(find.text('Assistants'), findsNothing);
});
testWidgets('shows who answers and what is left of its limits', (
tester,
) async {
testWidgets('shows who answers, and what is left of the budget for them '
'all', (tester) async {
await show(tester);
expect(find.text('Assistants'), findsOneWidget);
expect(find.text('Your budget, for all of them'), findsOneWidget);
expect(find.text('€0.99 of €1.00 left today'), findsOneWidget);
expect(find.text('Anthropic'), 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',
),
find.text('claude-sonnet-5-5 at api.anthropic.com\nRead and agree'),
findsOneWidget,
);
expect(find.text('Your use'), findsOneWidget);
});
testWidgets('a limit in tokens names them once', (tester) async {
testWidgets('a budget 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,
),
],
assistants: [claude],
budget: AssistantBudget(dailyTokens: 100000, monthlyTokens: 2000000),
usedToday: BudgetUse(tokens: 350000),
usedThisMonth: BudgetUse(tokens: 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',
'0 of 100,000 tokens left today\n'
'1,650,000 of 2,000,000 tokens left this month',
),
findsOneWidget,
);
});
testWidgets('a budget without limits says so', (tester) async {
gateway.offer = const AssistantsOffer(assistants: [claude]);
await show(tester);
expect(find.text('No limits'), findsOneWidget);
});
testWidgets('groups the assistants by provider, in the order the operator '
'set them', (tester) async {
gateway.offer = const AssistantsOffer(
assistants: [
claude,
OfferedAssistant(
id: 'gemini',
name: 'Gemini',
protocol: 'openai',
host: 'generativelanguage.googleapis.com',
model: 'gemini-3-pro',
),
OfferedAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
],
);
await show(tester);
final labels = [
for (final text in tester.widgetList<Text>(
find.descendant(
of: find.byType(AssistantsCard),
matching: find.byType(Text),
),
))
if (const {
'Anthropic',
'Google',
'Claude',
'Opus',
'Gemini',
}.contains(text.data))
text.data,
];
expect(labels, ['Anthropic', 'Claude', 'Opus', 'Google', 'Gemini']);
});
testWidgets('says what goes where before agreeing, and agrees only when '
'asked to (D40)', (tester) async {
await show(tester);