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(), 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;