feat(learn): Learn's agent, an MCP server suggesting in shared studysets
Some checks failed
CI / check (push) Failing after 1m56s
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>
This commit is contained in:
parent
72cac92365
commit
54e212a51c
4 changed files with 2925 additions and 314 deletions
107
apps/learn/halkyon_learn_agent/test/agent_home_test.dart
Normal file
107
apps/learn/halkyon_learn_agent/test/agent_home_test.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:halkyon_learn_agent/src/agent_home.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Where the agent keeps its token, keys and data (Halkyon's ADR 0044,
|
||||
/// D50): a folder per instance only its user may enter, files only it may
|
||||
/// read, and nothing read that others could have read or changed.
|
||||
void main() {
|
||||
late Directory root;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('agent_home');
|
||||
addTearDown(() => root.delete(recursive: true));
|
||||
});
|
||||
|
||||
int modeOf(String path) => FileStat.statSync(path).mode & 0x1ff;
|
||||
|
||||
test(
|
||||
'each instance has a folder of its own, its user alone entering it',
|
||||
() async {
|
||||
final learn = await AgentHome.open(
|
||||
root,
|
||||
Uri.parse('https://learn.halkyon.eu/'),
|
||||
);
|
||||
final local = await AgentHome.open(
|
||||
root,
|
||||
Uri.parse('http://localhost:8080'),
|
||||
);
|
||||
|
||||
expect(p.basename(learn.folder.path), 'learn.halkyon.eu');
|
||||
expect(p.basename(local.folder.path), 'localhost_8080');
|
||||
expect(modeOf(learn.folder.path), 0x1c0); // 0700
|
||||
},
|
||||
);
|
||||
|
||||
test('the token is kept for its user alone, and read back', () async {
|
||||
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
|
||||
expect(await home.token(), isNull);
|
||||
|
||||
await home.keepToken('hlk_agent_secret\n');
|
||||
|
||||
expect(await home.token(), 'hlk_agent_secret');
|
||||
expect(modeOf(p.join(home.folder.path, 'token')), 0x180); // 0600
|
||||
});
|
||||
|
||||
test(
|
||||
"a token, keys or folder others may read or change isn't read",
|
||||
() async {
|
||||
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
|
||||
await home.keepToken('secret');
|
||||
await Process.run('chmod', ['644', p.join(home.folder.path, 'token')]);
|
||||
|
||||
await expectLater(home.token(), throwsA(isA<AgentHomeRefused>()));
|
||||
|
||||
await Process.run('chmod', ['600', p.join(home.folder.path, 'token')]);
|
||||
await Process.run('chmod', ['755', home.folder.path]);
|
||||
await expectLater(
|
||||
AgentHome.open(root, Uri.parse('https://halkyon.li')),
|
||||
throwsA(isA<AgentHomeRefused>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'the keys are kept for its user alone, and read back as they were',
|
||||
() async {
|
||||
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
|
||||
expect(await home.keys(), isNull);
|
||||
final keys = UnlockedAccountKeys(
|
||||
encryptionPublicKey: Uint8List.fromList(List.filled(32, 1)),
|
||||
encryptionSecretKey: Uint8List.fromList(List.filled(32, 2)),
|
||||
signingPublicKey: Uint8List.fromList(List.filled(32, 3)),
|
||||
signingSecretKey: Uint8List.fromList(List.filled(64, 4)),
|
||||
);
|
||||
|
||||
await home.keepKeys(keys);
|
||||
|
||||
expect(await home.keys(), keys);
|
||||
expect(modeOf(p.join(home.folder.path, 'keys.json')), 0x180);
|
||||
},
|
||||
);
|
||||
|
||||
test('notes once the register holds its public keys', () async {
|
||||
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
|
||||
expect(await home.keysSet(), isFalse);
|
||||
|
||||
await home.noteKeysSet();
|
||||
|
||||
expect(await home.keysSet(), isTrue);
|
||||
});
|
||||
|
||||
test('an instance is reached over HTTPS, or plain HTTP on this machine '
|
||||
'alone', () async {
|
||||
await expectLater(
|
||||
AgentHome.open(root, Uri.parse('http://halkyon.li')),
|
||||
throwsA(isA<AgentHomeRefused>()),
|
||||
);
|
||||
await expectLater(
|
||||
AgentHome.open(root, Uri.parse('ftp://halkyon.li')),
|
||||
throwsA(isA<AgentHomeRefused>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
328
apps/learn/halkyon_learn_agent/test/agent_library_test.dart
Normal file
328
apps/learn/halkyon_learn_agent/test/agent_library_test.dart
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
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_core/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_core/core/profile/public_person.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/feedback/model/feedback.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/note/model/note_content.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:test/test.dart';
|
||||
|
||||
/// What the agent reads and suggests (Halkyon's ADR 0044, D7, D10, D51):
|
||||
/// the studysets shared with it alone, their cards and notes as text, and
|
||||
/// every change as a suggestion in the studyset's space, never a write.
|
||||
void main() {
|
||||
late FakeStudysetGateway studysetGateway;
|
||||
late FakeCardGateway cardGateway;
|
||||
late FakeNoteGateway noteGateway;
|
||||
late FakeFeedbackWriteGateway written;
|
||||
late FakeFeedbackServerGateway server;
|
||||
late CardRepository cards;
|
||||
late NoteRepository notes;
|
||||
late AgentLibrary library;
|
||||
late String cardId;
|
||||
late String noteId;
|
||||
|
||||
setUp(() async {
|
||||
studysetGateway = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'kanji',
|
||||
encodeStudysetPayload(StudysetContent.titled('Kanji')),
|
||||
parentId: 'japanese',
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'diary',
|
||||
encodeStudysetPayload(StudysetContent.titled('Diary')),
|
||||
)
|
||||
..shared['japanese'] = (space: 'space-1', viewOnly: true)
|
||||
..shared['kanji'] = (space: 'space-1', viewOnly: true);
|
||||
cardGateway = FakeCardGateway();
|
||||
noteGateway = FakeNoteGateway();
|
||||
written = FakeFeedbackWriteGateway();
|
||||
server = FakeFeedbackServerGateway();
|
||||
final studysets = StudysetRepository(studysetGateway);
|
||||
cards = CardRepository(cardGateway);
|
||||
notes = NoteRepository(noteGateway);
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeFileServerGateway(),
|
||||
MemoryFileCache(),
|
||||
FakeAssetSealing(),
|
||||
);
|
||||
addTearDown(() async {
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await assets.dispose();
|
||||
});
|
||||
cardId = await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.plain('dog'),
|
||||
),
|
||||
);
|
||||
await cards.create(
|
||||
'diary',
|
||||
CardContent(
|
||||
front: RichContent.plain('Secret'),
|
||||
back: RichContent.plain('Mine'),
|
||||
),
|
||||
);
|
||||
noteId = (await notes.create(
|
||||
'kanji',
|
||||
NoteContent(title: 'Radicals', body: RichContent.plain('氵 is water.')),
|
||||
)).noteId;
|
||||
library = AgentLibrary(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
feedback: FeedbackRepository(
|
||||
written,
|
||||
server,
|
||||
cards,
|
||||
notes,
|
||||
LearningPathRepository(FakeLearningPathGateway()),
|
||||
assets,
|
||||
FakeFeedbackEnvelopes(
|
||||
spaces: {'japanese': 'space-1', 'kanji': 'space-1'},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('lists the studysets shared with it, never one of its own '
|
||||
'space', () async {
|
||||
final listed = await library.sharedStudysets();
|
||||
|
||||
expect([for (final s in listed) s['id']], ['japanese', 'kanji']);
|
||||
expect(listed.first, {
|
||||
'id': 'japanese',
|
||||
'title': 'Japanese',
|
||||
'parent': null,
|
||||
'role': 'viewer',
|
||||
'cards': 1,
|
||||
'notes': 0,
|
||||
});
|
||||
expect(listed.last['parent'], 'japanese');
|
||||
});
|
||||
|
||||
test("reads a studyset's cards and notes as text; one not shared with it "
|
||||
"isn't there", () async {
|
||||
final japanese = await library.studyset('japanese');
|
||||
expect(japanese['cards'], [
|
||||
{'id': cardId, 'type': 'frontBack', 'front': '猫', 'back': 'dog'},
|
||||
]);
|
||||
expect(japanese['topics'], ['kanji']);
|
||||
final kanji = await library.studyset('kanji');
|
||||
expect(kanji['notes'], [
|
||||
{'id': noteId, 'title': 'Radicals', 'body': '氵 is water.'},
|
||||
]);
|
||||
|
||||
await expectLater(
|
||||
library.studyset('diary'),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
await expectLater(
|
||||
library.studyset('nothing'),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('searches the shared cards alone', () async {
|
||||
expect(
|
||||
[for (final card in await library.search('猫')) card['id']],
|
||||
[cardId],
|
||||
);
|
||||
expect(await library.search('secret'), isEmpty);
|
||||
});
|
||||
|
||||
test("a change to a card is suggested in its studyset's space, the card "
|
||||
'unchanged', () async {
|
||||
await library.suggestCardChange(
|
||||
cardId,
|
||||
back: 'cat',
|
||||
message: '猫 is a cat.',
|
||||
);
|
||||
|
||||
final thread = written.threads.single;
|
||||
expect(thread.space, 'space-1');
|
||||
expect(thread.json['target'], {
|
||||
'kind': 'card',
|
||||
'item': cardId,
|
||||
'version': 0,
|
||||
});
|
||||
expect(thread.json['body'], '猫 is a cat.');
|
||||
final proposed = thread.json['proposed']! as Map<String, Object?>;
|
||||
expect(
|
||||
RichContent.fromJson(proposed['back']! as Map<String, Object?>).plainText,
|
||||
'cat',
|
||||
);
|
||||
expect((await cards.byId(cardId))!.content.back.plainText, 'dog');
|
||||
});
|
||||
|
||||
test('new cards and notes are suggested for a shared studyset, nothing '
|
||||
'added', () async {
|
||||
await library.suggestNewCard(
|
||||
'kanji',
|
||||
front: '水',
|
||||
back: 'water',
|
||||
message: '',
|
||||
);
|
||||
await library.suggestNewNote(
|
||||
'japanese',
|
||||
title: 'Particles',
|
||||
body: 'は marks the topic.\nが the subject.',
|
||||
message: 'A start.',
|
||||
);
|
||||
await library.suggestNoteChange(
|
||||
noteId,
|
||||
title: 'Radicals (部首)',
|
||||
message: '',
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final t in written.threads) t.json['target']],
|
||||
[
|
||||
{'kind': 'newCard', 'item': 'kanji', 'version': 0},
|
||||
{'kind': 'newNote', 'item': 'japanese', 'version': 0},
|
||||
{'kind': 'note', 'item': noteId, 'version': 0},
|
||||
],
|
||||
);
|
||||
expect(await cards.cardsOf('kanji'), isEmpty);
|
||||
expect(await notes.notesOf('japanese'), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'a vocabulary card is suggested with its reading and meanings',
|
||||
() async {
|
||||
await library.suggestNewCard(
|
||||
'japanese',
|
||||
type: 'vocabulary',
|
||||
front: '犬',
|
||||
reading: 'いぬ',
|
||||
meanings: ['dog'],
|
||||
message: '',
|
||||
);
|
||||
|
||||
final proposed =
|
||||
written.threads.single.json['proposed']! as Map<String, Object?>;
|
||||
expect(proposed['type'], 'vocabulary');
|
||||
expect(proposed['reading'], 'いぬ');
|
||||
expect(proposed['meanings'], ['dog']);
|
||||
},
|
||||
);
|
||||
|
||||
test('nothing is suggested in a studyset not shared with it, on an item '
|
||||
"it can't find, or of a kind it can't write", () async {
|
||||
await expectLater(
|
||||
library.suggestNewCard('diary', front: 'x', back: 'y', message: ''),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
await expectLater(
|
||||
library.suggestCardChange('nothing', back: 'y', message: ''),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
final mine = (await cards.cardsOf('diary')).single.id;
|
||||
await expectLater(
|
||||
library.suggestCardChange(mine, back: 'y', message: ''),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
await expectLater(
|
||||
library.suggestNewCard(
|
||||
'japanese',
|
||||
type: 'matching',
|
||||
front: 'x',
|
||||
message: '',
|
||||
),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
await expectLater(
|
||||
library.report('card', mine, message: 'Delete it.'),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
expect(written.threads, isEmpty);
|
||||
});
|
||||
|
||||
test('a report asks for something shared to go, saying why', () async {
|
||||
await library.report('note', noteId, message: 'A duplicate.');
|
||||
await expectLater(
|
||||
library.report('card', cardId, message: ' '),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
|
||||
final thread = written.threads.single;
|
||||
expect(thread.kind, FeedbackKind.report);
|
||||
expect(thread.json['target'], {
|
||||
'kind': 'note',
|
||||
'item': noteId,
|
||||
'version': 0,
|
||||
});
|
||||
expect(thread.json['body'], 'A duplicate.');
|
||||
});
|
||||
|
||||
test("a studyset's suggestions are listed with their replies, and "
|
||||
'replied to', () async {
|
||||
server.gathered = [
|
||||
FeedbackThread(
|
||||
threadId: 'thread-1',
|
||||
publicId: 'space-1',
|
||||
studysetTitle: '',
|
||||
location: '',
|
||||
kind: FeedbackKind.suggestion,
|
||||
writer: const PublicPerson(handle: 'claude'),
|
||||
target: FeedbackTarget(
|
||||
kind: FeedbackTargetKind.card,
|
||||
item: cardId,
|
||||
version: 0,
|
||||
),
|
||||
createdAt: DateTime.utc(2026, 11, 11),
|
||||
body: '猫 is a cat.',
|
||||
status: FeedbackStatus.declined,
|
||||
messages: [
|
||||
FeedbackMessage(
|
||||
id: 'm-1',
|
||||
by: const PublicPerson(handle: 'alice'),
|
||||
fromAuthor: true,
|
||||
body: 'It is a pun.',
|
||||
createdAt: DateTime.utc(2026, 11, 11, 1),
|
||||
),
|
||||
],
|
||||
space: 'space-1',
|
||||
studysetId: 'japanese',
|
||||
),
|
||||
];
|
||||
|
||||
final listed = await library.suggestions('japanese');
|
||||
expect(listed.single, {
|
||||
'id': 'thread-1',
|
||||
'kind': 'suggestion',
|
||||
'target': {'kind': 'card', 'item': cardId},
|
||||
'by': '@claude',
|
||||
'status': 'declined',
|
||||
'message': '猫 is a cat.',
|
||||
'replies': [
|
||||
{'by': '@alice', 'message': 'It is a pun.'},
|
||||
],
|
||||
});
|
||||
|
||||
await library.reply('japanese', 'thread-1', message: 'Thanks!');
|
||||
expect(written.messages.single.json['body'], 'Thanks!');
|
||||
await expectLater(
|
||||
library.reply('japanese', 'thread-9', message: 'Hm?'),
|
||||
throwsA(isA<AgentRefused>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
184
apps/learn/halkyon_learn_agent/test/agent_server_test.dart
Normal file
184
apps/learn/halkyon_learn_agent/test/agent_server_test.dart
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
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'));
|
||||
});
|
||||
}
|
||||
60
apps/learn/halkyon_learn_agent/test/agent_sync_test.dart
Normal file
60
apps/learn/halkyon_learn_agent/test/agent_sync_test.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_learn_agent/src/agent_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The agent's sync (Halkyon's ADR 0044, D7), the `SyncRequests` its
|
||||
/// gateways ask: a burst of requests syncs once after a pause, a tool's
|
||||
/// sync runs at once and says whether it went, and what a sync merged is
|
||||
/// heard.
|
||||
void main() {
|
||||
late List<String> runs;
|
||||
late Exception? fails;
|
||||
late AgentSync sync;
|
||||
|
||||
setUp(() {
|
||||
runs = [];
|
||||
fails = null;
|
||||
sync = AgentSync(({required onMerge}) async {
|
||||
runs.add('sync');
|
||||
if (fails case final error?) {
|
||||
throw error;
|
||||
}
|
||||
onMerge();
|
||||
}, debounce: const Duration(milliseconds: 50));
|
||||
addTearDown(sync.dispose);
|
||||
});
|
||||
|
||||
test('requests in a burst sync once, after the pause', () async {
|
||||
sync
|
||||
..requestSync()
|
||||
..requestSync()
|
||||
..requestSync();
|
||||
expect(runs, isEmpty);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
expect(runs, ['sync']);
|
||||
});
|
||||
|
||||
test('a sync now runs at once, and what it merged is heard', () async {
|
||||
final merged = sync.merges.first;
|
||||
final synced = sync.synced.first;
|
||||
|
||||
await sync.syncNow();
|
||||
|
||||
expect(runs, ['sync']);
|
||||
await merged;
|
||||
await synced;
|
||||
});
|
||||
|
||||
test('a sync now that fails says so; the next that goes through '
|
||||
"doesn't", () async {
|
||||
fails = Exception('offline');
|
||||
await expectLater(sync.syncNow(), throwsA(isA<Exception>()));
|
||||
|
||||
fails = null;
|
||||
await sync.syncNow();
|
||||
expect(runs, ['sync', 'sync']);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue