halkyon/packages/halkyon_app/test/assistant/data/assistant_messages_test.dart
Tizian 「ティツィアーン」 cc87120bc8
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>
2026-10-26 16:21:53 +01:00

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;