Some checks failed
CI / check (push) Failing after 1m56s
Halkyon's ADR 0044, step 6c (D7, D49 to D52). apps/learn/halkyon_learn_agent is one executable, `halkyon-agent --instance URL sign-in | sync | mcp`: - sign-in keeps the token read from stdin, signs in with it, makes the agent's key pair on its first sign-in, keeps it before setting the public halves on the register (agents.setKeys), and syncs; - it opens its data as the app's _openPersonData does, on halkyon_learn_core and halkyon_content: its own SQLite database, the sync's space router, its keys and those of the spaces shared with it, and the repositories of studysets, cards, notes, paths, files and feedback; - mcp serves ten tools over stdio (dart_mcp): list, read and search the studysets shared with it, suggest a change or a new card or note, report, list a studyset's suggestions and reply. Each syncs first and a suggestion again at once; a refusal is the tool's error; - its files live in $XDG_CONFIG_HOME/halkyon-agent/<host>, 0700 and 0600, refused when others may read them (D50); - tool/install.sh builds it with `dart build cli` (libsodium and SQLite bundled) and copies it to its user on the host, the build before kept (D52). The synced gateways of learning paths and files, and the fakes of the core's gateways, move into halkyon_learn_core (its testing.dart), which the app's tests and the agent's share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
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'));
|
|
});
|
|
}
|