feat(learn): the assistant answers in the field
The field asks an assistant the instance offers as well as searching the web (ADR 0021): who answers is picked at its start, the one asked last first (askWith, a device setting), an assistant not agreed to yet asking for agreement first. Asking an assistant, every part of the topic may go along, and one chip leads them with how far it may look (D49), only what is chosen until the person widens it to the studyset, its tree or the library, private studysets only where switched on (D50, D8). The conversation shows in a sheet over the screen (D47), half the height and dragged up to the full: each question with what went along, the lookups, the answer as Markdown once whole, follow-ups, Stop, and Try again for a question that got no answer; a device that can't seal says it keeps nothing (D52). The field lists the conversations kept (D48), those about where the person is first, to open again or delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a125215942
commit
806ffae202
4 changed files with 2312 additions and 72 deletions
|
|
@ -118,6 +118,16 @@ void main() {
|
|||
expect(before.withBrowserView('a', detailed: true).assistant, isTrue);
|
||||
});
|
||||
|
||||
test('who answered last, kept with the rest', () {
|
||||
const before = DeviceSettings(assistant: true, searchEngine: 'ecosia');
|
||||
|
||||
final asked = before.withAskWith('assistant:claude');
|
||||
|
||||
expect(asked.askWith, 'assistant:claude');
|
||||
expect(asked.searchEngine, 'ecosia');
|
||||
expect(asked.withLanguage('de').askWith, 'assistant:claude');
|
||||
});
|
||||
|
||||
test('the search engine chosen, and back to the default, keep the rest', () {
|
||||
const before = DeviceSettings(assistant: true, language: 'de');
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ void main() {
|
|||
expect(read.customSearchUrl, 'https://search.example.org/?q=%s');
|
||||
});
|
||||
|
||||
test('keeps who answered last in the field', () async {
|
||||
expect((await store.read()).askWith, isNull);
|
||||
|
||||
await store.write(const DeviceSettings(askWith: 'own:o-1'));
|
||||
|
||||
expect((await FileDeviceSettingsStore(file).read()).askWith, 'own:o-1');
|
||||
});
|
||||
|
||||
test('reads a file from before the hand-off went (ADR 0022), and writes '
|
||||
'it anew without the assistant chosen', () async {
|
||||
file.writeAsStringSync(
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ void main() {
|
|||
explanation: paragraph('A kanji of the five elements.'),
|
||||
);
|
||||
final parts = AssistantTopicCard(
|
||||
studysetId: 'japanese',
|
||||
studysetTitle: 'Japanese',
|
||||
card: card,
|
||||
).parts;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_conversation_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/memory_device_settings_store.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
// Asking an assistant from the field (ADR 0021, D47 to D52): who answers
|
||||
// picked in the field, how far it may look widened only by the person, the
|
||||
// conversation in a sheet over the screen, kept conversations listed in the
|
||||
// field.
|
||||
|
||||
final _agreed = DateTime.utc(2026, 10, 25);
|
||||
|
||||
OfferedAssistant _claude({bool agreed = true}) => OfferedAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet',
|
||||
agreedAt: agreed ? _agreed : null,
|
||||
);
|
||||
|
||||
List<LlmEvent> _says(String text) => [
|
||||
LlmTextDelta(text),
|
||||
LlmFinished(
|
||||
message: LlmMessage.assistant([LlmText(text)]),
|
||||
stop: LlmStop.end,
|
||||
usage: const LlmUsage(inputTokens: 10, outputTokens: 5),
|
||||
),
|
||||
];
|
||||
|
||||
ByteData _studyset(String title) => encodeStudysetPayload(
|
||||
StudysetContent(title: title, description: RichContent.empty()),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late FakeAssistantGateway assistants;
|
||||
late FakeConversationGateway kept;
|
||||
late FakeStudysetGateway studysets;
|
||||
late MemoryDeviceSettingsStore store;
|
||||
late List<Uri> searched;
|
||||
|
||||
setUp(() {
|
||||
assistants = FakeAssistantGateway()
|
||||
..offer = AssistantsOffer(assistants: [_claude()]);
|
||||
kept = FakeConversationGateway();
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange('japanese', _studyset('Japanese'))
|
||||
..receiveRemoteChange('verbs', _studyset('Verbs'), parentId: 'japanese');
|
||||
searched = [];
|
||||
});
|
||||
|
||||
Future<void> showApp(
|
||||
WidgetTester tester, {
|
||||
bool web = false,
|
||||
String location = RoutePaths.home,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final initial = DeviceSettings(assistant: web);
|
||||
store = MemoryDeviceSettingsStore(initial);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
assistantGateway: assistants,
|
||||
conversationGateway: kept,
|
||||
studysetGateway: studysets,
|
||||
webSearch: WebSearch(
|
||||
open: (url) async {
|
||||
searched.add(url);
|
||||
return true;
|
||||
},
|
||||
deviceSearch: (_) async => false,
|
||||
),
|
||||
deviceSettings: initial,
|
||||
deviceSettingsStore: store,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Types [question] into the field and asks it with the field's button.
|
||||
Future<void> ask(WidgetTester tester, String question) async {
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
question,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byTooltip('Ask'),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Unfolds the floating button, an assistant answering.
|
||||
Future<void> unfold(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Ask'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("Home's field asks the assistant granted, and the answer comes "
|
||||
'in a sheet over Home, kept', (tester) async {
|
||||
await showApp(tester);
|
||||
expect(find.text('Ask Claude…'), findsOneWidget);
|
||||
assistants.answers.add(_says('Because it joins clauses.'));
|
||||
|
||||
await ask(tester, 'Why て-form?');
|
||||
|
||||
expect(find.text('Why て-form?'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Because it joins clauses.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
final (assistant, request) = assistants.asked.single;
|
||||
expect(assistant, 'claude');
|
||||
expect(request.tools, isEmpty, reason: 'only what is chosen (D50)');
|
||||
expect(kept.written, hasLength(2), reason: 'the conversation and a turn');
|
||||
expect((await store.read()).askWith, 'assistant:claude');
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Because it joins clauses.', findRichText: true),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('an assistant not agreed to asks for agreement first, sending '
|
||||
'nothing (D40)', (tester) async {
|
||||
assistants.offer = AssistantsOffer(assistants: [_claude(agreed: false)]);
|
||||
await showApp(tester);
|
||||
|
||||
await ask(tester, 'Why?');
|
||||
|
||||
expect(find.text('Before you ask Claude'), findsOneWidget);
|
||||
expect(assistants.asked, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('the web search and the assistants are picked from the field, '
|
||||
'the one asked last first', (tester) async {
|
||||
await showApp(tester, web: true);
|
||||
expect(find.text('Ask Claude…'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Who answers'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Web search'), findsOneWidget);
|
||||
await tester.tap(find.text('Web search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Search the web…'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField).last, 'は vs が');
|
||||
await tester.pump();
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byTooltip('Search'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(searched.single.queryParameters['q'], 'は vs が');
|
||||
expect(assistants.asked, isEmpty);
|
||||
expect((await store.read()).askWith, 'web');
|
||||
});
|
||||
|
||||
testWidgets('on a studyset, the assistant looks further only once the '
|
||||
"person widens it, and the studyset's name may go along", (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await unfold(tester);
|
||||
expect(find.text('Only what I choose'), findsOneWidget);
|
||||
expect(find.text('Studyset: Japanese'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('My whole library'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Its whole tree, from the top'),
|
||||
findsNothing,
|
||||
reason: 'a studyset at the top is its tree',
|
||||
);
|
||||
await tester.tap(find.text('This studyset with its topics'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('This studyset'), findsOneWidget);
|
||||
|
||||
assistants.answers.add(_says('Verbs, mostly.'));
|
||||
await ask(tester, 'What is in here?');
|
||||
|
||||
final request = assistants.asked.single.$2;
|
||||
expect(
|
||||
[for (final tool in request.tools) tool.name],
|
||||
['list_studysets', 'find_cards', 'find_notes', 'read_note'],
|
||||
);
|
||||
expect(request.system, contains('the studyset “Japanese”'));
|
||||
});
|
||||
|
||||
testWidgets("a topic's scope also reaches its whole tree", (tester) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('verbs'));
|
||||
await unfold(tester);
|
||||
|
||||
await tester.tap(find.text('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Its whole tree, from the top'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('kept conversations are listed in the field, those about '
|
||||
'where the person is first; one opens in its sheet, and goes after '
|
||||
'asking (D48)', (tester) async {
|
||||
final conversations = ConversationRepository(kept);
|
||||
addTearDown(conversations.dispose);
|
||||
final id = await conversations.start(
|
||||
question: 'Why て-form?',
|
||||
assistant: const AssistantRef(id: 'claude'),
|
||||
scope: const AssistantScope(),
|
||||
);
|
||||
await conversations.keep(id, [
|
||||
LlmMessage.user(const [LlmText('Why て-form?')]),
|
||||
LlmMessage.assistant(const [LlmText('It joins.')]),
|
||||
], LlmUsage.none);
|
||||
await showApp(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Conversations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Why て-form?'), findsOneWidget);
|
||||
expect(find.textContaining('Claude · Only what I choose'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Why て-form?'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('It joins.', findRichText: true), findsOneWidget);
|
||||
expect(find.text('Ask more…'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('Conversations'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Delete conversation'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No conversations yet'), findsOneWidget);
|
||||
expect(kept.blanked, [id]);
|
||||
});
|
||||
|
||||
testWidgets("a device that can't seal answers, and says it keeps nothing "
|
||||
'(D52)', (tester) async {
|
||||
kept.sealing = false;
|
||||
await showApp(tester);
|
||||
assistants.answers.add(_says('Because.'));
|
||||
|
||||
await ask(tester, 'Why?');
|
||||
|
||||
expect(find.textContaining('Not kept'), findsOneWidget);
|
||||
expect(find.text('Because.', findRichText: true), findsOneWidget);
|
||||
expect(kept.written, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a conversation whose assistant has gone is read, not '
|
||||
'continued', (tester) async {
|
||||
final conversations = ConversationRepository(kept);
|
||||
addTearDown(conversations.dispose);
|
||||
final id = await conversations.start(
|
||||
question: 'Old question',
|
||||
assistant: const AssistantRef(id: 'gone'),
|
||||
scope: const AssistantScope(),
|
||||
);
|
||||
await conversations.keep(id, [
|
||||
LlmMessage.user(const [LlmText('Old question')]),
|
||||
LlmMessage.assistant(const [LlmText('Old answer')]),
|
||||
], LlmUsage.none);
|
||||
await showApp(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Conversations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.textContaining('An assistant no longer offered'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Old question'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Old answer', findRichText: true), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('no longer offered to you here'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Ask more…'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a question that failed says why, and Try again asks it '
|
||||
'again', (tester) async {
|
||||
await showApp(tester);
|
||||
assistants.failing = const AssistantFailed(AssistantProblem.providerBusy);
|
||||
|
||||
await ask(tester, 'Why?');
|
||||
|
||||
expect(find.text('Try again'), findsOneWidget);
|
||||
expect(kept.written, isEmpty);
|
||||
|
||||
assistants
|
||||
..failing = null
|
||||
..answers.add(_says('Now it works.'));
|
||||
await tester.tap(find.text('Try again'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Now it works.', findRichText: true), findsOneWidget);
|
||||
expect(find.text('Try again'), findsNothing);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue