From cedf4c481ef698f0dcad62efe0b1ab7ff0b43b22 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 8 Apr 2026 18:28:16 +0800 Subject: [PATCH] fix: retry ACP websocket after plain-text 404 --- lib/runtime/gateway_acp_client.dart | 33 +++++++++++++++++++++- test/runtime/gateway_acp_client_suite.dart | 32 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index fcef6a4b..d5c22b1e 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -321,7 +321,22 @@ class GatewayAcpClient { ); } catch (error) { if (error is GatewayAcpException) { - rethrow; + if (!_shouldRetryViaWebSocketAfterHttpFailure( + error, + endpoint: resolvedEndpoint, + )) { + rethrow; + } + try { + return await _requestViaWebSocket( + request, + onNotification: onNotification, + endpointOverride: resolvedEndpoint, + authorizationOverride: authorizationOverride, + ); + } catch (_) { + rethrow; + } } return _requestViaWebSocket( request, @@ -544,6 +559,22 @@ class GatewayAcpClient { return base; } + bool _shouldRetryViaWebSocketAfterHttpFailure( + GatewayAcpException error, { + required Uri? endpoint, + }) { + if (resolveAcpWebSocketEndpoint(endpoint) == null) { + return false; + } + final details = asMap(error.details); + final statusCode = intValue(details['statusCode']); + final contentType = (stringValue(details['contentType']) ?? '') + .trim() + .toLowerCase(); + return statusCode == HttpStatus.notFound && + (contentType.isEmpty || contentType.contains('text/plain')); + } + bool _contentTypeLooksJsonOrSse(String contentType) { return contentType.contains('application/json') || contentType.contains('application/problem+json') || diff --git a/test/runtime/gateway_acp_client_suite.dart b/test/runtime/gateway_acp_client_suite.dart index 0813623c..cad39010 100644 --- a/test/runtime/gateway_acp_client_suite.dart +++ b/test/runtime/gateway_acp_client_suite.dart @@ -117,6 +117,27 @@ void main() { }, ); + test( + 'falls back to websocket when HTTP bridge returns plain-text 404', + () async { + final server = await _AcpFakeServer.start( + respondWithPlainTextNotFound: true, + ); + addTearDown(server.close); + + final client = GatewayAcpClient( + endpointResolver: () => server.baseHttpUri, + ); + + final capabilities = await client.loadCapabilities(forceRefresh: true); + + expect(capabilities.singleAgent, isTrue); + expect(server.lastHttpRequestPath, '/acp/rpc'); + expect(server.lastWebSocketRequestPath, '/acp'); + expect(server.rpcMethods, contains('acp.capabilities')); + }, + ); + test( 'forwards ACP authorization resolver headers over websocket', () async { @@ -234,12 +255,14 @@ class _AcpFakeServer { this._server, { required this.disableWebSocket, required this.respondWithHtmlError, + required this.respondWithPlainTextNotFound, required this.pathPrefix, }); final HttpServer _server; final bool disableWebSocket; final bool respondWithHtmlError; + final bool respondWithPlainTextNotFound; final String pathPrefix; final List rpcMethods = []; String? lastWebSocketAuthorization; @@ -253,6 +276,7 @@ class _AcpFakeServer { static Future<_AcpFakeServer> start({ bool disableWebSocket = false, bool respondWithHtmlError = false, + bool respondWithPlainTextNotFound = false, String pathPrefix = '', }) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); @@ -260,6 +284,7 @@ class _AcpFakeServer { server, disableWebSocket: disableWebSocket, respondWithHtmlError: respondWithHtmlError, + respondWithPlainTextNotFound: respondWithPlainTextNotFound, pathPrefix: _normalizePathPrefix(pathPrefix), ); unawaited(fake._listen()); @@ -333,6 +358,13 @@ class _AcpFakeServer { await request.response.close(); return; } + if (respondWithPlainTextNotFound) { + request.response.statusCode = HttpStatus.notFound; + request.response.headers.set(HttpHeaders.contentTypeHeader, 'text/plain'); + request.response.write('not found'); + await request.response.close(); + return; + } final body = await utf8.decodeStream(request); final envelope = _decodeMap(body); final id = envelope['id'];