feat(assistant): Claude reads studysets through a connector on the instance
A Claude plan can't drive a chat inside another app, but Claude's apps
(every plan, the phone apps too) can add a remote MCP server as a custom
connector. The instance now is one (ADR 0006): Serverpod's web server
serves the MCP endpoint (/mcp, Streamable HTTP, JSON answers, no
sessions) with five read-only tools over the person's own synced rows
(their studysets, a studyset's cards and notes a page at a time, a
search across cards, a note, their progress), and the OAuth the
specification asks for: protected resource and authorization server
metadata, dynamic client registration, authorization code with PKCE
(S256 only), tokens bound to the resource, iss in the response, refresh
tokens that rotate. Caddy forwards these paths alone to the web server,
which production now runs on 8082.
People connect with a code from the app (Settings › Assistant › Connect
Claude): eight Crockford characters, valid for ten minutes and once,
typed on the authorization page, which names where it returns to, loads
nothing from elsewhere and may not be framed. Anonymous people can
connect too. Codes and tokens are kept only as SHA-256 hashes; an access
token lives an hour, a refresh token 30 days. The app lists what is
connected and disconnects it, which ends its tokens at once; deleting
the account deletes it; a daily job forgets what expired and clients
nobody connected within a week. InstanceInfo says where the connector is
(additive); the migration only adds server-only tables, so devices and
older apps are untouched.
The handlers take plain requests, so the integration tests drive the
whole flow (registration, the code, tokens, refresh, the tools, another
person's content, disconnecting, expiry, pruning, account deletion)
without a web server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 12:13:00 +02:00
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
import 'package:flutter_test/flutter_test.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/model/connected_assistant.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
|
|
|
|
|
|
import '../../support/fake_assistant_gateway.dart';
|
|
|
|
|
import '../../support/pump_app.dart';
|
|
|
|
|
import '../../support/test_dependencies.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late FakeAssistantGateway gateway;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
gateway = FakeAssistantGateway(
|
|
|
|
|
initially: [
|
|
|
|
|
ConnectedAssistant(
|
|
|
|
|
id: 1,
|
|
|
|
|
name: 'Claude',
|
|
|
|
|
host: 'claude.ai',
|
|
|
|
|
connectedAt: DateTime.utc(2026, 10, 9),
|
|
|
|
|
lastUsedAt: DateTime.utc(2026, 10, 10),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Future<void> showApp(
|
|
|
|
|
WidgetTester tester, {
|
|
|
|
|
String location = RoutePaths.connectAssistant,
|
|
|
|
|
bool on = true,
|
|
|
|
|
}) async {
|
|
|
|
|
tester.view.physicalSize = const Size(400, 1000);
|
|
|
|
|
tester.view.devicePixelRatio = 1;
|
|
|
|
|
addTearDown(tester.view.reset);
|
|
|
|
|
await pumpApp(
|
|
|
|
|
tester,
|
|
|
|
|
testDependencies(
|
|
|
|
|
assistantGateway: gateway,
|
|
|
|
|
deviceSettings: DeviceSettings(assistant: on),
|
|
|
|
|
),
|
|
|
|
|
location: location,
|
|
|
|
|
);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Brings [finder] in Settings into view.
|
|
|
|
|
Future<void> scrollTo(WidgetTester tester, Finder finder) async {
|
|
|
|
|
await tester.scrollUntilVisible(
|
|
|
|
|
finder,
|
|
|
|
|
200,
|
|
|
|
|
scrollable: find
|
|
|
|
|
.descendant(
|
|
|
|
|
of: find.byType(SettingsScreen),
|
|
|
|
|
matching: find.byType(Scrollable),
|
|
|
|
|
)
|
|
|
|
|
.first,
|
|
|
|
|
);
|
|
|
|
|
await tester.ensureVisible(finder);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
testWidgets('Settings offers to connect Claude only while asking Claude is '
|
|
|
|
|
'on', (tester) async {
|
|
|
|
|
await showApp(tester, location: RoutePaths.settings, on: false);
|
feat(assistant): search the web too, and choose what goes along
The assistant's field now has a switch at its start: a question goes to
Claude or into a web search, and the field remembers which. A web search
opens the chosen engine's results: on Android the device's own web
search by default (ACTION_WEB_SEARCH, answered by its default browser or
search app; Brave Search where nothing answers), elsewhere Brave Search
by default; Settings › Assistant › Search engine offers Ecosia,
DuckDuckGo, Startpage, Qwant, Google, Bing, or one's own address with
%s where the question goes.
Above the field, chips offer what the question is about: a card's
front, back, each hint and its explanation, the studyset's name and
description. None is chosen, so only what the person picks goes along,
each part cut at 600 characters; Claude gets the question and a compact
line per chosen part, a web search the question and the chosen text,
never the studyset's name. A chosen chip alone is enough to send. The
old sentences around every question (where it was asked, the connector)
are gone: they cost tokens for nothing unless the studyset is chosen.
On a studyset's screen the button now stands above the bottom bar, as in
the Library: unfolded inside the bar, its chips lay outside the bar's
bounds, where taps don't land.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 14:41:36 +02:00
|
|
|
await scrollTo(tester, find.text('Ask Claude or search the web'));
|
feat(assistant): Claude reads studysets through a connector on the instance
A Claude plan can't drive a chat inside another app, but Claude's apps
(every plan, the phone apps too) can add a remote MCP server as a custom
connector. The instance now is one (ADR 0006): Serverpod's web server
serves the MCP endpoint (/mcp, Streamable HTTP, JSON answers, no
sessions) with five read-only tools over the person's own synced rows
(their studysets, a studyset's cards and notes a page at a time, a
search across cards, a note, their progress), and the OAuth the
specification asks for: protected resource and authorization server
metadata, dynamic client registration, authorization code with PKCE
(S256 only), tokens bound to the resource, iss in the response, refresh
tokens that rotate. Caddy forwards these paths alone to the web server,
which production now runs on 8082.
People connect with a code from the app (Settings › Assistant › Connect
Claude): eight Crockford characters, valid for ten minutes and once,
typed on the authorization page, which names where it returns to, loads
nothing from elsewhere and may not be framed. Anonymous people can
connect too. Codes and tokens are kept only as SHA-256 hashes; an access
token lives an hour, a refresh token 30 days. The app lists what is
connected and disconnects it, which ends its tokens at once; deleting
the account deletes it; a daily job forgets what expired and clients
nobody connected within a week. InstanceInfo says where the connector is
(additive); the migration only adds server-only tables, so devices and
older apps are untouched.
The handlers take plain requests, so the integration tests drive the
whole flow (registration, the code, tokens, refresh, the tools, another
person's content, disconnecting, expiry, pruning, account deletion)
without a web server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 12:13:00 +02:00
|
|
|
expect(find.text('Connect Claude'), findsNothing);
|
|
|
|
|
|
feat(assistant): search the web too, and choose what goes along
The assistant's field now has a switch at its start: a question goes to
Claude or into a web search, and the field remembers which. A web search
opens the chosen engine's results: on Android the device's own web
search by default (ACTION_WEB_SEARCH, answered by its default browser or
search app; Brave Search where nothing answers), elsewhere Brave Search
by default; Settings › Assistant › Search engine offers Ecosia,
DuckDuckGo, Startpage, Qwant, Google, Bing, or one's own address with
%s where the question goes.
Above the field, chips offer what the question is about: a card's
front, back, each hint and its explanation, the studyset's name and
description. None is chosen, so only what the person picks goes along,
each part cut at 600 characters; Claude gets the question and a compact
line per chosen part, a web search the question and the chosen text,
never the studyset's name. A chosen chip alone is enough to send. The
old sentences around every question (where it was asked, the connector)
are gone: they cost tokens for nothing unless the studyset is chosen.
On a studyset's screen the button now stands above the bottom bar, as in
the Library: unfolded inside the bar, its chips lay outside the bar's
bounds, where taps don't land.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 14:41:36 +02:00
|
|
|
await tester.tap(find.text('Ask Claude or search the web'));
|
feat(assistant): Claude reads studysets through a connector on the instance
A Claude plan can't drive a chat inside another app, but Claude's apps
(every plan, the phone apps too) can add a remote MCP server as a custom
connector. The instance now is one (ADR 0006): Serverpod's web server
serves the MCP endpoint (/mcp, Streamable HTTP, JSON answers, no
sessions) with five read-only tools over the person's own synced rows
(their studysets, a studyset's cards and notes a page at a time, a
search across cards, a note, their progress), and the OAuth the
specification asks for: protected resource and authorization server
metadata, dynamic client registration, authorization code with PKCE
(S256 only), tokens bound to the resource, iss in the response, refresh
tokens that rotate. Caddy forwards these paths alone to the web server,
which production now runs on 8082.
People connect with a code from the app (Settings › Assistant › Connect
Claude): eight Crockford characters, valid for ten minutes and once,
typed on the authorization page, which names where it returns to, loads
nothing from elsewhere and may not be framed. Anonymous people can
connect too. Codes and tokens are kept only as SHA-256 hashes; an access
token lives an hour, a refresh token 30 days. The app lists what is
connected and disconnects it, which ends its tokens at once; deleting
the account deletes it; a daily job forgets what expired and clients
nobody connected within a week. InstanceInfo says where the connector is
(additive); the migration only adds server-only tables, so devices and
older apps are untouched.
The handlers take plain requests, so the integration tests drive the
whole flow (registration, the code, tokens, refresh, the tools, another
person's content, disconnecting, expiry, pruning, account deletion)
without a web server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 12:13:00 +02:00
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await scrollTo(tester, find.text('Connect Claude'));
|
|
|
|
|
await tester.tap(find.text('Connect Claude'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.byType(ConnectClaudeScreen), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("shows the connector's address to add in Claude, to copy", (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final copied = <Object?>[];
|
|
|
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
|
|
|
SystemChannels.platform,
|
|
|
|
|
(call) async {
|
|
|
|
|
if (call.method == 'Clipboard.setData') {
|
|
|
|
|
copied.add((call.arguments as Map)['text']);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
addTearDown(
|
|
|
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
|
|
|
SystemChannels.platform,
|
|
|
|
|
null,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.text('https://learn.example.org/mcp'), findsOneWidget);
|
|
|
|
|
expect(find.textContaining('learn.example.org through'), findsOneWidget);
|
|
|
|
|
await tester.tap(find.byTooltip('Copy'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(copied, ['https://learn.example.org/mcp']);
|
|
|
|
|
expect(find.text('Copied'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('shows a code on asking, and a new one replaces it', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Show a code'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('K7QX-M2P0'), findsOneWidget);
|
|
|
|
|
expect(find.textContaining('Valid until'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('New code'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('K7QX-M2P0'), findsNothing);
|
|
|
|
|
expect(find.text('K7QX-M2P1'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('lists what is connected; disconnecting asks first', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
expect(find.text('Claude'), findsOneWidget);
|
|
|
|
|
expect(find.textContaining('claude.ai'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.widgetWithText(TextButton, 'Disconnect'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Disconnect Claude?'), findsOneWidget);
|
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Disconnect'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(gateway.connected, isEmpty);
|
|
|
|
|
expect(find.text('Nothing connected yet.'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('an instance without a connector says so, and shows no steps', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
gateway = FakeAssistantGateway(url: null);
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.textContaining("doesn't offer a connector"), findsOneWidget);
|
|
|
|
|
expect(find.text('Show a code'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('an instance out of reach says so, and asks again on Try '
|
|
|
|
|
'again', (tester) async {
|
|
|
|
|
gateway.unreachable = true;
|
|
|
|
|
await showApp(tester);
|
|
|
|
|
expect(find.textContaining("can't be reached"), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
gateway.unreachable = false;
|
|
|
|
|
await tester.tap(find.text('Try again'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.textContaining("can't be reached"), findsNothing);
|
|
|
|
|
expect(find.text('https://learn.example.org/mcp'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
}
|