import 'dart:async'; import 'dart:convert'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../runtime/runtime_models.dart'; class WebAcpException implements Exception { const WebAcpException(this.message, {this.code, this.details}); final String message; final String? code; final Object? details; @override String toString() => code == null ? message : '$code: $message'; } class WebAcpCapabilities { const WebAcpCapabilities({ required this.singleAgent, required this.multiAgent, required this.providers, required this.raw, }); const WebAcpCapabilities.empty() : singleAgent = false, multiAgent = false, providers = const {}, raw = const {}; final bool singleAgent; final bool multiAgent; final Set providers; final Map raw; } class WebAcpClient { const WebAcpClient(); static const Duration _defaultTimeout = Duration(seconds: 120); Future loadCapabilities({ required Uri endpoint, }) async { final response = await request( endpoint: endpoint, method: 'acp.capabilities', params: const {}, ); final result = _asMap(response['result']); final caps = _asMap(result['capabilities']); final providers = {}; for (final raw in [ ..._asList(result['providers']), ..._asList(caps['providers']), ]) { if (raw == null) { continue; } final provider = SingleAgentProviderCopy.fromJsonValue( raw.toString().trim().toLowerCase(), ); if (provider != SingleAgentProvider.auto) { providers.add(provider); } } final singleAgent = _boolValue(result['singleAgent']) ?? _boolValue(caps['single_agent']) ?? providers.isNotEmpty; final multiAgent = _boolValue(result['multiAgent']) ?? _boolValue(caps['multi_agent']) ?? false; return WebAcpCapabilities( singleAgent: singleAgent, multiAgent: multiAgent, providers: providers, raw: result, ); } Future cancelSession({ required Uri endpoint, required String sessionId, required String threadId, }) async { await request( endpoint: endpoint, method: 'session.cancel', params: {'sessionId': sessionId, 'threadId': threadId}, ); } Future> request({ required Uri endpoint, required String method, required Map params, void Function(Map notification)? onNotification, Duration timeout = _defaultTimeout, }) async { final requestId = '${DateTime.now().microsecondsSinceEpoch}-$method'; final wsEndpoint = _resolveWebSocketEndpoint(endpoint); if (wsEndpoint == null) { throw const WebAcpException( 'Missing ACP endpoint', code: 'ACP_ENDPOINT_MISSING', ); } final socket = WebSocketChannel.connect(wsEndpoint); final completer = Completer>(); late final StreamSubscription subscription; subscription = socket.stream.listen( (raw) { final json = _decodeMap(raw); final id = _stringValue(json['id']); final methodName = _stringValue(json['method']) ?? ''; if (id == requestId && (json.containsKey('result') || json.containsKey('error'))) { if (!completer.isCompleted) { completer.complete(json); } return; } if (methodName.isNotEmpty && onNotification != null) { onNotification(json); } }, onError: (Object error, StackTrace stackTrace) { if (!completer.isCompleted) { completer.completeError( WebAcpException(error.toString(), code: 'ACP_WS_RUNTIME_ERROR'), ); } }, onDone: () { if (!completer.isCompleted) { completer.completeError( const WebAcpException( 'ACP websocket closed before response', code: 'ACP_WS_EARLY_CLOSE', ), ); } }, cancelOnError: true, ); try { await socket.ready; socket.sink.add( jsonEncode({ 'jsonrpc': '2.0', 'id': requestId, 'method': method, 'params': params, }), ); final response = await completer.future.timeout(timeout); _throwIfJsonRpcError(response); return response; } finally { await subscription.cancel(); await socket.sink.close(); } } static Uri? _resolveWebSocketEndpoint(Uri? endpoint) { if (endpoint == null || endpoint.host.trim().isEmpty) { return null; } final scheme = endpoint.scheme.trim().toLowerCase(); final wsScheme = switch (scheme) { 'https' || 'wss' => 'wss', _ => 'ws', }; return endpoint.replace(path: '/acp', query: null, fragment: null, scheme: wsScheme); } void _throwIfJsonRpcError(Map response) { final error = _asMap(response['error']); if (error.isEmpty) { return; } throw WebAcpException( _stringValue(error['message']) ?? 'ACP request failed', code: _stringValue(error['code']), details: error['data'], ); } static Map _decodeMap(Object? raw) { if (raw is Map) { return raw; } if (raw is Map) { return raw.cast(); } if (raw is String) { final decoded = jsonDecode(raw); if (decoded is Map) { return decoded; } if (decoded is Map) { return decoded.cast(); } } return const {}; } static Map _asMap(Object? value) { if (value is Map) { return value; } if (value is Map) { return value.cast(); } return const {}; } static List _asList(Object? value) { if (value is List) { return value; } if (value is List) { return value.cast(); } return const []; } static String? _stringValue(Object? value) { final text = value?.toString().trim(); return (text == null || text.isEmpty) ? null : text; } static bool? _boolValue(Object? value) { if (value is bool) { return value; } final text = value?.toString().trim().toLowerCase(); if (text == 'true') { return true; } if (text == 'false') { return false; } return null; } }