feat(app,learn,todo): the assistants in Settings and on the operator's screen
ADR 0021, step 2's app half. halkyon_app asks the module's assistant endpoints (ServerpodAssistantGateway, ServerpodAssistantOperatorGateway) and a person's own provider directly (halkyon_llm), behind AssistantRepository: an AssistantChoice is a GrantedAssistant, asked through the instance, which counts it, or a PersonalAssistant with the person's own key, which the instance never sees. assistant_messages.dart turns a conversation into the endpoint's models and back. AssistantsCard, for an app's settings: what is granted and left of its limits, agreed to once on a sheet saying what goes where, the person's use by UTC month, the instance's privacy page, and their own assistants where the operator allows own keys (kept ones stay listed, unasked, where it doesn't). AssistantGrantsCard on InstanceOperatorScreen: grants by handle with their limits in the pricing's unit, changed and taken back after asking, everyone's use by month; a found account's menu grants too. Nothing shows where nothing is offered or can be read. package:halkyon_app/assistant_models.dart holds the models in pure Dart. Learn keeps own assistants in the person's settings (ownAssistants, schema 12, SettingsOwnAssistants); SettingsRepository.update refuses (OwnAssistantRefused) to write a key where the settings would not be sealed (SettingsGateway.seals). Learn depends on halkyon_llm directly. Todo wires the gateways; a member offers no assistant. No feature asks an assistant yet (step 3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1e5d7040fd
commit
36899cdc3b
4 changed files with 7505 additions and 46 deletions
|
|
@ -0,0 +1,67 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart'
|
||||
show OwnAssistant, OwnAssistantRefused;
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/settings_own_assistants.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
|
||||
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
|
||||
// The person's own assistants as Learn keeps them: in their settings,
|
||||
// sealed with their key (ADR 0021, point 10).
|
||||
|
||||
const _claude = OwnAssistant(
|
||||
id: 'own-1',
|
||||
name: 'My Claude',
|
||||
protocol: LlmProtocol.anthropic,
|
||||
model: 'claude-sonnet-5-5',
|
||||
apiKey: 'sk-ant-own',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late FakeSettingsGateway gateway;
|
||||
late SettingsRepository settings;
|
||||
late SettingsOwnAssistants store;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeSettingsGateway();
|
||||
settings = SettingsRepository(gateway);
|
||||
store = SettingsOwnAssistants(settings);
|
||||
});
|
||||
tearDown(() => settings.dispose());
|
||||
|
||||
test('follows the assistants kept, saved, changed and removed', () async {
|
||||
final watched = expectLater(
|
||||
store.watch(),
|
||||
emitsInOrder([
|
||||
isEmpty,
|
||||
[_claude],
|
||||
[
|
||||
isA<OwnAssistant>().having((own) => own.model, 'model', 'opus'),
|
||||
],
|
||||
isEmpty,
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await store.save(_claude);
|
||||
await store.save(
|
||||
const OwnAssistant(
|
||||
id: 'own-1',
|
||||
name: 'My Claude',
|
||||
protocol: LlmProtocol.anthropic,
|
||||
model: 'opus',
|
||||
apiKey: 'sk-ant-own',
|
||||
),
|
||||
);
|
||||
await store.remove('own-1');
|
||||
await watched;
|
||||
});
|
||||
|
||||
test("refuses a key where the settings wouldn't be sealed", () async {
|
||||
gateway.sealing = false;
|
||||
|
||||
await expectLater(store.save(_claude), throwsA(isA<OwnAssistantRefused>()));
|
||||
expect(gateway.writes, isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart'
|
||||
show OwnAssistant, OwnAssistantRefused;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
|
||||
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
|
||||
|
|
@ -116,4 +119,35 @@ void main() {
|
|||
);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
group("the person's own assistants (ADR 0021, point 10)", () {
|
||||
const own = OwnAssistant(
|
||||
id: 'own-1',
|
||||
name: 'My Claude',
|
||||
protocol: LlmProtocol.anthropic,
|
||||
model: 'claude-sonnet-5-5',
|
||||
apiKey: 'sk-ant-own',
|
||||
);
|
||||
|
||||
test('are kept where the settings are sealed', () async {
|
||||
await repository.update((s) => s.withOwnAssistant(own));
|
||||
|
||||
expect((await repository.read()).ownAssistants, [own]);
|
||||
});
|
||||
|
||||
test("are refused where the settings wouldn't be sealed, and nothing "
|
||||
'is written; other settings still are', () async {
|
||||
gateway.sealing = false;
|
||||
|
||||
await expectLater(
|
||||
repository.update((s) => s.withOwnAssistant(own)),
|
||||
throwsA(isA<OwnAssistantRefused>()),
|
||||
);
|
||||
expect(gateway.writes, isEmpty);
|
||||
|
||||
await repository.update((s) => s.withStudyTags('japanese', [verb]));
|
||||
expect(gateway.writes, hasLength(1));
|
||||
expect((await repository.read()).ownAssistants, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/assistant_models.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
|
||||
|
|
@ -7,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profil
|
|||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
|
||||
|
||||
void main() {
|
||||
const verb = (id: 'tag-1', name: 'verb');
|
||||
|
|
@ -488,4 +490,117 @@ void main() {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
group("the person's own assistants (ADR 0021, point 10)", () {
|
||||
const claude = OwnAssistant(
|
||||
id: 'own-1',
|
||||
name: 'My Claude',
|
||||
protocol: LlmProtocol.anthropic,
|
||||
model: 'claude-sonnet-5-5',
|
||||
apiKey: 'sk-ant-own',
|
||||
images: true,
|
||||
);
|
||||
const local = OwnAssistant(
|
||||
id: 'own-2',
|
||||
name: 'Local',
|
||||
protocol: LlmProtocol.openai,
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
apiKey: '',
|
||||
);
|
||||
|
||||
test('are added in order, changed in place by their ID, and '
|
||||
'removed', () {
|
||||
final settings = const PersonSettings()
|
||||
.withOwnAssistant(claude)
|
||||
.withOwnAssistant(local);
|
||||
const renamed = OwnAssistant(
|
||||
id: 'own-1',
|
||||
name: 'Claude',
|
||||
protocol: LlmProtocol.anthropic,
|
||||
model: 'claude-opus-5-5',
|
||||
apiKey: 'sk-ant-own',
|
||||
);
|
||||
|
||||
expect(settings.ownAssistants, [claude, local]);
|
||||
expect(settings.withOwnAssistant(renamed).ownAssistants, [
|
||||
renamed,
|
||||
local,
|
||||
]);
|
||||
expect(settings.withoutOwnAssistant('own-1').ownAssistants, [local]);
|
||||
});
|
||||
|
||||
test('survive a round trip with their keys, in the twelfth schema, '
|
||||
'left out while there are none; a payload from before has '
|
||||
'none', () {
|
||||
final settings = const PersonSettings()
|
||||
.withOwnAssistant(claude)
|
||||
.withOwnAssistant(local);
|
||||
|
||||
expect(settingsSchemaVersion, greaterThanOrEqualTo(12));
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
expect(
|
||||
decodePayload(
|
||||
encodeSettingsPayload(const PersonSettings()),
|
||||
newestSchema: settingsSchemaVersion,
|
||||
).keys,
|
||||
isNot(contains('ownAssistants')),
|
||||
);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
encodePayload(11, {
|
||||
'studyTags': <String, Object?>{},
|
||||
'ownAssistants': 'not yet',
|
||||
}),
|
||||
).ownAssistants,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test("one in a protocol this app doesn't know, or malformed, is left "
|
||||
'out, the others kept', () {
|
||||
final read = decodeSettingsPayload(
|
||||
encodePayload(settingsSchemaVersion, {
|
||||
'studyTags': <String, Object?>{},
|
||||
'ownAssistants': [
|
||||
{
|
||||
'id': 'own-9',
|
||||
'name': 'Gemini',
|
||||
'protocol': 'gemini',
|
||||
'model': 'gemini-3',
|
||||
'apiKey': 'key',
|
||||
},
|
||||
{'id': 'own-8', 'name': 'No model'},
|
||||
{
|
||||
'id': 'own-1',
|
||||
'name': 'My Claude',
|
||||
'protocol': 'anthropic',
|
||||
'model': 'claude-sonnet-5-5',
|
||||
'apiKey': 'sk-ant-own',
|
||||
'images': true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(read.ownAssistants, [claude]);
|
||||
});
|
||||
|
||||
test('fold by their IDs: these settings win, those only the other has '
|
||||
'are added', () {
|
||||
const changed = OwnAssistant(
|
||||
id: 'own-1',
|
||||
name: 'Changed there',
|
||||
protocol: LlmProtocol.anthropic,
|
||||
model: 'claude-sonnet-5-5',
|
||||
apiKey: 'sk-ant-other',
|
||||
);
|
||||
final here = const PersonSettings().withOwnAssistant(claude);
|
||||
final there = const PersonSettings()
|
||||
.withOwnAssistant(changed)
|
||||
.withOwnAssistant(local);
|
||||
|
||||
expect(here.foldedWith(there).ownAssistants, [claude, local]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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/citation/model/citation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.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/core/version/app_version.dart';
|
||||
|
|
@ -438,4 +439,142 @@ void main() {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Assistants (ADR 0021, step 2)', () {
|
||||
FakeAssistantGateway offering({required bool ownKeys}) =>
|
||||
FakeAssistantGateway()
|
||||
..offer = AssistantsOffer(
|
||||
assistants: const [
|
||||
OfferedAssistant(
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-sonnet-5-5',
|
||||
),
|
||||
],
|
||||
ownKeys: ownKeys,
|
||||
);
|
||||
|
||||
Future<void> show(WidgetTester tester, Dependencies dependencies) async {
|
||||
tester.view.physicalSize = const Size(900, 3000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(tester, dependencies, location: RoutePaths.settings);
|
||||
}
|
||||
|
||||
testWidgets('what is granted shows after the web search', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
testDependencies(assistantGateway: offering(ownKeys: false)),
|
||||
);
|
||||
|
||||
expect(find.text('Assistants'), findsOneWidget);
|
||||
expect(find.text('Claude'), findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Assistants')).dy,
|
||||
greaterThan(tester.getTopLeft(find.text('Web search')).dy),
|
||||
);
|
||||
expect(find.text('With your own key'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('nothing shows where nothing is offered or allowed', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, testDependencies());
|
||||
|
||||
expect(find.text('Assistants'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("an own key is kept in the person's settings, which follow "
|
||||
'them to every device', (tester) async {
|
||||
final settings = FakeSettingsGateway();
|
||||
await show(
|
||||
tester,
|
||||
testDependencies(
|
||||
assistantGateway: offering(ownKeys: true),
|
||||
settingsGateway: settings,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Add an assistant with your own key'));
|
||||
await tester.pumpAndSettle();
|
||||
final sheet = find.byType(SheetScaffold);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.widgetWithText(TextField, 'Name'),
|
||||
),
|
||||
'Mine',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.widgetWithText(TextField, 'Model'),
|
||||
),
|
||||
'claude-opus-5-5',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.widgetWithText(TextField, 'Key'),
|
||||
),
|
||||
'sk-ant-mine',
|
||||
);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.widgetWithText(FilledButton, 'Save'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mine'), findsOneWidget);
|
||||
final kept = (await SettingsRepository(settings).read()).ownAssistants;
|
||||
expect(kept.single.apiKey, 'sk-ant-mine');
|
||||
});
|
||||
|
||||
testWidgets("an own key is refused where this device can't seal the "
|
||||
'settings', (tester) async {
|
||||
final settings = FakeSettingsGateway()..sealing = false;
|
||||
await show(
|
||||
tester,
|
||||
testDependencies(
|
||||
assistantGateway: offering(ownKeys: true),
|
||||
settingsGateway: settings,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Add an assistant with your own key'));
|
||||
await tester.pumpAndSettle();
|
||||
final sheet = find.byType(SheetScaffold);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.widgetWithText(TextField, 'Name'),
|
||||
),
|
||||
'Mine',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.widgetWithText(TextField, 'Model'),
|
||||
),
|
||||
'claude-opus-5-5',
|
||||
);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.widgetWithText(FilledButton, 'Save'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining("this device can't encrypt your settings"),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(settings.writes, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue