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

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);