From 179294d059e115b0c2aa47bb91884df3901d6063 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 1 May 2026 14:43:05 +0800 Subject: [PATCH] fix: preserve bridge acp 502 diagnostics --- ...rnal_code_agent_acp_desktop_transport.dart | 2 + lib/runtime/gateway_acp_client.dart | 102 +++++++++++++++- .../runtime/gateway_acp_client_auth_test.dart | 114 +++++++++++++++++- 3 files changed, 209 insertions(+), 9 deletions(-) diff --git a/lib/runtime/external_code_agent_acp_desktop_transport.dart b/lib/runtime/external_code_agent_acp_desktop_transport.dart index 24c6bd85..15a177ab 100644 --- a/lib/runtime/external_code_agent_acp_desktop_transport.dart +++ b/lib/runtime/external_code_agent_acp_desktop_transport.dart @@ -132,6 +132,8 @@ class ExternalCodeAgentAcpDesktopTransport streamedText: streamedText, completedMessage: completedMessage, ); + } on GatewayAcpException { + rethrow; } catch (error) { throw GatewayAcpException( error.toString(), diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index abae9f6c..37c0ec85 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -706,12 +706,10 @@ class GatewayAcpClient { } try { final decoded = _decodeMap(trimmed); - final error = asMap(decoded['error']); - return (stringValue(error['message']) ?? - stringValue(decoded['message']) ?? - stringValue(decoded['detail']) ?? - '') - .trim(); + final detail = _extractStructuredErrorDetail(decoded); + if (detail.isNotEmpty) { + return detail; + } } on FormatException { // Fall through to textual snippet extraction below. } @@ -725,6 +723,98 @@ class GatewayAcpClient { : '${singleLine.substring(0, 157)}...'; } + String _extractStructuredErrorDetail(Map decoded) { + final candidates = []; + void addCandidate(Object? value) { + final text = _extractStructuredErrorText(value).trim(); + if (text.isNotEmpty && !candidates.contains(text)) { + candidates.add(text); + } + } + + final error = decoded['error']; + addCandidate(error); + if (error is Map) { + final errorMap = error.cast(); + addCandidate(errorMap['data']); + addCandidate(errorMap['details']); + } + for (final key in const [ + 'message', + 'detail', + 'errorMessage', + 'unavailableMessage', + 'reason', + 'description', + 'body', + ]) { + addCandidate(decoded[key]); + } + final code = stringValue(decoded['code']) ?? ''; + if (candidates.isEmpty && code.isNotEmpty) { + candidates.add(code); + } + return candidates.join(' · '); + } + + String _extractStructuredErrorText(Object? value, [Set? visited]) { + if (value == null) { + return ''; + } + if (value is String) { + return value.trim(); + } + if (value is num || value is bool) { + return value.toString(); + } + final seen = visited ?? {}; + if (!seen.add(value)) { + return ''; + } + if (value is Map) { + final map = value.cast(); + final parts = []; + for (final key in const [ + 'message', + 'detail', + 'error', + 'errorMessage', + 'unavailableMessage', + 'upstreamError', + 'reason', + 'description', + ]) { + final text = _extractStructuredErrorText(map[key], seen); + if (text.isNotEmpty && !parts.contains(text)) { + parts.add(text); + } + } + final code = + stringValue(map['code']) ?? stringValue(map['unavailableCode']) ?? ''; + final upstream = + stringValue(map['upstreamMethod']) ?? + stringValue(map['upstream']) ?? + ''; + if (code.isNotEmpty && parts.every((part) => !part.contains(code))) { + parts.add('code: $code'); + } + if (upstream.isNotEmpty) { + parts.add('upstream: $upstream'); + } + if (parts.length <= 1) { + return parts.join(); + } + return '${parts.first} (${parts.skip(1).join(', ')})'; + } + if (value is Iterable) { + return value + .map((item) => _extractStructuredErrorText(item, seen)) + .where((item) => item.isNotEmpty) + .join(' · '); + } + return value.toString().trim(); + } + Future _resolveAuthorizationHeader( Uri endpoint, { String authorizationOverride = '', diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index d273971c..66528a45 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -221,6 +221,58 @@ void main() { expect(capture.authorizationHeader, 'Bearer ready-token'); }); + test('surfaces structured bridge HTTP 502 diagnostics', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + await utf8.decoder.bind(request).join(); + request.response + ..statusCode = HttpStatus.badGateway + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'error': { + 'message': 'openclaw upstream request failed', + 'data': { + 'unavailableCode': 'UPSTREAM_BAD_GATEWAY', + 'upstreamMethod': 'session.start', + }, + }, + }), + ); + await request.response.close(); + }); + final client = GatewayAcpClient( + endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'), + ); + + await expectLater( + client.request( + method: 'session.start', + params: const {}, + ), + throwsA( + isA() + .having((error) => error.code, 'code', 'ACP_HTTP_502') + .having( + (error) => error.message, + 'message', + contains('openclaw upstream request failed'), + ) + .having( + (error) => error.message, + 'diagnostic code', + contains('UPSTREAM_BAD_GATEWAY'), + ) + .having( + (error) => error.message, + 'upstream', + contains('session.start'), + ), + ), + ); + }); + test('desktop bridge auth resolver skips unrelated endpoints', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-acp-auth-unrelated-', @@ -244,7 +296,9 @@ void main() { ); final controller = AppController( - environmentOverride: const {},store: store); + environmentOverride: const {}, + store: store, + ); addTearDown(controller.dispose); final header = await controller @@ -292,7 +346,9 @@ void main() { await store.saveSecretValueByRef('gateway_token_0', 'gateway-token'); final controller = AppController( - environmentOverride: const {},store: store); + environmentOverride: const {}, + store: store, + ); addTearDown(controller.dispose); await controller.settingsControllerInternal.resetSnapshot( await store.loadSettingsSnapshot(), @@ -390,7 +446,9 @@ void main() { await store.saveSecretValueByRef('gateway_token_0', 'gateway-token'); final controller = AppController( - environmentOverride: const {},store: store); + environmentOverride: const {}, + store: store, + ); addTearDown(controller.dispose); await controller.settingsControllerInternal.initialize(); @@ -553,6 +611,56 @@ void main() { }, ); + test( + 'desktop transport preserves gateway ACP HTTP failure detail', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + await utf8.decoder.bind(request).join(); + request.response + ..statusCode = HttpStatus.badGateway + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'error': { + 'message': 'openclaw upstream request failed', + 'data': { + 'unavailableCode': 'UPSTREAM_BAD_GATEWAY', + }, + }, + }), + ); + await request.response.close(); + }); + final endpoint = Uri.parse('http://127.0.0.1:${server.port}'); + final transport = ExternalCodeAgentAcpDesktopTransport( + client: GatewayAcpClient(endpointResolver: () => endpoint), + endpointResolver: (_) => endpoint, + taskEndpointResolver: (_) => endpoint, + ); + + await expectLater( + transport.executeTask( + _taskRequest( + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.openclaw, + ), + onUpdate: (_) {}, + ), + throwsA( + isA() + .having((error) => error.code, 'code', 'ACP_HTTP_502') + .having( + (error) => error.message, + 'message', + contains('openclaw upstream request failed'), + ), + ), + ); + }, + ); + test('desktop follow-up execution uses session.message', () async { final capture = await _startAcpHttpServer(); addTearDown(capture.close);