feat(learn): choosing how much an assistant thinks, and the assistants by provider in the field

Halkyon Learn's ADR 0021, D60 and D63:

- A chip after the scope's offers the efforts the operator allows for the
  assistant asked, its own marked; the one picked is kept per assistant on
  the device (DeviceSettings.askEfforts) and asked at, also by a
  conversation continued from the history. One the operator no longer
  allows counts as none.
- Every question asks for its effort's ceiling on output, as the thinking
  counts towards it (8,192 to 65,536 tokens; 16,384 at the model's own),
  where it asked for 4,096; the flashcards proposed, Explain and Why? ask
  at the assistant's own effort, a person's own key at none and 4,096.
- The field's picker lists the assistants under their providers' names,
  each in its colour.

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-26 16:05:49 +01:00
commit 8b5bdfed8c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 474 additions and 50 deletions

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import '../../support/memory_device_settings_store.dart';
@ -128,6 +129,44 @@ void main() {
expect(asked.withLanguage('de').askWith, 'assistant:claude');
});
test('the effort an assistant is asked at, kept per assistant with the '
'rest (D63)', () async {
final store = MemoryDeviceSettingsStore();
final bloc =
DeviceSettingsBloc(
store,
const DeviceSettings(askWith: 'assistant:opus'),
)..add(
const DeviceSettingsEventEffortChosen(
'assistant:opus',
LlmEffort.high,
),
);
addTearDown(bloc.close);
const chosen = DeviceSettings(
askWith: 'assistant:opus',
askEfforts: {'assistant:opus': LlmEffort.high},
);
await next(bloc, chosen);
bloc.add(
const DeviceSettingsEventEffortChosen('assistant:haiku', LlmEffort.low),
);
await next(
bloc,
const DeviceSettings(
askWith: 'assistant:opus',
askEfforts: {
'assistant:opus': LlmEffort.high,
'assistant:haiku': LlmEffort.low,
},
),
);
await pumpEventQueue();
expect(store.writes.first, chosen);
expect(store.writes.last.withLanguage('de').askEfforts, hasLength(2));
});
test('the search engine chosen, and back to the default, keep the rest', () {
const before = DeviceSettings(assistant: true, language: 'de');

View file

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
void main() {
late Directory folder;
@ -102,6 +103,29 @@ void main() {
expect((await FileDeviceSettingsStore(file).read()).askWith, 'own:o-1');
});
test('keeps the effort each assistant was last asked at, and reads none '
'it knows not (D63)', () async {
expect((await store.read()).askEfforts, isEmpty);
await store.write(
const DeviceSettings(
askEfforts: {'assistant:opus': LlmEffort.high},
),
);
expect((await FileDeviceSettingsStore(file).read()).askEfforts, {
'assistant:opus': LlmEffort.high,
});
file.writeAsStringSync(
'{"askEfforts": {"assistant:opus": "deep", "assistant:haiku": "low", '
'"assistant:sonnet": 3}}',
);
expect((await FileDeviceSettingsStore(file).read()).askEfforts, {
'assistant:haiku': LlmEffort.low,
});
});
test('reads a file from before the hand-off went (ADR 0022), and writes '
'it anew without the assistant chosen', () async {
file.writeAsStringSync(

View file

@ -80,6 +80,12 @@ void main() {
expect(assistant, 'claude');
expect(request.tools, [proposeCardsTool]);
expect(request.system, proposalPrompt());
expect(
request.effort,
isNull,
reason: "the assistant's own effort: nobody chose one here (D63)",
);
expect(request.maxOutputTokens, LlmEffort.defaultOutputCeiling);
expect(
(request.messages.single.parts.single as LlmText).text,
'Verbs: 食べる is to eat.',

View file

@ -131,6 +131,7 @@ void main() {
AssistantGateway? asking,
AssistantTask task = AssistantTask.ask,
String? title,
LlmEffort? effort,
}) => ConversationCubit(
assistants: AssistantRepository(asking ?? gateway),
lookups: lookups,
@ -141,6 +142,7 @@ void main() {
conversationId: conversationId,
task: task,
title: title,
effort: effort,
answerIn: 'en',
);
@ -182,7 +184,12 @@ void main() {
final (assistant, request) = gateway.asked.single;
expect(assistant, 'claude');
expect(request.maxOutputTokens, ConversationCubit.maxOutputTokens);
expect(
request.maxOutputTokens,
LlmEffort.defaultOutputCeiling,
reason: "the model's own effort thinks within the default ceiling",
);
expect(request.effort, isNull);
expect(request.tools, isEmpty, reason: 'only what is ticked goes (D50)');
expect(request.system, contains('nothing to look up'));
@ -195,6 +202,60 @@ void main() {
await conversation.close();
});
test('asks every question at the effort the person chose, with its '
'ceiling (D63)', () async {
const thinking = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus',
effort: LlmEffort.low,
efforts: [LlmEffort.low, LlmEffort.medium, LlmEffort.high],
);
final conversation = cubit(
choice: const GrantedAssistant(thinking),
effort: LlmEffort.high,
);
await conversation.open();
gateway.answers
..add(_says('Because it joins.'))
..add(_says('Yes.'));
await conversation.ask('Why て-form?', const []);
await conversation.ask('Always?', const []);
for (final (_, request) in gateway.asked) {
expect(request.effort, LlmEffort.high);
expect(request.maxOutputTokens, LlmEffort.high.outputCeiling);
}
expect(gateway.asked, hasLength(2));
await conversation.close();
});
test("without an effort chosen, it asks at the assistant's, within its "
'ceiling', () async {
const thinking = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus',
effort: LlmEffort.low,
efforts: [LlmEffort.low, LlmEffort.medium],
);
final conversation = cubit(choice: const GrantedAssistant(thinking));
await conversation.open();
gateway.answers.add(_says('Because it joins.'));
await conversation.ask('Why て-form?', const []);
final (_, request) = gateway.asked.single;
expect(request.effort, isNull, reason: 'the instance applies its own');
expect(request.maxOutputTokens, LlmEffort.low.outputCeiling);
await conversation.close();
});
test('sends the conversation so far with a follow-up, and keeps it in the '
'same conversation', () async {
final conversation = cubit();

View file

@ -167,11 +167,14 @@ void main() {
/// The languages the instance's LanguageTool checks (ADR 0024); none
/// without.
List<String>? grammarLanguages,
/// The efforts this device last asked each assistant at (D63).
Map<String, LlmEffort> askEfforts = const {},
}) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final initial = DeviceSettings(assistant: web);
final initial = DeviceSettings(assistant: web, askEfforts: askEfforts);
store = MemoryDeviceSettingsStore(initial);
await pumpApp(
tester,
@ -300,6 +303,109 @@ void main() {
expect((await store.read()).askWith, 'web');
});
testWidgets('the picker groups the assistants by their provider, each '
'under its name in its colour (D60)', (tester) async {
assistants.offer = AssistantsOffer(assistants: [_claude(), _seeing]);
await showApp(tester, web: true);
await tester.tap(find.byTooltip('Who answers'));
await tester.pumpAndSettle();
double top(String text) => tester.getTopLeft(find.text(text).last).dy;
expect(find.text('Anthropic'), findsOneWidget);
expect(find.text('OpenAI'), findsOneWidget);
expect(find.byType(ProviderDot), findsNWidgets(2));
expect(top('Web search'), lessThan(top('Anthropic')));
expect(top('Anthropic'), lessThan(top('Claude')));
expect(top('Claude'), lessThan(top('OpenAI')));
expect(top('OpenAI'), lessThan(top('Seeing')));
});
group('effort (D63)', () {
const tip = 'Effort: how much it thinks before it answers';
final opus = OfferedAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus',
agreedAt: _agreed,
effort: LlmEffort.low,
efforts: const [LlmEffort.low, LlmEffort.medium, LlmEffort.high],
);
testWidgets("is chosen in the field among the operator's levels alone, "
'remembered for the assistant on this device, and asked at', (
tester,
) async {
assistants.offer = AssistantsOffer(assistants: [opus]);
await showApp(tester);
expect(find.widgetWithText(FilterChip, 'Low'), findsOneWidget);
await tester.tap(find.byTooltip(tip));
await tester.pumpAndSettle();
expect(find.text('Low (default)'), findsOneWidget);
expect(find.text('Medium'), findsOneWidget);
expect(find.text('High'), findsOneWidget);
expect(find.text('Extra high'), findsNothing);
expect(find.text('Max'), findsNothing);
await tester.tap(find.text('High'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.widgetWithText(FilterChip, 'High'), findsOneWidget);
expect((await store.read()).askEfforts, {
'assistant:opus': LlmEffort.high,
});
assistants.answers.add(_says('Because it joins clauses.'));
await ask(tester, 'Why て-form?');
final (_, request) = assistants.asked.single;
expect(request.effort, LlmEffort.high);
expect(request.maxOutputTokens, LlmEffort.high.outputCeiling);
});
testWidgets("left alone, the question goes at the assistant's own", (
tester,
) async {
assistants.offer = AssistantsOffer(assistants: [opus]);
await showApp(tester);
assistants.answers.add(_says('Because.'));
await ask(tester, 'Why?');
final (_, request) = assistants.asked.single;
expect(request.effort, isNull, reason: 'the instance applies its own');
expect(request.maxOutputTokens, LlmEffort.low.outputCeiling);
});
testWidgets('one remembered that the operator no longer allows is '
"dropped for the assistant's own", (tester) async {
assistants.offer = AssistantsOffer(assistants: [opus]);
await showApp(tester, askEfforts: {'assistant:opus': LlmEffort.max});
expect(find.widgetWithText(FilterChip, 'Low'), findsOneWidget);
assistants.answers.add(_says('Because.'));
await ask(tester, 'Why?');
expect(assistants.asked.single.$2.effort, isNull);
});
testWidgets('an assistant without levels to choose, and the web search, '
'show none', (tester) async {
await showApp(tester, web: true);
expect(find.text('Ask Claude…'), findsOneWidget);
expect(find.byTooltip(tip), findsNothing);
await tester.tap(find.byTooltip('Who answers'));
await tester.pumpAndSettle();
await tester.tap(find.text('Web search'));
await tester.pumpAndSettle();
expect(find.byTooltip(tip), findsNothing);
});
});
testWidgets('on a studyset, the assistant looks further only once the '
"person widens it, and the studyset's name may go along", (
tester,