184 lines
6.2 KiB
Dart
184 lines
6.2 KiB
Dart
|
|
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<String> 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<String>();
|
||
|
|
final toClient = StreamController<String>();
|
||
|
|
final agent = AgentServer(
|
||
|
|
StreamChannel<String>.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<String>.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<CallToolResult> call(String tool, Map<String, Object?> 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'));
|
||
|
|
});
|
||
|
|
}
|