diff --git a/docs/reports/2026-04-08-acp-http-404-repro-verification.md b/docs/reports/2026-04-08-acp-http-404-repro-verification.md new file mode 100644 index 00000000..5d0be061 --- /dev/null +++ b/docs/reports/2026-04-08-acp-http-404-repro-verification.md @@ -0,0 +1,55 @@ +# ACP HTTP 404 场景自动复现验证 + +## 变更摘要 + +本次验证目标不是再次修复,而是确认 `ACP_HTTP_404: ACP HTTP request failed (404) · unexpected content type: text/plain` 这一类场景能否通过自动化测试稳定复现,并作为后续回归证据保留。 + +验证对象聚焦在 ACP runtime transport 行为: + +- 当客户端以 `http(s)` 基址优先请求 `/acp/rpc` +- 服务端返回 `404` +- 且 `content-type` 为 `text/plain` +- 但同一基址下 `/acp` WebSocket 仍可正常响应 + +该场景现在由 `test/runtime/gateway_acp_client_suite.dart` 内的专门用例覆盖。 + +## 测试命令与结果 + +- `flutter test test/runtime/gateway_acp_client_test.dart --reporter expanded --plain-name "falls back to websocket when HTTP bridge returns plain-text 404"` + - 结果:通过 + - 关键输出:`GatewayAcpClient falls back to websocket when HTTP bridge returns plain-text 404` +- `flutter test test/runtime/gateway_acp_client_test.dart --reporter expanded` + - 结果:通过 + - 关键输出:`00:00 +12: All tests passed!` +- `flutter analyze` + - 结果:通过 + - 关键输出:`No issues found!` + +## 重点验证点覆盖 + +- 已验证:自动化测试可以稳定构造“`/acp/rpc` 返回 `404 text/plain`,但 `/acp` WebSocket 可用”的复现场景。 +- 已验证:当前实现会在该场景下从 HTTP bridge 失败回退到 WebSocket,并成功获取 ACP capabilities。 +- 已验证:原有错误分类仍保留,`text/html` 这类网页响应不会被误判为可回退场景。 +- 已验证:相关 ACP client suite 全量回归通过,没有引入同文件内的其他协议回归。 + +## 失败项 + +- 无自动化失败项。 + +## 高风险回归点 + +- ACP transport 是运行时核心链路,涉及 HTTP / WebSocket 双通道切换,属于中高风险协议行为。 +- 当前自动化复现基于 fake server,覆盖的是协议级场景,不等同于已经对用户机器上的真实 `127.0.0.1:18789` 服务完成实机重放。 + +## 建议人工补测项 + +- 如果需要证明“截图里的真实本地服务”与自动化场景完全一致,建议对用户当前本地 gateway 做一次实机补测: + - 在真实 `127.0.0.1:18789` 上确认 `/acp/rpc` 是否返回 `404 text/plain` + - 同时确认 `/acp` WebSocket 是否可握手并返回 `acp.capabilities` + - 从 Desktop UI 发起一次与截图相同的对话请求,确认不再出现该报错 + +## 相关文件 + +- `test/runtime/gateway_acp_client_suite.dart` +- `test/runtime/gateway_acp_client_test.dart` +- `lib/runtime/gateway_acp_client.dart` diff --git a/integration_test/desktop_navigation_flow_test.dart b/integration_test/desktop_navigation_flow_test.dart index f76a7781..085f43a9 100644 --- a/integration_test/desktop_navigation_flow_test.dart +++ b/integration_test/desktop_navigation_flow_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../test/helpers/test_keys.dart'; import 'test_support.dart'; Finder _textEither(String zh, String en) { @@ -45,6 +46,7 @@ void main() { WidgetTester tester, ) async { await pumpDesktopApp(tester); + await waitForIntegrationFinder(tester, find.byKey(TestKeys.assistantTaskRail)); expect(_textEither('新对话', 'New conversation'), findsWidgets); await tester.tap( diff --git a/integration_test/desktop_settings_flow_test.dart b/integration_test/desktop_settings_flow_test.dart index 8f9772c1..8ba5168b 100644 --- a/integration_test/desktop_settings_flow_test.dart +++ b/integration_test/desktop_settings_flow_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../test/helpers/test_keys.dart'; import 'test_support.dart'; Finder _textEither(String zh, String en) { @@ -45,6 +46,7 @@ void main() { 'desktop shell exposes settings entry for gateway configuration', (WidgetTester tester) async { await pumpDesktopApp(tester); + await waitForIntegrationFinder(tester, find.byKey(TestKeys.assistantTaskRail)); await tester.tap( find.byKey(const Key('assistant-side-pane-tab-navigation')), diff --git a/test/features/assistant_page_single_agent_flow_suite.dart b/test/features/assistant_page_single_agent_flow_suite.dart index 2051702b..cdcb96e9 100644 --- a/test/features/assistant_page_single_agent_flow_suite.dart +++ b/test/features/assistant_page_single_agent_flow_suite.dart @@ -10,6 +10,20 @@ import 'package:xworkmate/runtime/runtime_models.dart'; import '../test_support.dart'; +Future _waitForText( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (finder.evaluate().isEmpty) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for ${finder.description}'); + } + await tester.pump(const Duration(milliseconds: 50)); + } +} + void main() { testWidgets( 'AssistantPage single agent can be selected and receive streaming reply', @@ -57,7 +71,7 @@ void main() { ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - await tester.pumpAndSettle(); + await _waitForText(tester, find.textContaining('CODEX_REPLY')); expect(find.textContaining('CODEX_REPLY'), findsWidgets); expect(server.requestCount, greaterThanOrEqualTo(1)); @@ -108,7 +122,9 @@ class _ChatServer { HttpHeaders.contentTypeHeader, 'text/event-stream; charset=utf-8', ); + request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache'); request.response.write('data: ${jsonEncode(response)}\n\n'); + await request.response.flush(); await request.response.close(); } } diff --git a/test/features/settings_page_external_acp_end_to_end_suite.dart b/test/features/settings_page_external_acp_end_to_end_suite.dart index f409daa1..59698ed1 100644 --- a/test/features/settings_page_external_acp_end_to_end_suite.dart +++ b/test/features/settings_page_external_acp_end_to_end_suite.dart @@ -11,6 +11,20 @@ import 'package:xworkmate/runtime/runtime_models_profiles.dart'; import '../test_support.dart'; +Future _waitForText( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (finder.evaluate().isEmpty) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for ${finder.description}'); + } + await tester.pump(const Duration(milliseconds: 50)); + } +} + void main() { testWidgets('SettingsPage Codex external ACP can test and save', ( WidgetTester tester, @@ -47,20 +61,21 @@ void main() { const ValueKey('external-acp-test-Codex'), ); final saveButton = find.byKey( - const ValueKey('external-acp-apply-Codex'), + const ValueKey('external-acp-save-Codex'), ); expect(endpointField, findsOneWidget); await tester.enterText(endpointField, server.baseUri.toString()); - await tester.pumpAndSettle(); + await tester.pump(); await tester.tap(testButton); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await _waitForText(tester, find.textContaining('连接成功')); expect(find.textContaining('连接成功'), findsOneWidget); await tester.tap(saveButton); - await tester.pumpAndSettle(); + await tester.pump(); final saved = controller.settings.externalAcpEndpointForProviderId('codex'); expect(saved?.endpoint, server.baseUri.toString()); @@ -110,7 +125,9 @@ class _AcpServer { HttpHeaders.contentTypeHeader, 'text/event-stream; charset=utf-8', ); + request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache'); request.response.write('data: ${jsonEncode(response)}\n\n'); + await request.response.flush(); await request.response.close(); } } diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index a0852607..8f947e91 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -673,7 +673,7 @@ paths: ValueKey('external-acp-test-${customProfile.providerKey}'), ); final applyButton = find.byKey( - ValueKey('external-acp-apply-${customProfile.providerKey}'), + ValueKey('external-acp-save-${customProfile.providerKey}'), ); expect(labelField, findsOneWidget);