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>
355 lines
11 KiB
Dart
355 lines
11 KiB
Dart
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.invalidEffort,
|
|
AssistantProblem.invalidEffort,
|
|
),
|
|
(
|
|
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;
|