feat: add session deletion flow

This commit is contained in:
sladro 2026-03-30 14:09:50 +08:00
parent 1cec285c09
commit 4ed8525209
13 changed files with 740 additions and 452 deletions

View File

@ -6,14 +6,17 @@ class AgentApiClient {
final Uri baseUri;
final Dio _dio;
Uri get projectsUri => baseUri.resolve('/api/projects');
Uri get sessionsUri => baseUri.resolve('/api/sessions');
Uri get pairingCodeUri => baseUri.resolve('/api/pairing/code');
Uri get pairingRedeemUri => baseUri.resolve('/api/pairing/redeem');
Uri sessionUri(String sessionId) =>
baseUri.resolve('/api/sessions/$sessionId');
Uri sessionHistoryUri(String sessionId, {int lineCount = 200}) {
return baseUri.resolve('/api/sessions/$sessionId/history').replace(
queryParameters: <String, String>{'lineCount': '$lineCount'},
);
return baseUri
.resolve('/api/sessions/$sessionId/history')
.replace(queryParameters: <String, String>{'lineCount': '$lineCount'});
}
Future<List<Map<String, dynamic>>> listSessions() async {
@ -21,14 +24,72 @@ class AgentApiClient {
return _readJsonList(response.data, 'sessions');
}
Future<Map<String, dynamic>> createSession(String name) async {
Uri projectUri(String projectId) =>
baseUri.resolve('/api/projects/$projectId');
Future<List<Map<String, dynamic>>> listProjects() async {
final response = await _dio.getUri(projectsUri);
return _readJsonList(response.data, 'projects');
}
Future<Map<String, dynamic>> getProjectDetail(String projectId) async {
final response = await _dio.getUri(projectUri(projectId));
return _readJsonMap(response.data, 'project detail');
}
Future<Map<String, dynamic>> createProject({
required String name,
required String workingDirectory,
}) async {
final response = await _dio.postUri(
sessionsUri,
data: <String, String>{'name': name},
projectsUri,
data: <String, String>{
'name': name,
'workingDirectory': workingDirectory,
},
);
return _readJsonMap(response.data, 'project');
}
Future<Map<String, dynamic>> updateProject({
required String projectId,
required String name,
required String workingDirectory,
}) async {
final response = await _dio.putUri(
projectUri(projectId),
data: <String, String>{
'name': name,
'workingDirectory': workingDirectory,
},
);
return _readJsonMap(response.data, 'project');
}
Future<Map<String, dynamic>> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
final data = <String, String>{};
if (name != null && name.isNotEmpty) {
data['name'] = name;
}
if (projectId != null && projectId.isNotEmpty) {
data['projectId'] = projectId;
}
if (workingDirectory != null && workingDirectory.isNotEmpty) {
data['workingDirectory'] = workingDirectory;
}
final response = await _dio.postUri(sessionsUri, data: data);
return _readJsonMap(response.data, 'session');
}
Future<void> deleteSession(String sessionId) async {
await _dio.deleteUri(sessionUri(sessionId));
}
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int lineCount = 200,
@ -45,10 +106,7 @@ class AgentApiClient {
}) {
return _dio.postUri(
pairingRedeemUri,
data: <String, String>{
'code': code,
'deviceName': deviceName,
},
data: <String, String>{'code': code, 'deviceName': deviceName},
);
}

View File

@ -30,9 +30,9 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
}
void _showMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _refreshSessions() async {
@ -82,7 +82,7 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
}
try {
await repository.createSession(trimmedName);
await repository.createSession(name: trimmedName);
await _reloadSessions();
} catch (error) {
if (!mounted) {
@ -93,6 +93,47 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
}
}
Future<void> _deleteSession(Session session) async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete session'),
content: Text(
'Delete "${session.name}"? This removes the saved session and its terminal history.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (shouldDelete != true) {
return;
}
try {
await ref
.read(sessionRepositoryProvider)
.deleteSession(session.sessionId);
await _reloadSessions();
} catch (error) {
if (!mounted) {
return;
}
_showMessage('Failed to delete session: $error');
}
}
void _openSession(Session session) {
Navigator.of(context).push(
MaterialPageRoute(
@ -108,7 +149,9 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
Future<void> _applyAgentUrl() async {
final parsedUri = Uri.tryParse(_agentUrlController.text.trim());
if (parsedUri == null || parsedUri.scheme.isEmpty || parsedUri.host.isEmpty) {
if (parsedUri == null ||
parsedUri.scheme.isEmpty ||
parsedUri.host.isEmpty) {
_showMessage('Enter a valid agent base URL.');
return;
}
@ -201,10 +244,7 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'$error',
textAlign: TextAlign.center,
),
Text('$error', textAlign: TextAlign.center),
],
),
);
@ -251,9 +291,23 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
leading: const Icon(Icons.terminal),
title: Text(session.name),
subtitle: Text('Status: ${session.status}'),
trailing: Text(
session.sessionId,
style: Theme.of(context).textTheme.bodySmall,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
session.sessionId,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 8),
IconButton(
key: Key(
'session_delete_button_${session.sessionId}',
),
tooltip: 'Delete session',
onPressed: () => _deleteSession(session),
icon: const Icon(Icons.delete_outline),
),
],
),
),
);

View File

@ -11,8 +11,20 @@ class SessionRepository {
return sessions.map(Session.fromJson).toList(growable: false);
}
Future<Session> createSession(String name) async {
final session = await _client.createSession(name);
Future<Session> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
final session = await _client.createSession(
name: name,
projectId: projectId,
workingDirectory: workingDirectory,
);
return Session.fromJson(session);
}
Future<void> deleteSession(String sessionId) {
return _client.deleteSession(sessionId);
}
}

View File

@ -14,7 +14,10 @@ void main() {
test('builds pairing urls from base uri', () {
final client = AgentApiClient(Uri.parse('https://host:9443'));
expect(client.pairingCodeUri.toString(), 'https://host:9443/api/pairing/code');
expect(
client.pairingCodeUri.toString(),
'https://host:9443/api/pairing/code',
);
expect(
client.pairingRedeemUri.toString(),
'https://host:9443/api/pairing/redeem',
@ -24,16 +27,8 @@ void main() {
test('lists sessions from the sessions endpoint', () async {
final adapter = _FakeHttpClientAdapter(
jsonEncode([
{
'sessionId': 'abc',
'name': 'codex-main',
'status': 'idle',
},
{
'sessionId': 'def',
'name': 'cloud-code',
'status': 'active',
},
{'sessionId': 'abc', 'name': 'codex-main', 'status': 'idle'},
{'sessionId': 'def', 'name': 'cloud-code', 'status': 'active'},
]),
);
final dio = Dio()..httpClientAdapter = adapter;
@ -53,16 +48,12 @@ void main() {
test('creates a session with the provided name', () async {
final adapter = _FakeHttpClientAdapter(
jsonEncode({
'sessionId': 'abc',
'name': 'codex-main',
'status': 'idle',
}),
jsonEncode({'sessionId': 'abc', 'name': 'codex-main', 'status': 'idle'}),
);
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
final session = await client.createSession('codex-main');
final session = await client.createSession(name: 'codex-main');
expect(adapter.lastOptions?.method, 'POST');
expect(
@ -74,6 +65,30 @@ void main() {
expect(session['name'], 'codex-main');
});
test('deletes a session by id', () async {
final adapter = _FakeHttpClientAdapter();
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
await client.deleteSession('abc');
expect(adapter.lastOptions?.method, 'DELETE');
expect(
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/sessions/abc',
);
});
test('builds project urls from base uri', () {
final client = AgentApiClient(Uri.parse('https://host:9443'));
expect(client.projectsUri.toString(), 'https://host:9443/api/projects');
expect(
client.projectUri('abc').toString(),
'https://host:9443/api/projects/abc',
);
});
test('posts pairing redeem payload to the redeem endpoint', () async {
final adapter = _FakeHttpClientAdapter();
final dio = Dio()..httpClientAdapter = adapter;
@ -86,10 +101,10 @@ void main() {
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/pairing/redeem',
);
expect(
adapter.lastOptions?.data,
<String, String>{'code': '123456', 'deviceName': 'tablet'},
);
expect(adapter.lastOptions?.data, <String, String>{
'code': '123456',
'deviceName': 'tablet',
});
});
test('fetches session history from the history endpoint', () async {

View File

@ -1,474 +1,418 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/app/app.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
import 'package:term_remote_ctl/features/projects/project.dart';
import 'package:term_remote_ctl/features/projects/project_repository.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/sessions/session_list_page.dart';
import 'package:term_remote_ctl/features/sessions/session_repository.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
import 'package:xterm/xterm.dart';
void main() {
testWidgets('shows the sessions shell', (tester) async {
final repository = _FakeSessionRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
],
child: const TermRemoteCtlApp(),
),
testWidgets('shows the project-first shell', (tester) async {
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
);
await tester.pumpAndSettle();
final agentUrlField = tester.widget<TextField>(find.byType(TextField).first);
final agentUrlField = tester.widget<TextField>(
find.byType(TextField).first,
);
expect(find.text('Sessions'), findsOneWidget);
expect(find.text('Projects'), findsOneWidget);
expect(find.text('Agent base URL'), findsOneWidget);
expect(agentUrlField.controller?.text, 'http://10.0.2.2:5067');
expect(agentUrlField.decoration?.hintText, 'http://10.0.2.2:5067');
expect(find.text('Session requests use this base origin: http://10.0.2.2:5067.'), findsOneWidget);
expect(find.text('codex-main'), findsOneWidget);
});
testWidgets('surfaces create-session errors in a snackbar', (tester) async {
final repository = _FakeSessionRepository(shouldThrowOnCreate: true);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'broken-session');
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(find.textContaining('Failed to create session'), findsOneWidget);
});
testWidgets('tapping a session opens the terminal page', (tester) async {
final repository = _FakeSessionRepository();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(find.text('Send input'), findsOneWidget);
expect(find.text('Attached to codex-main'), findsOneWidget);
expect(find.text('Live | 2 lines'), findsOneWidget);
expect(find.text('Terminal controls'), findsOneWidget);
expect(find.byKey(const Key('terminal_controls_panel')), findsOneWidget);
expect(find.text('Connected'), findsOneWidget);
expect(find.text('History ready'), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget);
expect(
find.text('Live terminal attach is minimal for now. Resize and reconnect are not implemented yet.'),
findsNothing,
find.text('Project requests use this base origin: http://10.0.2.2:5067.'),
findsOneWidget,
);
expect(find.text('codex-main'), findsOneWidget);
expect(find.text(r'C:\repo\codex-main'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Open terminal'), findsOneWidget);
});
testWidgets('project card opens a terminal without an extra prompt', (
tester,
) async {
final sessionRepository = _FakeSessionRepository();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: sessionRepository,
);
await _openProjectTerminal(tester);
expect(sessionRepository.lastCreatedProjectId, 'project-1');
expect(find.text('codex-main'), findsOneWidget);
expect(find.text(r'C:\repo\codex-main'), findsOneWidget);
expect(
find.byKey(const Key('terminal_toggle_actions_button')),
findsOneWidget,
);
});
testWidgets(
'terminal page keeps tools hidden until the user opens the tools sheet',
(tester) async {
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
);
await _openProjectTerminal(tester);
expect(find.byKey(const Key('terminal_tools_sheet')), findsNothing);
expect(
find.byKey(const Key('terminal_toggle_actions_button')),
findsOneWidget,
);
expect(find.byKey(const Key('terminal_input_bar')), findsOneWidget);
expect(find.byKey(const Key('terminal_send_button')), findsOneWidget);
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('terminal_tools_sheet')), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget);
expect(find.text('Latest'), findsOneWidget);
},
);
testWidgets(
'project launch surfaces a friendly message when the working directory is invalid',
(tester) async {
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FailingSessionRepository(),
);
await tester.tap(find.widgetWithText(FilledButton, 'Open terminal'));
await tester.pumpAndSettle();
expect(
find.text(
'Working directory does not exist. Update the project path and try again.',
),
findsOneWidget,
);
},
);
testWidgets(
'terminal page shows diagnostic entries for input and output activity',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
await tester.enterText(find.byType(TextField).last, 'dir');
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
await tester.pumpAndSettle();
transportFactory.createdTransports.single.emit('command-output');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_diagnostics_button')));
await tester.pumpAndSettle();
expect(find.textContaining('ui.input.send | dir'), findsOneWidget);
expect(find.textContaining(r'socket.input.tx | dir\r'), findsOneWidget);
expect(
find.textContaining('socket.frame.rx | command-output'),
findsOneWidget,
);
},
);
testWidgets('terminal page reconnects after the socket closes', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await _openProjectTerminal(tester);
expect(find.text('Attached to codex-main'), findsOneWidget);
expect(find.text('codex-main'), findsOneWidget);
expect(transportFactory.createCount, 1);
final firstTransport = transportFactory.createdTransports.first;
await firstTransport.close();
await transportFactory.createdTransports.first.close();
await tester.pump();
expect(find.text('Reconnecting'), findsOneWidget);
final textField = tester.widget<TextField>(find.byType(TextField).last);
final sendButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Send'));
expect(textField.enabled, isFalse);
expect(sendButton.onPressed, isNull);
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
expect(find.text('Connection lost. Reconnecting...'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 2);
expect(find.text('Attached to codex-main'), findsOneWidget);
final reconnectedField = tester.widget<TextField>(find.byType(TextField).last);
final reconnectedButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Send'));
expect(reconnectedField.enabled, isTrue);
expect(reconnectedButton.onPressed, isNotNull);
expect(find.text('codex-main'), findsOneWidget);
});
testWidgets('terminal page can send input after leaving and reopening a session', (
testWidgets('terminal page can open another terminal for the same project', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
final sessionRepository = _FakeSessionRepository();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: sessionRepository,
);
await _openProjectTerminal(tester);
expect(sessionRepository.createCount, 1);
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
await tester.tap(find.text('New terminal'));
await tester.pumpAndSettle();
expect(sessionRepository.createCount, 2);
expect(find.text('codex-main'), findsOneWidget);
});
testWidgets(
'terminal page lets the user return to live mode and load older history',
(tester) async {
final apiClient = _SequencedHistoryAgentApiClient(
responses: [
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
apiClient: apiClient,
);
await _openProjectTerminal(tester);
expect(find.text('Back to live'), findsNothing);
await tester.tap(find.text('Live | 2 lines'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsOneWidget);
expect(find.text('Load older lines'), findsOneWidget);
await tester.ensureVisible(find.text('Load older lines'));
await tester.tap(find.text('Load older lines'));
await tester.pumpAndSettle();
expect(apiClient.requestedLineCounts, [200, 400]);
expect(find.text('3 lines loaded'), findsOneWidget);
},
);
testWidgets('session list deletes a session after confirmation', (
tester,
) async {
final sessionRepository = _FakeSessionRepository.withSessions([
Session(
sessionId: 'session-1',
name: 'codex-main',
status: 'created',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
]);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
sessionRepositoryProvider.overrideWithValue(sessionRepository),
],
child: const TermRemoteCtlApp(),
child: const MaterialApp(home: SessionListPage()),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 1);
expect(find.text('codex-main'), findsOneWidget);
await tester.pageBack();
await tester.tap(find.byKey(const Key('session_delete_button_session-1')));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 2);
expect(find.text('Attached to codex-main'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'dir');
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
await tester.pumpAndSettle();
expect(
transportFactory.createdTransports.last.sentMessages,
contains('{"type":"input","input":"dir\\r"}'),
);
});
testWidgets('terminal view accepts keyboard input after leaving and reopening a session', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.tap(find.byType(TerminalView));
await tester.pump(const Duration(seconds: 1));
tester.testTextInput.enterText('pwd');
await tester.idle();
expect(
transportFactory.createdTransports.last.sentMessages,
contains('{"type":"input","input":"pwd"}'),
);
});
testWidgets('terminal view regains keyboard input on reopen without an extra tap', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
tester.testTextInput.enterText('whoami');
await tester.idle();
expect(
transportFactory.createdTransports.last.sentMessages,
contains('{"type":"input","input":"whoami"}'),
);
});
testWidgets('terminal page lets the user return to live mode', (
tester,
) async {
final repository = _FakeSessionRepository();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsNothing);
await tester.tap(find.text('Live | 2 lines'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsOneWidget);
expect(find.text('Scrollback | 2 lines'), findsOneWidget);
expect(find.text('Recent scrollback'), findsOneWidget);
expect(find.text('Browsing history. Live output is still arriving.'), findsOneWidget);
expect(find.text('one'), findsOneWidget);
expect(find.text('two'), findsOneWidget);
expect(find.byKey(const Key('terminal_scrollback_list')), findsOneWidget);
expect(find.byKey(const Key('terminal_scrollback_actions')), findsOneWidget);
expect(find.text('Recent history is loaded. Older lines are not loaded yet.'), findsOneWidget);
expect(find.text('Load older lines'), findsOneWidget);
await tester.tap(find.text('Back to live'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsNothing);
expect(find.text('Live | 2 lines'), findsOneWidget);
expect(find.text('Recent scrollback'), findsNothing);
expect(find.text('Browsing history. Live output is still arriving.'), findsNothing);
});
testWidgets('terminal page loads older scrollback lines on demand', (
tester,
) async {
final repository = _FakeSessionRepository();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
);
final apiClient = _SequencedHistoryAgentApiClient(
responses: [
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(apiClient),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.tap(find.text('Live | 2 lines'));
await tester.pumpAndSettle();
expect(find.text('Load older lines'), findsOneWidget);
await tester.tap(find.text('Load older lines'));
await tester.pump();
await tester.pumpAndSettle();
expect(apiClient.requestedLineCounts, [1000, 2000]);
expect(find.text('Scrollback | 3 lines'), findsOneWidget);
expect(find.text('3 lines loaded'), findsOneWidget);
expect(find.text('zero'), findsOneWidget);
expect(find.text('Load older lines'), findsNothing);
expect(
find.text('Recent history is loaded. Older lines are not loaded yet.'),
findsNothing,
);
});
testWidgets('terminal page surfaces new live output while browsing history', (
tester,
) async {
final repository = _FakeSessionRepository();
final transport = _FakeTerminalSocketTransport(autoAttach: true);
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => transport,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.tap(find.text('Live | 2 lines'));
await tester.pumpAndSettle();
expect(find.text('New output available'), findsNothing);
transport.emit('next-line');
await tester.pumpAndSettle();
expect(find.text('New output available'), findsOneWidget);
await tester.tap(find.text('New output available'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsNothing);
expect(find.text('Live | 3 lines'), findsOneWidget);
expect(find.text('New output available'), findsNothing);
expect(sessionRepository.deletedSessionIds, ['session-1']);
expect(find.text('codex-main'), findsNothing);
});
}
class _FakeSessionRepository extends SessionRepository {
_FakeSessionRepository({this.shouldThrowOnCreate = false})
: _sessions = [
Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
),
],
super(_FakeAgentApiClient());
Future<void> _pumpApp(
WidgetTester tester, {
required _FakeProjectRepository projectRepository,
required SessionRepository sessionRepository,
AgentApiClient? apiClient,
TerminalSocketSessionFactory? socketFactory,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(
apiClient ?? _FakeAgentApiClient(),
),
projectRepositoryProvider.overrideWithValue(projectRepository),
sessionRepositoryProvider.overrideWithValue(sessionRepository),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
transportFactory: (_) =>
_FakeTerminalSocketTransport(autoAttach: true),
),
),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
}
final List<Session> _sessions;
final bool shouldThrowOnCreate;
Future<void> _openProjectTerminal(WidgetTester tester) async {
await tester.tap(find.widgetWithText(FilledButton, 'Open terminal'));
await tester.pumpAndSettle();
}
class _FakeProjectRepository extends ProjectRepository {
_FakeProjectRepository()
: _projects = [
Project(
projectId: 'project-1',
name: 'codex-main',
workingDirectory: r'C:\repo\codex-main',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
],
super(_FakeAgentApiClient());
final List<Project> _projects;
@override
Future<List<Session>> listSessions() async {
return List<Session>.of(_sessions);
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
@override
Future<ProjectDetail> getProjectDetail(String projectId) async {
return ProjectDetail(
project: _projects.single,
recentSessions: const <Session>[],
);
}
}
class _FakeSessionRepository extends SessionRepository {
_FakeSessionRepository()
: _sessions = <Session>[],
super(_FakeAgentApiClient());
_FakeSessionRepository.withSessions(List<Session> sessions)
: _sessions = List<Session>.of(sessions),
super(_FakeAgentApiClient());
String? lastCreatedProjectId;
int createCount = 0;
final List<String> deletedSessionIds = <String>[];
final List<Session> _sessions;
@override
Future<List<Session>> listSessions() async => List<Session>.of(_sessions);
@override
Future<Session> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
createCount += 1;
lastCreatedProjectId = projectId;
return Session(
sessionId: 'session-$createCount',
name: name ?? 'codex-main',
status: 'created',
projectId: projectId,
workingDirectory: workingDirectory ?? r'C:\repo\codex-main',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
);
}
@override
Future<Session> createSession(String name) async {
if (shouldThrowOnCreate) {
throw StateError('boom');
}
Future<void> deleteSession(String sessionId) async {
deletedSessionIds.add(sessionId);
_sessions.removeWhere((session) => session.sessionId == sessionId);
}
}
return Session(sessionId: 'created', name: name, status: 'idle');
class _FailingSessionRepository extends SessionRepository {
_FailingSessionRepository() : super(_FakeAgentApiClient());
@override
Future<List<Session>> listSessions() async => const <Session>[];
@override
Future<Session> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
throw DioException.badResponse(
statusCode: 400,
requestOptions: RequestOptions(path: '/api/sessions'),
response: Response<Map<String, dynamic>>(
requestOptions: RequestOptions(path: '/api/sessions'),
statusCode: 400,
data: const <String, dynamic>{'error': 'invalid_working_directory'},
),
);
}
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('https://host:9443'));
_FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067'));
@override
Future<Map<String, dynamic>> getSessionHistory(
@ -484,12 +428,13 @@ class _FakeAgentApiClient extends AgentApiClient {
}
class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
_SequencedHistoryAgentApiClient({required List<Map<String, dynamic>> responses})
: _responses = responses;
_SequencedHistoryAgentApiClient({
required List<Map<String, dynamic>> responses,
}) : _responses = responses;
final List<Map<String, dynamic>> _responses;
final requestedLineCounts = <int>[];
var _index = 0;
int _index = 0;
@override
Future<Map<String, dynamic>> getSessionHistory(
@ -510,7 +455,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
_FakeTerminalSocketTransport({this.autoAttach = false}) {
if (autoAttach) {
Future<void>.microtask(() {
emit('{"type":"attached","sessionId":"abc"}');
emit('{"type":"attached","sessionId":"session-1"}');
});
}
}
@ -539,7 +484,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
class _QueuedTerminalSocketTransportFactory {
final createdTransports = <_FakeTerminalSocketTransport>[];
var createCount = 0;
int createCount = 0;
TerminalSocketTransport create(Uri _) {
final transport = _FakeTerminalSocketTransport(autoAttach: true);

View File

@ -1,7 +1,9 @@
using System.Text.Json;
using TermRemoteCtl.Agent.Projects;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Security;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Api;
@ -27,20 +29,69 @@ public static class SessionEndpoints
}
});
group.MapDelete("/{sessionId}", async (
string sessionId,
ISessionHost host,
SessionRegistry registry,
CancellationToken cancellationToken) =>
{
try
{
if (!registry.TryGet(sessionId, out _))
{
return Results.NotFound();
}
await host.StopAsync(sessionId, cancellationToken).ConfigureAwait(false);
await registry.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
});
group.MapPost(string.Empty, async (
HttpRequest httpRequest,
ProjectRegistry projectRegistry,
SessionRegistry registry,
SessionHistoryStore historyStore,
IClock clock,
CancellationToken cancellationToken) =>
{
var request = await ReadCreateSessionRequestAsync(httpRequest, cancellationToken);
if (request is null || string.IsNullOrWhiteSpace(request.Name))
if (request is null)
{
return Results.BadRequest(new { error = "invalid_request" });
}
var record = registry.Create(request.Name, clock.UtcNow);
ProjectRecord? project = null;
if (!string.IsNullOrWhiteSpace(request.ProjectId))
{
if (!projectRegistry.TryGet(request.ProjectId, out project) || project is null)
{
return Results.NotFound(new { error = "project_not_found" });
}
}
var resolvedName = (request.Name ?? project?.Name)?.Trim();
if (string.IsNullOrWhiteSpace(resolvedName))
{
return Results.BadRequest(new { error = "invalid_request" });
}
var workingDirectory = (request.WorkingDirectory ?? project?.WorkingDirectory)?.Trim();
if (!string.IsNullOrWhiteSpace(workingDirectory) && !Directory.Exists(workingDirectory))
{
return Results.BadRequest(new { error = "invalid_working_directory" });
}
var record = registry.Create(
resolvedName,
clock.UtcNow,
project?.ProjectId,
workingDirectory);
await historyStore.AppendAsync(record.SessionId, string.Empty, cancellationToken);
return Results.Ok(record);
@ -68,4 +119,4 @@ public static class SessionEndpoints
}
}
public sealed record CreateSessionRequest(string Name);
public sealed record CreateSessionRequest(string? Name, string? ProjectId, string? WorkingDirectory);

View File

@ -31,4 +31,18 @@ public sealed class SessionHistoryStore
await writer.WriteAsync(chunk.AsMemory(), cancellationToken);
await writer.FlushAsync(cancellationToken);
}
public Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
cancellationToken.ThrowIfCancellationRequested();
var filePath = Path.Combine(_historyRootPath, $"{sessionId}.log");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
return Task.CompletedTask;
}
}

View File

@ -18,7 +18,11 @@ public sealed class SessionRegistry
_ringBufferLineLimit = options.Value.RingBufferLineLimit;
}
public SessionRecord Create(string name, DateTimeOffset now)
public SessionRecord Create(
string name,
DateTimeOffset now,
string? projectId = null,
string? workingDirectory = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
@ -26,6 +30,8 @@ public sealed class SessionRegistry
Guid.NewGuid().ToString("N"),
name,
"created",
projectId,
workingDirectory,
now,
now);
@ -41,6 +47,18 @@ public sealed class SessionRegistry
.ToArray();
}
public IReadOnlyList<SessionRecord> ListRecentForProject(string projectId, int maxCount)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCount);
return _records.Values
.Where(record => string.Equals(record.ProjectId, projectId, StringComparison.Ordinal))
.OrderByDescending(record => record.UpdatedAtUtc)
.Take(maxCount)
.ToArray();
}
public bool TryGet(string sessionId, out SessionRecord? record)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
@ -89,4 +107,17 @@ public sealed class SessionRegistry
lines.Skip(skipCount).ToArray(),
skipCount > 0);
}
public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (!_records.TryRemove(sessionId, out _))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
_historyBySession.TryRemove(sessionId, out _);
await _historyStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false);
}
}

View File

@ -6,6 +6,8 @@ public interface ISessionHost
Task StartAsync(string sessionId, CancellationToken cancellationToken);
Task StopAsync(string sessionId, CancellationToken cancellationToken);
Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken);
Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken);

View File

@ -29,7 +29,12 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
return;
}
var session = _sessionFactory.Create(sessionId);
if (!_sessionRegistry.TryGet(sessionId, out var record) || record is null)
{
throw new KeyNotFoundException($"Session '{sessionId}' is not registered.");
}
var session = _sessionFactory.Create(sessionId, record.WorkingDirectory);
if (!_sessions.TryAdd(sessionId, session))
{
await session.DisposeAsync().ConfigureAwait(false);
@ -76,6 +81,20 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
await session.ResizeAsync(columns, rows, cancellationToken).ConfigureAwait(false);
}
public async Task StopAsync(string sessionId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
cancellationToken.ThrowIfCancellationRequested();
if (!_sessions.TryRemove(sessionId, out var session))
{
return;
}
session.OutputReceived -= HandleSessionOutput;
await session.DisposeAsync().ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
foreach (var session in _sessions.Values)

View File

@ -44,6 +44,9 @@ public sealed class TerminalWebSocketHandlerTests
await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
await WaitForConditionAsync(() => fixture.TerminalHost.Inputs.Contains(("input", session.SessionId, "dir")), TimeSpan.FromSeconds(2));
await WaitForConditionAsync(
() => fixture.Diagnostics.Events.Contains(("backend.input.received", session.SessionId, "dir")),
TimeSpan.FromSeconds(2));
}
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
@ -87,9 +90,12 @@ public sealed class TerminalWebSocketHandlerTests
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
private readonly TestTerminalSessionHost _terminalHost = new();
private readonly RecordingTerminalDiagnosticsSink _diagnostics = new();
public TestTerminalSessionHost TerminalHost => _terminalHost;
public RecordingTerminalDiagnosticsSink Diagnostics => _diagnostics;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
@ -108,6 +114,7 @@ public sealed class TerminalWebSocketHandlerTests
{
services.RemoveAll<ISessionHost>();
services.AddSingleton<ISessionHost>(_terminalHost);
services.AddSingleton<ITerminalDiagnosticsSink>(_diagnostics);
});
}
@ -121,6 +128,18 @@ public sealed class TerminalWebSocketHandlerTests
}
}
private sealed class RecordingTerminalDiagnosticsSink : ITerminalDiagnosticsSink
{
private readonly List<(string EventName, string SessionId, string Detail)> _events = [];
public IReadOnlyList<(string EventName, string SessionId, string Detail)> Events => _events;
public void Record(string eventName, string sessionId, string detail)
{
_events.Add((eventName, sessionId, detail));
}
}
private sealed class TestTerminalSessionHost : ISessionHost
{
private readonly List<(string Kind, string SessionId, string Value)> _inputs = new();
@ -134,6 +153,11 @@ public sealed class TerminalWebSocketHandlerTests
return Task.CompletedTask;
}
public Task StopAsync(string sessionId, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken)
{
_inputs.Add(("input", sessionId, input));

View File

@ -60,6 +60,32 @@ public sealed class SessionFlowTests
Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId));
}
[Fact]
public async Task Delete_Removes_Session_From_List_And_Stops_Host()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" });
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
Assert.NotNull(created);
using (var socket = await fixture.ConnectTerminalAsync(created!.SessionId))
{
_ = await ReceiveTextAsync(socket);
}
var deleteResponse = await client.DeleteAsync($"/api/sessions/{created.SessionId}");
deleteResponse.EnsureSuccessStatusCode();
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(listedSessions);
Assert.DoesNotContain(listedSessions!, session => session.SessionId == created.SessionId);
Assert.Equal(1, fixture.SessionHost.StopCountFor(created.SessionId));
}
private static async Task<string> ReceiveTextAsync(WebSocket socket)
{
var buffer = new byte[4096];
@ -128,6 +154,7 @@ public sealed class SessionFlowTests
private sealed class RecordingSessionHost : ISessionHost
{
private readonly Dictionary<string, int> _startCounts = new(StringComparer.Ordinal);
private readonly Dictionary<string, int> _stopCounts = new(StringComparer.Ordinal);
private readonly HashSet<string> _startedSessions = new(StringComparer.Ordinal);
public event EventHandler<TerminalOutputEventArgs>? OutputReceived
@ -156,10 +183,30 @@ public sealed class SessionFlowTests
return Task.CompletedTask;
}
public Task StopAsync(string sessionId, CancellationToken cancellationToken)
{
if (_stopCounts.TryGetValue(sessionId, out var count))
{
_stopCounts[sessionId] = count + 1;
}
else
{
_stopCounts[sessionId] = 1;
}
_startedSessions.Remove(sessionId);
return Task.CompletedTask;
}
public int StartCountFor(string sessionId)
{
return _startCounts.TryGetValue(sessionId, out var count) ? count : 0;
}
public int StopCountFor(string sessionId)
{
return _stopCounts.TryGetValue(sessionId, out var count) ? count : 0;
}
}
private sealed record SessionResponse(

View File

@ -68,6 +68,22 @@ public class SessionRegistryTests
Assert.Equal("dir\r\n", content);
}
[Fact]
public async Task Delete_Removes_Session_Record_And_History_Log()
{
using var harness = SessionRegistryHarness.Create();
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None);
await registry.DeleteAsync(session.SessionId, CancellationToken.None);
Assert.Empty(registry.List());
Assert.False(registry.TryGet(session.SessionId, out _));
Assert.False(File.Exists(Path.Combine(harness.DataRoot, "sessions", $"{session.SessionId}.log")));
Assert.Throws<KeyNotFoundException>(() => registry.GetHistory(session.SessionId, 10));
}
private sealed class SessionRegistryHarness : IDisposable
{
private SessionRegistryHarness(string dataRoot, SessionRegistry registry)