feat(learn): asking an assistant in a conversation

ConversationCubit asks the assistant chosen each question with the
conversation so far (ADR 0021, D47), answers the model's lookups on the
device within the question's scope, eight rounds at most, the ninth told
to answer with what it found and the tenth ending the turn, every call
given its result so the conversation can still be sent; and keeps each
turn, sealed, once it is answered (D51): nothing of a question stopped
or failed, which can be asked again, and nothing at all on a device that
can't seal, which still answers (D52). The question and the parts sent
along are texts of their own, so a kept turn shows as it was asked, with
its lookups and its answer (TurnView).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 20:14:44 +01:00
commit a125215942
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1250 additions and 124 deletions

View file

@ -8,7 +8,9 @@ import 'package:halkyon_llm/halkyon_llm.dart';
// the question with what the person ticked, labelled, and what it is told
// about how far it may look.
String _text(LlmMessage message) => (message.parts.single as LlmText).text;
List<String> _texts(LlmMessage message) => [
for (final part in message.parts) (part as LlmText).text,
];
void main() {
group('the question', () {
@ -16,24 +18,23 @@ void main() {
final message = questionMessage(' Why て-form? ', const []);
expect(message.role, LlmRole.user);
expect(_text(message), 'Why て-form?');
expect(_texts(message), ['Why て-form?']);
});
test('carries each part ticked, labelled, in the order ticked', () {
test('carries each part ticked, labelled, in the order ticked, each on '
'a blank line as a provider joining them reads it', () {
final message = questionMessage('Why?', const [
AssistantPart(kind: AssistantPartKind.front, text: '食べて'),
AssistantPart(kind: AssistantPartKind.hint, text: 'te-form', number: 2),
AssistantPart(kind: AssistantPartKind.studyset, text: 'Verbs'),
]);
expect(
_text(message),
'Why?\n\n'
'What it is about:\n\n'
"The card's front:\n食べて\n\n"
"The card's hint 2:\nte-form\n\n"
'The studyset:\nVerbs',
);
expect(_texts(message), [
'Why?',
"\n\nThe card's front:\n食べて",
"\n\nThe card's hint 2:\nte-form",
'\n\nThe studyset:\nVerbs',
]);
});
test('cuts each part as a ticked part is cut', () {
@ -42,8 +43,27 @@ void main() {
AssistantPart(kind: AssistantPartKind.back, text: long),
]);
expect(_text(message), isNot(contains(long)));
expect(_text(message), endsWith('…'));
expect(_texts(message).last, isNot(contains(long)));
expect(_texts(message).last, endsWith('…'));
});
test('reads back as asked, a question of parts alone too', () {
const parts = [
AssistantPart(kind: AssistantPartKind.back, text: 'to eat'),
AssistantPart(kind: AssistantPartKind.hint, text: 'ichidan', number: 3),
AssistantPart(kind: AssistantPartKind.description, text: 'N5 verbs'),
];
final asked = askedIn(questionMessage('Why?', parts));
expect(asked.question, 'Why?');
expect(asked.parts, parts);
final alone = askedIn(questionMessage('', parts));
expect(alone.question, isEmpty);
expect(alone.parts, parts);
expect(
_texts(questionMessage('', parts)).first,
"The card's back:\nto eat",
);
});
});

View file

@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_lookup.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/turn_view.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
// A kept turn as its conversation shows it: what was asked, the lookups,
// the answer; read from the messages alone, as they are kept.
void main() {
const front = AssistantPart(kind: AssistantPartKind.front, text: '食べて');
test('reads the question and what went along back', () {
final view = TurnView.of([
questionMessage('Why て?', const [front]),
LlmMessage.assistant(const [LlmText('Because.')]),
]);
expect(
view,
const TurnView(question: 'Why て?', sent: [front], answer: 'Because.'),
);
});
test('shows each lookup with what it found, and every text the model '
'wrote', () {
final view = TurnView.of([
questionMessage('Which verbs are irregular?', const []),
LlmMessage.assistant(const [
LlmText('Let me look.'),
LlmToolCall(id: 't1', name: 'find_cards', input: {'query': 'する'}),
]),
LlmMessage.user(const [
LlmToolResult(callId: 't1', content: '[{"id":"a"},{"id":"b"}]'),
]),
LlmMessage.assistant(const [LlmText('する and 来る.')]),
]);
expect(view.lookups, const [
AssistantLookup(kind: LookupKind.cards, query: 'する', found: 2),
]);
expect(view.answer, 'Let me look.\n\nする and 来る.');
expect(view.stoppedEarly, isFalse);
});
test('says when a turn ended on lookups, without an answer', () {
final view = TurnView.of([
questionMessage('Everything?', const []),
LlmMessage.assistant(const [
LlmToolCall(id: 't1', name: 'list_studysets', input: {}),
]),
LlmMessage.user(const [
LlmToolResult(callId: 't1', content: 'Stopped.', isError: true),
]),
]);
expect(view.answer, isEmpty);
expect(view.stoppedEarly, isTrue);
});
test('a question on its way shows as asked, nothing answered yet', () {
expect(
TurnView.of([questionMessage('Hm?', const [])]),
const TurnView(question: 'Hm?', answer: ''),
);
});
}