import 'dart:async'; import 'dart:convert'; import 'package:dart_mcp/client.dart'; import 'package:halkyon_content/rich_text.dart'; import 'package:halkyon_content/testing.dart'; import 'package:halkyon_learn_agent/src/agent_library.dart'; import 'package:halkyon_learn_agent/src/agent_server.dart'; import 'package:halkyon_learn_core/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_core/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_core/feature/card/model/card_content.dart'; import 'package:halkyon_learn_core/feature/feedback/data/feedback_repository.dart'; import 'package:halkyon_learn_core/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_core/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_core/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_core/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_core/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_core/testing.dart'; import 'package:stream_channel/stream_channel.dart'; import 'package:test/test.dart'; /// The agent as Claude Code meets it (Halkyon's ADR 0044, D51): an MCP /// server whose tools read the studysets shared with it and suggest, each /// read after a sync and each suggestion synced at once, a refusal told to /// the model as the tool's error. void main() { late FakeFeedbackWriteGateway written; late ServerConnection server; late List syncs; Exception? syncFails; late String cardId; setUp(() async { final studysetGateway = FakeStudysetGateway() ..receiveRemoteChange( 'japanese', encodeStudysetPayload(StudysetContent.titled('Japanese')), ) ..shared['japanese'] = (space: 'space-1', viewOnly: true); written = FakeFeedbackWriteGateway(); final studysets = StudysetRepository(studysetGateway); final cards = CardRepository(FakeCardGateway()); final notes = NoteRepository(FakeNoteGateway()); final assets = AssetRepository( FakeAssetGateway(), FakeFileServerGateway(), MemoryFileCache(), FakeAssetSealing(), ); cardId = await cards.create( 'japanese', CardContent( front: RichContent.plain('猫'), back: RichContent.plain('dog'), ), ); syncs = []; syncFails = null; final library = AgentLibrary( studysets: studysets, cards: cards, notes: notes, feedback: FeedbackRepository( written, FakeFeedbackServerGateway(), cards, notes, LearningPathRepository(FakeLearningPathGateway()), assets, FakeFeedbackEnvelopes(spaces: {'japanese': 'space-1'}), ), ); final toServer = StreamController(); final toClient = StreamController(); final agent = AgentServer( StreamChannel.withCloseGuarantee( toServer.stream, toClient.sink, ), library, sync: () async { syncs.add('sync'); if (syncFails case final error?) { throw error; } }, ); final client = MCPClient(Implementation(name: 'test', version: '0')); server = client.connectServer( StreamChannel.withCloseGuarantee(toClient.stream, toServer.sink), ); final initialized = await server.initialize( InitializeRequest( protocolVersion: ProtocolVersion.latestSupported, capabilities: client.capabilities, clientInfo: client.implementation, ), ); expect(initialized.instructions, contains('suggest')); server.notifyInitialized(InitializedNotification()); await agent.initialized; addTearDown(() async { await client.shutdown(); await agent.shutdown(); await studysets.dispose(); await cards.dispose(); await notes.dispose(); await assets.dispose(); }); }); Future call(String tool, Map arguments) => server.callTool(CallToolRequest(name: tool, arguments: arguments)); Object? jsonOf(CallToolResult result) => jsonDecode((result.content.single as TextContent).text); test('offers its tools, every one reading or suggesting', () async { expect( [for (final tool in (await server.listTools()).tools) tool.name], unorderedEquals([ 'list_studysets', 'read_studyset', 'search_cards', 'suggest_card_change', 'suggest_new_card', 'suggest_note_change', 'suggest_new_note', 'report', 'list_suggestions', 'reply', ]), ); }); test('reads after a sync, as JSON', () async { final result = await call('read_studyset', {'studyset_id': 'japanese'}); expect(result.isError, isNot(isTrue)); expect(syncs, ['sync']); expect((jsonOf(result)! as Map)['cards'], [ {'id': cardId, 'type': 'frontBack', 'front': '猫', 'back': 'dog'}, ]); }); test('a suggestion goes and is synced at once', () async { final result = await call('suggest_card_change', { 'card_id': cardId, 'back': 'cat', 'message': '猫 is a cat.', }); expect(result.isError, isNot(isTrue)); expect(written.threads.single.json['body'], '猫 is a cat.'); // Before, to find the card; after, to send it. expect(syncs, ['sync', 'sync']); }); test('a refusal, or an argument missing, is an error the model reads, ' 'and nothing is written', () async { final refused = await call('read_studyset', {'studyset_id': 'diary'}); expect(refused.isError, isTrue); expect( (refused.content.single as TextContent).text, contains('No studyset diary'), ); final missing = await call('suggest_new_note', {'studyset_id': 'japanese'}); expect(missing.isError, isTrue); expect(written.threads, isEmpty); }); test('a sync that fails leaves what is held to read, and says so', () async { syncFails = Exception('offline'); final result = await call('list_studysets', {}); expect(result.isError, isNot(isTrue)); final json = jsonOf(result)! as Map; expect(json['studysets'], hasLength(1)); expect(json['warning'], contains('Not synced')); }); }