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>
328 lines
10 KiB
Dart
328 lines
10 KiB
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_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>()),
|
|
);
|
|
});
|
|
}
|