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
3 changed files with 7505 additions and 46 deletions
|
|
@ -0,0 +1,355 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
import 'package:serverpod_client/serverpod_client.dart';
|
||||
|
||||
// The conversation as features speak it (halkyon_llm's) and as the
|
||||
// module's endpoint carries it (Halkyon Learn's ADR 0021, point 7): every
|
||||
// part goes and comes back as it was, and the instance's refusals arrive as
|
||||
// the app's failures.
|
||||
|
||||
final _image = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
LlmRequest _request() => LlmRequest(
|
||||
system: 'You help with flashcards.',
|
||||
maxOutputTokens: 800,
|
||||
personId: 'never sent from here',
|
||||
tools: const [
|
||||
LlmTool(
|
||||
name: 'find_cards',
|
||||
description: 'Finds cards',
|
||||
inputSchema: {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'query': {'type': 'string'},
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
messages: [
|
||||
LlmMessage.user([
|
||||
const LlmText('What does 猫 mean?'),
|
||||
LlmImage(mediaType: 'image/png', bytes: _image),
|
||||
]),
|
||||
LlmMessage.assistant(const [
|
||||
LlmProviderBlock(
|
||||
protocol: LlmProtocol.anthropic,
|
||||
block: {'type': 'thinking', 'thinking': '…', 'signature': 'sig'},
|
||||
),
|
||||
LlmText('Looking it up.', extra: {'phase': 'commentary'}),
|
||||
LlmToolCall(
|
||||
id: 'call_1',
|
||||
name: 'find_cards',
|
||||
input: {'query': '猫'},
|
||||
extra: {'id': 'fc_1'},
|
||||
),
|
||||
]),
|
||||
LlmMessage.user(const [
|
||||
LlmToolResult(callId: 'call_1', content: 'No card holds it.'),
|
||||
LlmToolResult(callId: 'call_2', content: 'Timed out', isError: true),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('questionOf', () {
|
||||
test(
|
||||
'carries the system prompt, the tools, the ceiling and every part',
|
||||
() {
|
||||
final question = questionOf('claude', _request());
|
||||
expect(question.assistant, 'claude');
|
||||
expect(question.system, 'You help with flashcards.');
|
||||
expect(question.maxOutputTokens, 800);
|
||||
expect(question.tools.single.name, 'find_cards');
|
||||
expect(jsonDecode(question.tools.single.inputSchema), {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'query': {'type': 'string'},
|
||||
},
|
||||
});
|
||||
final [asked, thought, results] = question.messages;
|
||||
expect(asked.role, protocol.AssistantRole.user);
|
||||
expect(asked.parts.first.text, 'What does 猫 mean?');
|
||||
expect(asked.parts.last.kind, protocol.AssistantPartKind.image);
|
||||
expect(asked.parts.last.mediaType, 'image/png');
|
||||
expect(
|
||||
Uint8List.sublistView(asked.parts.last.bytes!),
|
||||
orderedEquals(_image),
|
||||
);
|
||||
expect(thought.role, protocol.AssistantRole.assistant);
|
||||
expect(thought.parts[0].kind, protocol.AssistantPartKind.providerBlock);
|
||||
expect(thought.parts[0].protocol, 'anthropic');
|
||||
expect(jsonDecode(thought.parts[1].extra!), {'phase': 'commentary'});
|
||||
expect(thought.parts[2].callId, 'call_1');
|
||||
expect(jsonDecode(thought.parts[2].json!), {'query': '猫'});
|
||||
expect(results.parts.last.isError, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test("leaves out the person's ID: the instance gives its own (D30)", () {
|
||||
final json = jsonEncode(questionOf('claude', _request()).toJson());
|
||||
expect(json, isNot(contains('never sent from here')));
|
||||
});
|
||||
});
|
||||
|
||||
test('a turn goes and comes back as it was, every part', () {
|
||||
final request = _request();
|
||||
for (final message in request.messages) {
|
||||
final back = llmMessageOf(messageOf(message));
|
||||
expect(back.role, message.role);
|
||||
expect(
|
||||
jsonEncode(messageOf(back).toJson()),
|
||||
jsonEncode(messageOf(message).toJson()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
group('llmMessageOf refuses', () {
|
||||
test('a turn without parts', () {
|
||||
expect(
|
||||
() => llmMessageOf(
|
||||
protocol.AssistantMessage(
|
||||
role: protocol.AssistantRole.assistant,
|
||||
parts: [],
|
||||
),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test("a part out of its role's turn", () {
|
||||
expect(
|
||||
() => llmMessageOf(
|
||||
protocol.AssistantMessage(
|
||||
role: protocol.AssistantRole.assistant,
|
||||
parts: [
|
||||
protocol.AssistantPart(
|
||||
kind: protocol.AssistantPartKind.toolResult,
|
||||
callId: 'call_1',
|
||||
text: 'Done',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('a part without what its kind needs, or of a protocol unknown', () {
|
||||
for (final part in [
|
||||
protocol.AssistantPart(kind: protocol.AssistantPartKind.text),
|
||||
protocol.AssistantPart(
|
||||
kind: protocol.AssistantPartKind.providerBlock,
|
||||
protocol: 'gopher',
|
||||
json: '{}',
|
||||
),
|
||||
protocol.AssistantPart(
|
||||
kind: protocol.AssistantPartKind.toolCall,
|
||||
callId: 'call_1',
|
||||
name: 'find_cards',
|
||||
json: '[1]',
|
||||
),
|
||||
]) {
|
||||
expect(
|
||||
() => llmMessageOf(
|
||||
protocol.AssistantMessage(
|
||||
role: protocol.AssistantRole.assistant,
|
||||
parts: [part],
|
||||
),
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: part.kind.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('eventsOf', () {
|
||||
protocol.AssistantAnswer finished() => protocol.AssistantAnswer(
|
||||
message: protocol.AssistantMessage(
|
||||
role: protocol.AssistantRole.assistant,
|
||||
parts: [
|
||||
protocol.AssistantPart(
|
||||
kind: protocol.AssistantPartKind.text,
|
||||
text: 'Cat.',
|
||||
),
|
||||
],
|
||||
),
|
||||
stop: protocol.AssistantStop.end,
|
||||
inputTokens: 12,
|
||||
outputTokens: 3,
|
||||
);
|
||||
|
||||
test(
|
||||
'turns the pieces into text, then the whole turn with its tokens',
|
||||
() async {
|
||||
final events = await eventsOf(
|
||||
Stream.fromIterable([
|
||||
protocol.AssistantAnswer(delta: 'Ca'),
|
||||
protocol.AssistantAnswer(delta: ''),
|
||||
protocol.AssistantAnswer(delta: 't.'),
|
||||
finished(),
|
||||
]),
|
||||
).toList();
|
||||
expect(events, hasLength(3));
|
||||
expect((events[0] as LlmTextDelta).text, 'Ca');
|
||||
expect((events[1] as LlmTextDelta).text, 't.');
|
||||
final last = events[2] as LlmFinished;
|
||||
expect(last.message.text, 'Cat.');
|
||||
expect(last.stop, LlmStop.end);
|
||||
expect(last.usage, const LlmUsage(inputTokens: 12, outputTokens: 3));
|
||||
},
|
||||
);
|
||||
|
||||
test('fails where the answer ends without its turn', () {
|
||||
expect(
|
||||
eventsOf(Stream.fromIterable([protocol.AssistantAnswer(delta: 'Ca')])),
|
||||
emitsInOrder([
|
||||
isA<LlmTextDelta>(),
|
||||
emitsError(const AssistantFailed(AssistantProblem.failed)),
|
||||
emitsDone,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("fails where the turn isn't one, once", () {
|
||||
expect(
|
||||
eventsOf(
|
||||
Stream.fromIterable([
|
||||
protocol.AssistantAnswer(
|
||||
message: protocol.AssistantMessage(
|
||||
role: protocol.AssistantRole.assistant,
|
||||
parts: [],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
emitsInOrder([
|
||||
emitsError(const AssistantFailed(AssistantProblem.providerFailed)),
|
||||
emitsDone,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"turns the instance's refusal into the app's, with when it resets",
|
||||
() {
|
||||
final resets = DateTime.utc(2026, 10, 26);
|
||||
expect(
|
||||
eventsOf(
|
||||
Stream.error(
|
||||
protocol.AssistantRefusedException(
|
||||
refusal: protocol.AssistantRefusal.limitReached,
|
||||
resetsAt: resets,
|
||||
),
|
||||
),
|
||||
),
|
||||
emitsInOrder([
|
||||
emitsError(
|
||||
AssistantFailed(AssistantProblem.limitReached, resetsAt: resets),
|
||||
),
|
||||
emitsDone,
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('failureOf', () {
|
||||
test('names every refusal of the instance as the app does', () {
|
||||
for (final (refusal, problem) in [
|
||||
(protocol.AssistantRefusal.notGranted, AssistantProblem.notGranted),
|
||||
(protocol.AssistantRefusal.notAgreed, AssistantProblem.notAgreed),
|
||||
(protocol.AssistantRefusal.limitReached, AssistantProblem.limitReached),
|
||||
(
|
||||
protocol.AssistantRefusal.limitOutdated,
|
||||
AssistantProblem.limitOutdated,
|
||||
),
|
||||
(
|
||||
protocol.AssistantRefusal.imagesNotRead,
|
||||
AssistantProblem.imagesNotRead,
|
||||
),
|
||||
(protocol.AssistantRefusal.malformed, AssistantProblem.malformed),
|
||||
(protocol.AssistantRefusal.tooLarge, AssistantProblem.tooLarge),
|
||||
(
|
||||
protocol.AssistantRefusal.tooManyAtOnce,
|
||||
AssistantProblem.tooManyAtOnce,
|
||||
),
|
||||
(protocol.AssistantRefusal.providerKey, AssistantProblem.providerKey),
|
||||
(protocol.AssistantRefusal.providerBusy, AssistantProblem.providerBusy),
|
||||
(
|
||||
protocol.AssistantRefusal.providerRefused,
|
||||
AssistantProblem.providerRefused,
|
||||
),
|
||||
(
|
||||
protocol.AssistantRefusal.providerFailed,
|
||||
AssistantProblem.providerFailed,
|
||||
),
|
||||
]) {
|
||||
expect(
|
||||
failureOf(protocol.AssistantRefusedException(refusal: refusal)),
|
||||
AssistantFailed(problem),
|
||||
reason: refusal.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps how long to wait', () {
|
||||
expect(
|
||||
failureOf(
|
||||
protocol.AssistantRefusedException(
|
||||
refusal: protocol.AssistantRefusal.providerBusy,
|
||||
retryAfterSeconds: 30,
|
||||
),
|
||||
),
|
||||
const AssistantFailed(
|
||||
AssistantProblem.providerBusy,
|
||||
retryAfter: Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('names a session without the scope, or none, not allowed', () {
|
||||
expect(
|
||||
failureOf(ServerpodClientUnauthorized()),
|
||||
const AssistantFailed(AssistantProblem.notAllowed),
|
||||
);
|
||||
expect(
|
||||
failureOf(ServerpodClientForbidden()),
|
||||
const AssistantFailed(AssistantProblem.notAllowed),
|
||||
);
|
||||
});
|
||||
|
||||
test('names anything else failed', () {
|
||||
expect(
|
||||
failureOf(const SocketLikeException()),
|
||||
const AssistantFailed(AssistantProblem.failed),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("ownKeyFailureOf names a personal key's provider's failures", () {
|
||||
for (final (kind, problem) in [
|
||||
(LlmFailureKind.key, AssistantProblem.providerKey),
|
||||
(LlmFailureKind.rateLimited, AssistantProblem.providerBusy),
|
||||
(LlmFailureKind.unavailable, AssistantProblem.providerBusy),
|
||||
(LlmFailureKind.request, AssistantProblem.providerRefused),
|
||||
(LlmFailureKind.provider, AssistantProblem.providerFailed),
|
||||
(LlmFailureKind.malformed, AssistantProblem.providerFailed),
|
||||
(LlmFailureKind.network, AssistantProblem.failed),
|
||||
]) {
|
||||
expect(
|
||||
ownKeyFailureOf(LlmFailure(kind: kind, detail: 'detail')).problem,
|
||||
problem,
|
||||
reason: kind.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A failure to reach the instance, as the network gives one.
|
||||
final class const SocketLikeException() implements Exception;
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
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';
|
||||
|
||||
// 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).
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
LlmRequest _request() => LlmRequest(
|
||||
messages: [
|
||||
LlmMessage.user(const [LlmText('What does 猫 mean?')]),
|
||||
],
|
||||
maxOutputTokens: 400,
|
||||
system: 'You help with flashcards.',
|
||||
personId: 'person-1',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('AssistantRepository', () {
|
||||
late FakeAssistantGateway gateway;
|
||||
late FakeLlmClient llm;
|
||||
late AssistantRepository repository;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAssistantGateway()
|
||||
..offer = const AssistantsOffer(assistants: [_claude], ownKeys: true);
|
||||
llm = FakeLlmClient();
|
||||
repository = AssistantRepository(gateway, llm: llm);
|
||||
addTearDown(repository.dispose);
|
||||
});
|
||||
|
||||
test('asks a granted assistant through the instance, by its ID, and '
|
||||
'never its provider directly', () async {
|
||||
final finished = LlmFinished(
|
||||
message: LlmMessage.assistant(const [LlmText('Cat.')]),
|
||||
stop: LlmStop.end,
|
||||
usage: const LlmUsage(inputTokens: 10, outputTokens: 2),
|
||||
);
|
||||
gateway.answers.add([const LlmTextDelta('Cat.'), finished]);
|
||||
|
||||
final events = await repository
|
||||
.ask(const GrantedAssistant(_claude), _request())
|
||||
.toList();
|
||||
|
||||
expect(events.last, same(finished));
|
||||
expect(gateway.asked.single.$1, 'claude');
|
||||
expect(llm.sent, isEmpty);
|
||||
});
|
||||
|
||||
test("asks a person's own assistant directly, with their key, without "
|
||||
'their ID, and never through the instance', () async {
|
||||
llm.replyText('Cat.');
|
||||
|
||||
final events = await repository
|
||||
.ask(const PersonalAssistant(_own), _request())
|
||||
.toList();
|
||||
|
||||
expect((events.last as LlmFinished).message.text, 'Cat.');
|
||||
final (endpoint, request) = llm.sent.single;
|
||||
expect(endpoint.protocol, LlmProtocol.openai);
|
||||
expect(endpoint.host, 'api.openai.com');
|
||||
expect(endpoint.model, 'gpt-5');
|
||||
expect(endpoint.apiKey, 'sk-own');
|
||||
expect(request.personId, isNull);
|
||||
expect(request.system, 'You help with flashcards.');
|
||||
expect(request.maxOutputTokens, 400);
|
||||
expect(gateway.calls, isEmpty);
|
||||
});
|
||||
|
||||
test("a personal key's provider failing ends the answer as the app's "
|
||||
'failure', () async {
|
||||
llm.fail(LlmFailureKind.key);
|
||||
|
||||
await expectLater(
|
||||
repository.ask(const PersonalAssistant(_own), _request()),
|
||||
emitsError(const AssistantFailed(AssistantProblem.providerKey)),
|
||||
);
|
||||
});
|
||||
|
||||
test("an own assistant's address that isn't one fails before anything "
|
||||
'is sent', () async {
|
||||
const plain = OwnAssistant(
|
||||
id: 'own-2',
|
||||
name: 'Plain',
|
||||
protocol: LlmProtocol.openai,
|
||||
baseUrl: 'ftp://models.example.org',
|
||||
model: 'local',
|
||||
apiKey: 'sk-plain',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.ask(const PersonalAssistant(plain), _request()),
|
||||
emitsError(const AssistantFailed(AssistantProblem.providerFailed)),
|
||||
);
|
||||
expect(llm.sent, isEmpty);
|
||||
});
|
||||
|
||||
test('agreeing records it and tells what shows the assistants', () async {
|
||||
final changed = expectLater(repository.changes, emits(null));
|
||||
|
||||
await repository.agree('claude');
|
||||
|
||||
await changed;
|
||||
expect((await repository.offered()).assistants.single.agreed, isTrue);
|
||||
});
|
||||
|
||||
test('agreeing to an assistant not granted fails, and changes '
|
||||
'nothing', () async {
|
||||
var changes = 0;
|
||||
repository.changes.listen((_) => changes++);
|
||||
|
||||
await expectLater(
|
||||
repository.agree('gpt'),
|
||||
throwsA(const AssistantFailed(AssistantProblem.notGranted)),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(changes, 0);
|
||||
});
|
||||
|
||||
test("dispose closes the personal keys' HTTP client", () async {
|
||||
await repository.dispose();
|
||||
expect(llm.closed, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('AssistantOperatorRepository', () {
|
||||
const anna = 'account-anna';
|
||||
const annabel = 'account-annabel';
|
||||
late FakeAssistantOperatorGateway gateway;
|
||||
late FakeInstanceOperatorGateway accounts;
|
||||
late AssistantOperatorRepository repository;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAssistantOperatorGateway()
|
||||
..set = const AssistantsSetUp(
|
||||
assistants: [
|
||||
SetUpAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
),
|
||||
],
|
||||
)
|
||||
..accounts.addAll({anna: 'Anna', annabel: 'annabel'});
|
||||
accounts = FakeInstanceOperatorGateway()
|
||||
..operates = true
|
||||
..add(OperatedAccount(id: anna, handle: 'Anna', since: DateTime(2026)))
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: annabel,
|
||||
handle: 'annabel',
|
||||
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 {
|
||||
final changed = expectLater(repository.changes, emits(null));
|
||||
|
||||
await repository.grant(
|
||||
handle: ' @anna ',
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1000000,
|
||||
monthlyLimit: null,
|
||||
);
|
||||
|
||||
await changed;
|
||||
final grant = gateway.granted.single;
|
||||
expect(grant.account, anna);
|
||||
expect(grant.unit, AssistantLimitUnit.money);
|
||||
expect(grant.dailyLimit, 1000000);
|
||||
expect(grant.monthlyLimit, isNull);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(
|
||||
accounts.calls.where((call) => call == 'accounts '),
|
||||
isEmpty,
|
||||
reason: 'nothing typed is never looked up',
|
||||
);
|
||||
});
|
||||
|
||||
test("the instance's refusals pass through, and change nothing", () async {
|
||||
var changes = 0;
|
||||
repository.changes.listen((_) => changes++);
|
||||
|
||||
await expectLater(
|
||||
repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'gpt',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.assistantNotSet)),
|
||||
);
|
||||
await expectLater(
|
||||
repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: -1,
|
||||
monthlyLimit: null,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.invalidLimit)),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(changes, 0);
|
||||
});
|
||||
|
||||
test('changing limits keeps the agreement; taking one back removes '
|
||||
'it', () async {
|
||||
await repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: 2,
|
||||
);
|
||||
gateway.granted[0] = AssistantGrant(
|
||||
account: anna,
|
||||
handle: 'Anna',
|
||||
assistant: 'claude',
|
||||
grantedAt: gateway.granted[0].grantedAt,
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: 2,
|
||||
agreedAt: DateTime.utc(2026, 10, 25),
|
||||
);
|
||||
|
||||
await repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 5,
|
||||
monthlyLimit: null,
|
||||
);
|
||||
expect(gateway.granted.single.agreedAt, DateTime.utc(2026, 10, 25));
|
||||
expect(gateway.granted.single.dailyLimit, 5);
|
||||
|
||||
await repository.revoke(account: anna, assistant: 'claude');
|
||||
expect(gateway.granted, isEmpty);
|
||||
});
|
||||
|
||||
test('asks the figures of the UTC month a moment is in', () async {
|
||||
await repository.figures(DateTime.utc(2026, 10, 31, 23, 30));
|
||||
await repository.figures(DateTime.utc(2026, 11));
|
||||
|
||||
expect(gateway.calls, [
|
||||
'figures ${DateTime.utc(2026, 10).toIso8601String()}',
|
||||
'figures ${DateTime.utc(2026, 11).toIso8601String()}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
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;
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart'
|
||||
as protocol
|
||||
show AssistantLimitUnit, AssistantsSetUp;
|
||||
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
|
||||
/// endpoint and method. Asking streams over a WebSocket, which this can't
|
||||
/// fake: `eventsOf` is tested on its own.
|
||||
final class _Instance() {
|
||||
static final anna = UuidValue.withValidation(
|
||||
'0191f0c4-1111-7000-8000-000000000001',
|
||||
);
|
||||
|
||||
/// What each call refuses with: an HTTP status, or the module's
|
||||
/// exception.
|
||||
final refusals = <String, Object>{};
|
||||
|
||||
/// What each call sent, by its endpoint and method.
|
||||
final bodies = <String, String>{};
|
||||
|
||||
static final figures = [
|
||||
AssistantMonthFigures(
|
||||
account: anna,
|
||||
handle: 'anna',
|
||||
assistant: 'claude',
|
||||
month: DateTime.utc(2026, 10),
|
||||
requests: 3,
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
costMicros: 8100,
|
||||
),
|
||||
];
|
||||
|
||||
static Object? _answer(String call) => switch (call) {
|
||||
'halkyon_base.assistant/offered' => AssistantsOffered(
|
||||
assistants: [
|
||||
AssistantOffer(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
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,
|
||||
),
|
||||
],
|
||||
ownKeys: true,
|
||||
currency: 'EUR',
|
||||
).toJson(),
|
||||
'halkyon_base.assistantOperator/setUp' => protocol.AssistantsSetUp(
|
||||
assistants: [
|
||||
AssistantInfo(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
images: true,
|
||||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
),
|
||||
AssistantInfo(
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
protocol: 'openai',
|
||||
host: 'models.example.org',
|
||||
model: 'qwen',
|
||||
images: false,
|
||||
),
|
||||
],
|
||||
currency: 'EUR',
|
||||
ownKeys: false,
|
||||
).toJson(),
|
||||
'halkyon_base.assistantOperator/grants' => [
|
||||
AssistantGrantOverview(
|
||||
account: anna,
|
||||
handle: 'anna',
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
limitUnit: protocol.AssistantLimitUnit.tokens,
|
||||
monthlyLimit: 2000000,
|
||||
outdated: true,
|
||||
).toJson(),
|
||||
],
|
||||
'halkyon_base.assistant/figures' ||
|
||||
'halkyon_base.assistantOperator/figures' => [
|
||||
for (final each in figures) each.toJson(),
|
||||
],
|
||||
_ => null,
|
||||
};
|
||||
|
||||
http.Client client() => MockClient((request) async {
|
||||
final call = request.url.path.substring(1);
|
||||
bodies[call] = request.body;
|
||||
if (refusals[call] case final refusal?) {
|
||||
return switch (refusal) {
|
||||
final int status => http.Response('', status),
|
||||
final AssistantRefusedException refused => http.Response(
|
||||
jsonEncode({
|
||||
'className': 'halkyon_base.AssistantRefusedException',
|
||||
'data': refused.toJson(),
|
||||
}),
|
||||
400,
|
||||
),
|
||||
final OperatorRefusedException refused => http.Response(
|
||||
jsonEncode({
|
||||
'className': 'halkyon_base.OperatorRefusedException',
|
||||
'data': refused.toJson(),
|
||||
}),
|
||||
400,
|
||||
),
|
||||
_ => throw ArgumentError.value(refusal),
|
||||
};
|
||||
}
|
||||
return switch (call) {
|
||||
'halkyon_base.assistant/agree' ||
|
||||
'halkyon_base.assistantOperator/grant' ||
|
||||
'halkyon_base.assistantOperator/revoke' => http.Response('null', 200),
|
||||
_ when _answer(call) != null => http.Response(
|
||||
jsonEncode(_answer(call)),
|
||||
200,
|
||||
),
|
||||
_ => http.Response('', 404),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// What a person's and an operator's apps ask of the module's assistants
|
||||
/// (Halkyon Learn's ADR 0021, step 2), and its refusals in the app's terms.
|
||||
void main() {
|
||||
late _Instance instance;
|
||||
late BaseClient app;
|
||||
|
||||
setUp(() {
|
||||
instance = _Instance();
|
||||
app = BaseClient(
|
||||
'https://learn.example.org/',
|
||||
httpClientOverride: instance.client(),
|
||||
);
|
||||
addTearDown(app.close);
|
||||
});
|
||||
|
||||
group('the person', () {
|
||||
late ServerpodAssistantGateway gateway;
|
||||
|
||||
setUp(() => gateway = ServerpodAssistantGateway(app.modules));
|
||||
|
||||
test('reads what is offered, priced in money, with what is left', () async {
|
||||
final offer = await gateway.offered();
|
||||
|
||||
expect(
|
||||
offer,
|
||||
AssistantsOffer(
|
||||
assistants: [
|
||||
OfferedAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
images: true,
|
||||
agreedAt: DateTime.utc(2026, 10, 25, 9),
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1000000,
|
||||
usedToday: 8100,
|
||||
usedThisMonth: 8100,
|
||||
),
|
||||
],
|
||||
ownKeys: true,
|
||||
currency: 'EUR',
|
||||
),
|
||||
);
|
||||
expect(offer.assistants.single.leftToday, 991900);
|
||||
expect(offer.assistants.single.leftThisMonth, isNull);
|
||||
});
|
||||
|
||||
test("agrees by the assistant's ID, and reads its own figures", () async {
|
||||
await gateway.agree('claude');
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistant/agree']!),
|
||||
{'assistant': 'claude'},
|
||||
);
|
||||
|
||||
expect(await gateway.figures(), [
|
||||
AssistantUse(
|
||||
assistant: 'claude',
|
||||
month: DateTime.utc(2026, 10),
|
||||
requests: 3,
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
costMicros: 8100,
|
||||
account: '${_Instance.anna}',
|
||||
handle: 'anna',
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test("the instance's refusals, in the app's terms", () async {
|
||||
final resets = DateTime.utc(2026, 10, 26);
|
||||
instance.refusals['halkyon_base.assistant/agree'] =
|
||||
AssistantRefusedException(
|
||||
refusal: AssistantRefusal.notGranted,
|
||||
resetsAt: resets,
|
||||
);
|
||||
await expectLater(
|
||||
gateway.agree('gpt'),
|
||||
throwsA(
|
||||
AssistantFailed(AssistantProblem.notGranted, resetsAt: resets),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('without the scope or a session, not allowed; out of reach, '
|
||||
'failed', () async {
|
||||
for (final (status, problem) in [
|
||||
(401, AssistantProblem.notAllowed),
|
||||
(403, AssistantProblem.notAllowed),
|
||||
(500, AssistantProblem.failed),
|
||||
]) {
|
||||
instance.refusals['halkyon_base.assistant/offered'] = status;
|
||||
await expectLater(
|
||||
gateway.offered(),
|
||||
throwsA(AssistantFailed(problem)),
|
||||
reason: '$status',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('the operator', () {
|
||||
late ServerpodAssistantOperatorGateway gateway;
|
||||
|
||||
setUp(() => gateway = ServerpodAssistantOperatorGateway(app.modules));
|
||||
|
||||
test('reads the assistants set, priced or not, 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.named('gpt'), isNull);
|
||||
});
|
||||
|
||||
test('reads grants, one of an assistant no longer set', () async {
|
||||
expect(await gateway.grants(), [
|
||||
AssistantGrant(
|
||||
account: '${_Instance.anna}',
|
||||
handle: 'anna',
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
monthlyLimit: 2000000,
|
||||
outdated: true,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('grants 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,
|
||||
);
|
||||
await gateway.revoke(account: anna, assistant: 'claude');
|
||||
await gateway.figures(DateTime.utc(2026, 10));
|
||||
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/grant']!),
|
||||
{
|
||||
'account': anna,
|
||||
'assistant': 'claude',
|
||||
'dailyLimit': 1000000,
|
||||
'monthlyLimit': null,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/revoke']!),
|
||||
{'account': anna, 'assistant': 'claude'},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/figures']!),
|
||||
{'month': '2026-10-01T00:00:00.000Z'},
|
||||
);
|
||||
});
|
||||
|
||||
test("an account ID that isn't one fails, and nothing is sent", () async {
|
||||
await expectLater(
|
||||
gateway.revoke(account: 'anna', assistant: 'claude'),
|
||||
throwsA(const OperatorFailed(OperatorProblem.failed)),
|
||||
);
|
||||
expect(instance.bodies, isEmpty);
|
||||
});
|
||||
|
||||
test("the instance's refusals, in the app's terms", () async {
|
||||
for (final (refusal, problem) in [
|
||||
(OperatorRefusal.notFound, OperatorProblem.notFound),
|
||||
(OperatorRefusal.assistantNotSet, OperatorProblem.assistantNotSet),
|
||||
(OperatorRefusal.invalidLimit, OperatorProblem.invalidLimit),
|
||||
]) {
|
||||
instance.refusals['halkyon_base.assistantOperator/grant'] =
|
||||
OperatorRefusedException(reason: refusal);
|
||||
await expectLater(
|
||||
gateway.grant(
|
||||
account: '${_Instance.anna}',
|
||||
assistant: 'claude',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
),
|
||||
throwsA(OperatorFailed(problem)),
|
||||
reason: refusal.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
await expectLater(
|
||||
gateway.grants(),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notAllowed)),
|
||||
reason: '$status',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue