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>
338 lines
9.8 KiB
Dart
338 lines
9.8 KiB
Dart
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 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',
|
|
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,
|
|
),
|
|
SetUpAssistant(
|
|
id: 'opus',
|
|
name: 'Opus',
|
|
protocol: 'anthropic',
|
|
host: 'api.anthropic.com',
|
|
model: 'claude-opus-5-5',
|
|
),
|
|
],
|
|
);
|
|
|
|
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('AssistantGrantCubit', () {
|
|
late FakeAssistantOperatorGateway gateway;
|
|
late FakeInstanceOperatorGateway accounts;
|
|
late AssistantOperatorRepository repository;
|
|
|
|
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', 'ben': 'ben'});
|
|
accounts = FakeInstanceOperatorGateway()
|
|
..operates = true
|
|
..add(anna)
|
|
..add(ben);
|
|
repository = AssistantOperatorRepository(gateway, accounts);
|
|
addTearDown(repository.dispose);
|
|
});
|
|
|
|
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(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('a person is added once, and taken off again', () {
|
|
final cubit = cubitOf()
|
|
..add(anna)
|
|
..add(anna)
|
|
..add(ben)
|
|
..remove('anna');
|
|
|
|
expect(cubit.state.accounts, [ben]);
|
|
});
|
|
|
|
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',
|
|
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'});
|
|
|
|
cubit
|
|
..choose('claude', on: false)
|
|
..choose('opus', on: true);
|
|
expect(await cubit.save(AssistantBudget.none), isTrue);
|
|
|
|
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, the oldest first, 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(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);
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|