@TestOn('vm') library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/runtime/gateway_acp_client.dart'; import 'package:xworkmate/runtime/multi_agent_orchestrator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; void main() { group('GatewayAcpClient', () { test('loads ACP capabilities over websocket when available', () async { final server = await _AcpFakeServer.start(); addTearDown(server.close); final client = GatewayAcpClient( endpointResolver: () => server.baseHttpUri, ); final capabilities = await client.loadCapabilities(forceRefresh: true); expect(capabilities.singleAgent, isTrue); expect(capabilities.multiAgent, isTrue); expect(capabilities.providers, contains(SingleAgentProvider.codex)); expect(server.rpcMethods, contains('acp.capabilities')); }); test('falls back to HTTP+SSE when websocket is unavailable', () async { final server = await _AcpFakeServer.start(disableWebSocket: true); addTearDown(server.close); final client = GatewayAcpClient( endpointResolver: () => server.baseHttpUri, ); final capabilities = await client.loadCapabilities(forceRefresh: true); expect(capabilities.singleAgent, isTrue); expect(capabilities.multiAgent, isTrue); expect(capabilities.providers, contains(SingleAgentProvider.claude)); expect(server.rpcMethods, contains('acp.capabilities')); }); test( 'streams multi-agent events and supports cancel/close session', () async { final server = await _AcpFakeServer.start(); addTearDown(server.close); final client = GatewayAcpClient( endpointResolver: () => server.baseHttpUri, ); final events = await client .runMultiAgent( GatewayAcpMultiAgentRequest( sessionId: 'session-ma', threadId: 'thread-ma', prompt: 'run multi-agent', workingDirectory: '/tmp', attachments: const [], selectedSkills: const ['design'], aiGatewayBaseUrl: 'https://example.invalid', aiGatewayApiKey: 'test-key', resumeSession: false, ), ) .toList(); expect(events, isNotEmpty); expect(events.first.type, 'step'); expect(events.last.type, 'result'); expect(events.last.error, isFalse); await client.cancelSession( sessionId: 'session-ma', threadId: 'thread-ma', ); await client.closeSession( sessionId: 'session-ma', threadId: 'thread-ma', ); expect(server.rpcMethods, contains('session.cancel')); expect(server.rpcMethods, contains('session.close')); }, ); }); } class _AcpFakeServer { _AcpFakeServer._(this._server, {required this.disableWebSocket}); final HttpServer _server; final bool disableWebSocket; final List rpcMethods = []; Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}'); static Future<_AcpFakeServer> start({bool disableWebSocket = false}) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final fake = _AcpFakeServer._(server, disableWebSocket: disableWebSocket); unawaited(fake._listen()); return fake; } Future close() async { await _server.close(force: true); } Future _listen() async { await for (final request in _server) { if (!disableWebSocket && request.uri.path == '/acp' && WebSocketTransformer.isUpgradeRequest(request)) { final socket = await WebSocketTransformer.upgrade(request); unawaited(_handleWebSocket(socket)); continue; } if (request.uri.path == '/acp/rpc' && request.method == 'POST') { await _handleHttpRpc(request); continue; } request.response ..statusCode = HttpStatus.notFound ..write('not found'); await request.response.close(); } } Future _handleWebSocket(WebSocket socket) async { await for (final raw in socket) { final envelope = _decodeMap(raw); final id = envelope['id']; final method = envelope['method']?.toString() ?? ''; final params = _asMap(envelope['params']); if (method.isEmpty) { continue; } rpcMethods.add(method); await _dispatch( method: method, id: id, params: params, notify: (notification) async { socket.add(jsonEncode(notification)); }, respond: (response) async { socket.add(jsonEncode(response)); }, ); } } Future _handleHttpRpc(HttpRequest request) async { final body = await utf8.decodeStream(request); final envelope = _decodeMap(body); final id = envelope['id']; final method = envelope['method']?.toString() ?? ''; final params = _asMap(envelope['params']); if (method.isEmpty) { request.response.statusCode = HttpStatus.badRequest; await request.response.close(); return; } rpcMethods.add(method); request.response.headers.set( HttpHeaders.contentTypeHeader, 'text/event-stream', ); request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache'); Future notify(Map notification) async { request.response.write('data: ${jsonEncode(notification)}\n\n'); await request.response.flush(); } Future respond(Map response) async { request.response.write('data: ${jsonEncode(response)}\n\n'); await request.response.flush(); await request.response.close(); } await _dispatch( method: method, id: id, params: params, notify: notify, respond: respond, ); } Future _dispatch({ required String method, required Object? id, required Map params, required Future Function(Map notification) notify, required Future Function(Map response) respond, }) async { switch (method) { case 'acp.capabilities': await respond( _resultEnvelope( id: id, result: { 'singleAgent': true, 'multiAgent': true, 'providers': ['codex', 'claude', 'gemini', 'opencode'], 'capabilities': { 'single_agent': true, 'multi_agent': true, 'providers': ['codex', 'claude', 'gemini', 'opencode'], }, }, ), ); return; case 'session.start': case 'session.message': final sessionId = params['sessionId']?.toString() ?? 'session-default'; final threadId = params['threadId']?.toString() ?? sessionId; final mode = params['mode']?.toString() ?? 'single-agent'; if (mode == 'multi-agent') { await notify( _notificationEnvelope( method: 'multi_agent.event', params: { 'type': 'step', 'title': 'Architect', 'message': 'planning', 'pending': false, 'error': false, 'data': {'seq': 1}, }, ), ); await respond( _resultEnvelope( id: id, result: { 'success': true, 'summary': 'multi-agent done', 'finalScore': 9, 'iterations': 1, }, ), ); return; } final provider = params['provider']?.toString() ?? 'unknown'; await notify( _notificationEnvelope( method: 'session.update', params: { 'sessionId': sessionId, 'threadId': threadId, 'turnId': 'turn-single', 'type': 'delta', 'delta': 'delta-single', 'seq': 1, 'mode': 'single-agent', }, ), ); await respond( _resultEnvelope( id: id, result: { 'success': true, 'output': 'single-agent result ($provider)', 'turnId': 'turn-single', }, ), ); return; case 'session.cancel': await respond( _resultEnvelope( id: id, result: const { 'accepted': true, 'cancelled': true, }, ), ); return; case 'session.close': await respond( _resultEnvelope( id: id, result: const {'accepted': true, 'closed': true}, ), ); return; default: await respond({ 'jsonrpc': '2.0', 'id': id, 'error': { 'code': -32601, 'message': 'method not found', }, }); } } Map _resultEnvelope({ required Object? id, required Map result, }) { return {'jsonrpc': '2.0', 'id': id, 'result': result}; } Map _notificationEnvelope({ required String method, required Map params, }) { return { 'jsonrpc': '2.0', 'method': method, 'params': params, }; } Map _decodeMap(Object raw) { if (raw is String) { final decoded = jsonDecode(raw); return _asMap(decoded); } if (raw is List) { final decoded = jsonDecode(utf8.decode(raw)); return _asMap(decoded); } return _asMap(raw); } Map _asMap(Object? raw) { if (raw is Map) { return raw; } if (raw is Map) { return raw.cast(); } return const {}; } }