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:
parent
1e5d7040fd
commit
36899cdc3b
1 changed files with 7505 additions and 46 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue