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:
parent
24ed63585e
commit
cc87120bc8
1 changed files with 5348 additions and 2709 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue