Merge branch 'codex/acp-jsonfix'

This commit is contained in:
Haitao Pan 2026-04-04 15:29:17 +08:00
commit fa068bc815
2 changed files with 114 additions and 3 deletions

View File

@ -465,6 +465,21 @@ class GatewayAcpClient {
.value(HttpHeaders.contentTypeHeader)
?.toLowerCase() ??
'';
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.transform(utf8.decoder).join();
throw GatewayAcpException(
_describeHttpError(
statusCode: response.statusCode,
contentType: contentType,
body: body,
),
code: 'ACP_HTTP_${response.statusCode}',
details: <String, dynamic>{
'statusCode': response.statusCode,
'contentType': contentType,
},
);
}
if (contentType.contains('text/event-stream')) {
return _consumeSseRpcResponse(
response: response,
@ -481,6 +496,58 @@ class GatewayAcpClient {
}
}
String _describeHttpError({
required int statusCode,
required String contentType,
required String body,
}) {
final base = 'ACP HTTP request failed ($statusCode)';
final normalizedType = contentType.trim();
if (normalizedType.isNotEmpty &&
!_contentTypeLooksJsonOrSse(normalizedType)) {
return '$base · unexpected content type: $normalizedType';
}
final detail = _extractErrorDetail(body);
if (detail.isNotEmpty) {
return '$base · $detail';
}
return base;
}
bool _contentTypeLooksJsonOrSse(String contentType) {
return contentType.contains('application/json') ||
contentType.contains('application/problem+json') ||
contentType.contains('text/json') ||
contentType.contains('text/event-stream');
}
String _extractErrorDetail(String body) {
final trimmed = body.trim();
if (trimmed.isEmpty) {
return '';
}
try {
final decoded = _decodeMap(trimmed);
final error = asMap(decoded['error']);
return (stringValue(error['message']) ??
stringValue(decoded['message']) ??
stringValue(decoded['detail']) ??
'')
.trim();
} on FormatException {
// Fall through to textual snippet extraction below.
}
final singleLine = trimmed.replaceAll(RegExp(r'\s+'), ' ');
if (singleLine.isEmpty) {
return '';
}
return singleLine.length <= 160
? singleLine
: '${singleLine.substring(0, 157)}...';
}
Future<String> _resolveAuthorizationHeader(
Uri endpoint, {
String authorizationOverride = '',

View File

@ -43,6 +43,29 @@ void main() {
expect(server.rpcMethods, contains('acp.capabilities'));
});
test('surfaces HTTP content-type errors without raw JSON parse failures', () async {
final server = await _AcpFakeServer.start(
disableWebSocket: true,
respondWithHtmlError: true,
);
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
);
await expectLater(
() => client.loadCapabilities(forceRefresh: true),
throwsA(
isA<GatewayAcpException>().having(
(error) => error.toString(),
'message',
contains('unexpected content type: text/html'),
),
),
);
});
test(
'forwards ACP authorization resolver headers over websocket',
() async {
@ -128,19 +151,31 @@ void main() {
}
class _AcpFakeServer {
_AcpFakeServer._(this._server, {required this.disableWebSocket});
_AcpFakeServer._(
this._server, {
required this.disableWebSocket,
required this.respondWithHtmlError,
});
final HttpServer _server;
final bool disableWebSocket;
final bool respondWithHtmlError;
final List<String> rpcMethods = <String>[];
String? lastWebSocketAuthorization;
String? lastHttpAuthorization;
Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}');
static Future<_AcpFakeServer> start({bool disableWebSocket = false}) async {
static Future<_AcpFakeServer> start({
bool disableWebSocket = false,
bool respondWithHtmlError = false,
}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = _AcpFakeServer._(server, disableWebSocket: disableWebSocket);
final fake = _AcpFakeServer._(
server,
disableWebSocket: disableWebSocket,
respondWithHtmlError: respondWithHtmlError,
);
unawaited(fake._listen());
return fake;
}
@ -200,6 +235,15 @@ class _AcpFakeServer {
lastHttpAuthorization = request.headers.value(
HttpHeaders.authorizationHeader,
);
if (respondWithHtmlError) {
request.response.statusCode = HttpStatus.notFound;
request.response.headers.set(HttpHeaders.contentTypeHeader, 'text/html');
request.response.write(
'<!doctype html><script>var key = "opencode-theme-id"</script>',
);
await request.response.close();
return;
}
final body = await utf8.decodeStream(request);
final envelope = _decodeMap(body);
final id = envelope['id'];