From 4ed8525209e61e84251fec85f2149ea0bf266c3e Mon Sep 17 00:00:00 2001 From: sladro Date: Mon, 30 Mar 2026 14:09:50 +0800 Subject: [PATCH] feat: add session deletion flow --- .../lib/core/network/agent_api_client.dart | 78 +- .../features/sessions/session_list_page.dart | 78 +- .../features/sessions/session_repository.dart | 16 +- .../core/network/agent_api_client_test.dart | 57 +- apps/mobile_app/test/widget_test.dart | 749 ++++++++---------- .../Api/SessionEndpoints.cs | 57 +- .../History/SessionHistoryStore.cs | 14 + .../Sessions/SessionRegistry.cs | 33 +- .../Terminal/ISessionHost.cs | 2 + .../Terminal/PowerShellSessionHost.cs | 21 +- .../Realtime/TerminalWebSocketHandlerTests.cs | 24 + .../SessionFlowTests.cs | 47 ++ .../Sessions/SessionRegistryTests.cs | 16 + 13 files changed, 740 insertions(+), 452 deletions(-) diff --git a/apps/mobile_app/lib/core/network/agent_api_client.dart b/apps/mobile_app/lib/core/network/agent_api_client.dart index 5d5d4cd..b0c3f10 100644 --- a/apps/mobile_app/lib/core/network/agent_api_client.dart +++ b/apps/mobile_app/lib/core/network/agent_api_client.dart @@ -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: {'lineCount': '$lineCount'}, - ); + return baseUri + .resolve('/api/sessions/$sessionId/history') + .replace(queryParameters: {'lineCount': '$lineCount'}); } Future>> listSessions() async { @@ -21,14 +24,72 @@ class AgentApiClient { return _readJsonList(response.data, 'sessions'); } - Future> createSession(String name) async { + Uri projectUri(String projectId) => + baseUri.resolve('/api/projects/$projectId'); + + Future>> listProjects() async { + final response = await _dio.getUri(projectsUri); + return _readJsonList(response.data, 'projects'); + } + + Future> getProjectDetail(String projectId) async { + final response = await _dio.getUri(projectUri(projectId)); + return _readJsonMap(response.data, 'project detail'); + } + + Future> createProject({ + required String name, + required String workingDirectory, + }) async { final response = await _dio.postUri( - sessionsUri, - data: {'name': name}, + projectsUri, + data: { + 'name': name, + 'workingDirectory': workingDirectory, + }, ); + return _readJsonMap(response.data, 'project'); + } + + Future> updateProject({ + required String projectId, + required String name, + required String workingDirectory, + }) async { + final response = await _dio.putUri( + projectUri(projectId), + data: { + 'name': name, + 'workingDirectory': workingDirectory, + }, + ); + return _readJsonMap(response.data, 'project'); + } + + Future> createSession({ + String? name, + String? projectId, + String? workingDirectory, + }) async { + final data = {}; + 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 deleteSession(String sessionId) async { + await _dio.deleteUri(sessionUri(sessionId)); + } + Future> getSessionHistory( String sessionId, { int lineCount = 200, @@ -45,10 +106,7 @@ class AgentApiClient { }) { return _dio.postUri( pairingRedeemUri, - data: { - 'code': code, - 'deviceName': deviceName, - }, + data: {'code': code, 'deviceName': deviceName}, ); } diff --git a/apps/mobile_app/lib/features/sessions/session_list_page.dart b/apps/mobile_app/lib/features/sessions/session_list_page.dart index 83abe24..2334616 100644 --- a/apps/mobile_app/lib/features/sessions/session_list_page.dart +++ b/apps/mobile_app/lib/features/sessions/session_list_page.dart @@ -30,9 +30,9 @@ class _SessionListPageState extends ConsumerState { } void _showMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } Future _refreshSessions() async { @@ -82,7 +82,7 @@ class _SessionListPageState extends ConsumerState { } try { - await repository.createSession(trimmedName); + await repository.createSession(name: trimmedName); await _reloadSessions(); } catch (error) { if (!mounted) { @@ -93,6 +93,47 @@ class _SessionListPageState extends ConsumerState { } } + Future _deleteSession(Session session) async { + final shouldDelete = await showDialog( + 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 { Future _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 { 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 { 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), + ), + ], ), ), ); diff --git a/apps/mobile_app/lib/features/sessions/session_repository.dart b/apps/mobile_app/lib/features/sessions/session_repository.dart index c63c047..ffcefb0 100644 --- a/apps/mobile_app/lib/features/sessions/session_repository.dart +++ b/apps/mobile_app/lib/features/sessions/session_repository.dart @@ -11,8 +11,20 @@ class SessionRepository { return sessions.map(Session.fromJson).toList(growable: false); } - Future createSession(String name) async { - final session = await _client.createSession(name); + Future createSession({ + String? name, + String? projectId, + String? workingDirectory, + }) async { + final session = await _client.createSession( + name: name, + projectId: projectId, + workingDirectory: workingDirectory, + ); return Session.fromJson(session); } + + Future deleteSession(String sessionId) { + return _client.deleteSession(sessionId); + } } diff --git a/apps/mobile_app/test/core/network/agent_api_client_test.dart b/apps/mobile_app/test/core/network/agent_api_client_test.dart index 5dbdca0..7cbada9 100644 --- a/apps/mobile_app/test/core/network/agent_api_client_test.dart +++ b/apps/mobile_app/test/core/network/agent_api_client_test.dart @@ -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, - {'code': '123456', 'deviceName': 'tablet'}, - ); + expect(adapter.lastOptions?.data, { + 'code': '123456', + 'deviceName': 'tablet', + }); }); test('fetches session history from the history endpoint', () async { diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index 47142b9..e81e2ff 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -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(find.byType(TextField).first); + final agentUrlField = tester.widget( + 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(find.byType(TextField).last); - final sendButton = tester.widget(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(find.byType(TextField).last); - final reconnectedButton = tester.widget(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: [ + { + 'sessionId': 'session-1', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }, + { + 'sessionId': 'session-1', + 'lines': ['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: [ - { - 'sessionId': 'abc', - 'lines': ['one', 'two'], - 'hasMoreAbove': true, - }, - { - 'sessionId': 'abc', - 'lines': ['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 _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 _sessions; - final bool shouldThrowOnCreate; +Future _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 _projects; @override - Future> listSessions() async { - return List.of(_sessions); + Future> listProjects() async => List.of(_projects); + + @override + Future getProjectDetail(String projectId) async { + return ProjectDetail( + project: _projects.single, + recentSessions: const [], + ); + } +} + +class _FakeSessionRepository extends SessionRepository { + _FakeSessionRepository() + : _sessions = [], + super(_FakeAgentApiClient()); + + _FakeSessionRepository.withSessions(List sessions) + : _sessions = List.of(sessions), + super(_FakeAgentApiClient()); + + String? lastCreatedProjectId; + int createCount = 0; + final List deletedSessionIds = []; + final List _sessions; + + @override + Future> listSessions() async => List.of(_sessions); + + @override + Future 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 createSession(String name) async { - if (shouldThrowOnCreate) { - throw StateError('boom'); - } + Future 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> listSessions() async => const []; + + @override + Future createSession({ + String? name, + String? projectId, + String? workingDirectory, + }) async { + throw DioException.badResponse( + statusCode: 400, + requestOptions: RequestOptions(path: '/api/sessions'), + response: Response>( + requestOptions: RequestOptions(path: '/api/sessions'), + statusCode: 400, + data: const {'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> getSessionHistory( @@ -484,12 +428,13 @@ class _FakeAgentApiClient extends AgentApiClient { } class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient { - _SequencedHistoryAgentApiClient({required List> responses}) - : _responses = responses; + _SequencedHistoryAgentApiClient({ + required List> responses, + }) : _responses = responses; final List> _responses; final requestedLineCounts = []; - var _index = 0; + int _index = 0; @override Future> getSessionHistory( @@ -510,7 +455,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport { _FakeTerminalSocketTransport({this.autoAttach = false}) { if (autoAttach) { Future.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); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs index d2d91ce..80cf682 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs @@ -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); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs index e14c61f..829aaa3 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs @@ -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; + } } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs index d150841..98db386 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -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 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); + } } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs index 8bdc8cc..b266f33 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ISessionHost.cs @@ -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); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs index f8167e4..086ac2e 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs @@ -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) diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs index edf95b1..185687c 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs @@ -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 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(); services.AddSingleton(_terminalHost); + services.AddSingleton(_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)); diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs index 9c5d2d0..bcf3417 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs @@ -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(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>("/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 ReceiveTextAsync(WebSocket socket) { var buffer = new byte[4096]; @@ -128,6 +154,7 @@ public sealed class SessionFlowTests private sealed class RecordingSessionHost : ISessionHost { private readonly Dictionary _startCounts = new(StringComparer.Ordinal); + private readonly Dictionary _stopCounts = new(StringComparer.Ordinal); private readonly HashSet _startedSessions = new(StringComparer.Ordinal); public event EventHandler? 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( diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs index b744c58..b4ac68e 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs @@ -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(() => registry.GetHistory(session.SessionId, 10)); + } + private sealed class SessionRegistryHarness : IDisposable { private SessionRegistryHarness(string dataRoot, SessionRegistry registry)