fix: preserve bridge acp 502 diagnostics

This commit is contained in:
Haitao Pan 2026-05-01 14:43:05 +08:00
parent b582b1e815
commit 179294d059
3 changed files with 209 additions and 9 deletions

View File

@ -132,6 +132,8 @@ class ExternalCodeAgentAcpDesktopTransport
streamedText: streamedText,
completedMessage: completedMessage,
);
} on GatewayAcpException {
rethrow;
} catch (error) {
throw GatewayAcpException(
error.toString(),

View File

@ -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<String, dynamic> decoded) {
final candidates = <String>[];
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<String, dynamic>();
addCandidate(errorMap['data']);
addCandidate(errorMap['details']);
}
for (final key in const <String>[
'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<Object>? 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 ?? <Object>{};
if (!seen.add(value)) {
return '';
}
if (value is Map) {
final map = value.cast<String, dynamic>();
final parts = <String>[];
for (final key in const <String>[
'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<String> _resolveAuthorizationHeader(
Uri endpoint, {
String authorizationOverride = '',

View File

@ -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(<String, dynamic>{
'error': <String, dynamic>{
'message': 'openclaw upstream request failed',
'data': <String, dynamic>{
'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 <String, dynamic>{},
),
throwsA(
isA<GatewayAcpException>()
.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 <String, String>{},store: store);
environmentOverride: const <String, String>{},
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 <String, String>{},store: store);
environmentOverride: const <String, String>{},
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 <String, String>{},store: store);
environmentOverride: const <String, String>{},
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(<String, dynamic>{
'error': <String, dynamic>{
'message': 'openclaw upstream request failed',
'data': <String, dynamic>{
'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<GatewayAcpException>()
.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);