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
3 changed files with 474 additions and 50 deletions

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,