From e54a97bd364b5f9dabc3add8e77ea2812548ed5b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 09:05:02 +0800 Subject: [PATCH 01/13] fix: keep running task follow-up in current thread --- .../assistant_page_state_actions.dart | 5 -- .../assistant_page_session_binding_test.dart | 55 +++++++++++++ .../assistant_execution_target_test.dart | 79 ++++++++++++++++++- 3 files changed, 130 insertions(+), 9 deletions(-) diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 7b563b5d..fb74d393 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -85,11 +85,6 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { if (rawPrompt.isEmpty) { return; } - if (controller.assistantSessionHasPendingRun( - controller.currentSessionKey, - )) { - await createNewThreadInternal(); - } final submittedSessionKey = controller.currentSessionKey; final autoAgent = pickAutoAgentInternal(controller, rawPrompt); diff --git a/test/features/assistant/assistant_page_session_binding_test.dart b/test/features/assistant/assistant_page_session_binding_test.dart index 484435ef..8a715503 100644 --- a/test/features/assistant/assistant_page_session_binding_test.dart +++ b/test/features/assistant/assistant_page_session_binding_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/assistant/assistant_page_main.dart'; +import 'package:xworkmate/features/assistant/assistant_page_state_actions.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/theme/app_theme.dart'; @@ -114,4 +117,56 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(milliseconds: 100)); }); + + testWidgets('keeps follow-up submit bound to the running task', ( + tester, + ) async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + final pageKey = GlobalKey(); + + const sessionKey = 'running-task'; + await controller.sessionsController.switchSession(sessionKey); + controller.aiGatewayPendingSessionKeysInternal.add(sessionKey); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Material( + child: SizedBox( + width: 1280, + height: 760, + child: AssistantPage( + key: pageKey, + controller: controller, + showStandaloneTaskRail: false, + onOpenDetail: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + final state = pageKey.currentState!; + state.inputControllerInternal.text = 'continue current task'; + + unawaited( + state.submitPromptInternal().catchError((_) { + return null; + }), + ); + await tester.pump(); + + expect(controller.currentSessionKey, sessionKey); + expect( + controller.assistantSessions.map((session) => session.key), + isNot(contains(startsWith('draft:'))), + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + }); } diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index c5b079a5..be4435b7 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1812,6 +1812,77 @@ void main() { ); }); + test( + 'sendChatMessage queues follow-up turns on the same session', + () async { + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedController(fakeGoTaskService); + addTearDown(controller.dispose); + + await controller.switchSession('running-task'); + final firstFuture = controller.sendChatMessage('first turn'); + await fakeGoTaskService.waitForRequestCount(1); + expect(fakeGoTaskService.requests.single.sessionId, 'running-task'); + expect(fakeGoTaskService.requests.single.threadId, 'running-task'); + expect( + controller.assistantSessionHasPendingRun('running-task'), + isTrue, + ); + + final secondFuture = controller.sendChatMessage('follow up'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(fakeGoTaskService.requests, hasLength(1)); + expect(controller.currentSessionKey, 'running-task'); + + fakeGoTaskService.complete( + 'running-task', + const GoTaskServiceResult( + success: true, + message: 'first result', + turnId: 'turn-1', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await firstFuture; + await fakeGoTaskService.waitForRequestCount(2); + + final followUpRequest = fakeGoTaskService.requests.last; + expect(followUpRequest.sessionId, 'running-task'); + expect(followUpRequest.threadId, 'running-task'); + expect(followUpRequest.prompt, contains('follow up')); + expect(controller.currentSessionKey, 'running-task'); + + fakeGoTaskService.complete( + 'running-task', + const GoTaskServiceResult( + success: true, + message: 'second result', + turnId: 'turn-2', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await secondFuture; + + expect( + controller.localSessionMessagesInternal['running-task']!.map( + (message) => message.text, + ), + containsAll([ + 'first turn', + 'first result', + 'follow up', + 'second result', + ]), + ); + }, + ); + test( 'background task completion does not overwrite the selected session', () async { @@ -2516,7 +2587,7 @@ void main() { await expectLater( controller .sendChatMessage('active prompt $index') - .timeout(const Duration(milliseconds: 250)), + .timeout(const Duration(seconds: 2)), completes, ); await fakeGoTaskService.waitForRequestCount(index + 1); @@ -2536,7 +2607,7 @@ void main() { 'queued prompt', attachments: [queuedAttachment], ) - .timeout(const Duration(milliseconds: 250)), + .timeout(const Duration(seconds: 2)), completes, ); await _waitForThreadLifecycleStatus( @@ -2655,7 +2726,7 @@ void main() { final taskFuture = controller.sendChatMessage('use OpenClaw default'); await fakeGoTaskService.waitForRequestCount(1); await expectLater( - taskFuture.timeout(const Duration(milliseconds: 250)), + taskFuture.timeout(const Duration(seconds: 2)), completes, ); @@ -3707,7 +3778,7 @@ Future> _startOpenClawActiveTasks( await expectLater( controller .sendChatMessage('active task $index') - .timeout(const Duration(milliseconds: 250)), + .timeout(const Duration(seconds: 2)), completes, ); await fakeGoTaskService.waitForRequestCount(index + 1); From 8c29172fdf83c3195a586669ba58d90d0295116b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 10:41:46 +0800 Subject: [PATCH 02/13] fix: isolate openclaw e2e artifacts --- .../desktop_thread_artifact_service.dart | 8 ++- ...troller_thread_workspace_binding_test.dart | 6 +- .../assistant_execution_target_test.dart | 61 +++++++++++++++++-- .../desktop_thread_artifact_service_test.dart | 23 ++++--- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/lib/runtime/desktop_thread_artifact_service.dart b/lib/runtime/desktop_thread_artifact_service.dart index 1f9acf21..d046f301 100644 --- a/lib/runtime/desktop_thread_artifact_service.dart +++ b/lib/runtime/desktop_thread_artifact_service.dart @@ -60,7 +60,9 @@ class DesktopThreadArtifactService { final taskArtifactPaths = normalizeTaskArtifactPathsInternal( artifactRelativePaths, ); - final allFiles = await collectFilesInternal(root); + final allFiles = taskArtifactPaths.isEmpty + ? const [] + : await collectFilesInternal(root); final fileEntries = await buildEntriesInternal(allFiles, normalizedRef); final taskFiles = taskArtifactPaths.isEmpty ? const [] @@ -91,7 +93,9 @@ class DesktopThreadArtifactService { ? 'No task artifacts recorded for this run.' : 'No current task artifacts found. Showing all files for this thread.' : ''; - final filesMessage = fileEntries.isEmpty + final filesMessage = taskArtifactPaths.isEmpty + ? '' + : fileEntries.isEmpty ? 'No files found in the recorded working directory.' : ''; final changesMessage = changes.isEmpty diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 5aab6cec..3025ceea 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -1310,10 +1310,8 @@ void main() { sessionKey: 'unit-fixture-task-a', ); expect(snapshot.resultEntries, isEmpty); - expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - contains('old-task-report.md'), - ); + expect(snapshot.fileEntries, isEmpty); + expect(snapshot.resultMessage, 'No task artifacts recorded for this run.'); }); test('skips download URL artifacts outside the bridge host', () async { diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index be4435b7..17d01d25 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -2263,9 +2263,10 @@ void main() { ); expect(taskBSnapshot.fileEntries, isEmpty); expect( - taskBSnapshot.filesMessage, - 'No files found in the recorded working directory.', + taskBSnapshot.resultMessage, + 'No task artifacts recorded for this run.', ); + expect(taskBSnapshot.filesMessage, isEmpty); }, ); @@ -2404,9 +2405,10 @@ void main() { sessionKey: 'terminal-failure-task', ); expect(snapshot.resultEntries, isEmpty); + expect(snapshot.fileEntries, isEmpty); expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - contains('first.md'), + snapshot.resultMessage, + 'No task artifacts recorded for this run.', ); }, ); @@ -2709,6 +2711,57 @@ void main() { }, ); + test( + 'OpenClaw gateway admits five representative E2E tasks without queueing', + () async { + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(() { + fakeGoTaskService.completeAll(); + controller.dispose(); + }); + + const prompts = [ + '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 制作 使用codex 制作连续制作 7张的一些列图片', + '参考附件模版制作 ,围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 连续制作 7张的一些列图片', + '拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF', + '围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 测试制作视频', + '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频', + ]; + + for (var index = 0; index < prompts.length; index += 1) { + final sessionKey = 'openclaw-e2e-$index'; + await _selectGatewaySession(controller, sessionKey); + await expectLater( + controller + .sendChatMessage(prompts[index]) + .timeout(const Duration(seconds: 2)), + completes, + ); + } + + await fakeGoTaskService.waitForRequestCount(prompts.length); + expect(fakeGoTaskService.requests, hasLength(prompts.length)); + expect(controller.openClawGatewayActiveTasksInternal, prompts.length); + expect(controller.openClawGatewayQueuedTurnsInternal, isEmpty); + for (var index = 0; index < prompts.length; index += 1) { + final sessionKey = 'openclaw-e2e-$index'; + expect( + controller + .requireTaskThreadForSessionInternal(sessionKey) + .lifecycleState + .status, + 'running', + ); + expect(fakeGoTaskService.requests[index].sessionId, sessionKey); + expect( + fakeGoTaskService.requests[index].prompt, + contains(prompts[index]), + ); + } + }, + ); + test('OpenClaw gateway task uses the server default model', () async { final fakeGoTaskService = _BlockingGoTaskServiceClient(); final controller = _connectedGatewayController(fakeGoTaskService); diff --git a/test/runtime/desktop_thread_artifact_service_test.dart b/test/runtime/desktop_thread_artifact_service_test.dart index 9917a34b..a797093f 100644 --- a/test/runtime/desktop_thread_artifact_service_test.dart +++ b/test/runtime/desktop_thread_artifact_service_test.dart @@ -1,12 +1,13 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/assistant_artifacts.dart'; import 'package:xworkmate/runtime/desktop_thread_artifact_service.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; void main() { test( - 'loadSnapshot exposes all local workspace files when current run has no artifacts', + 'loadSnapshot hides historical workspace files when current run has no artifacts', () async { final workspace = await Directory.systemTemp.createTemp( 'xworkmate-artifact-snapshot-', @@ -25,10 +26,7 @@ void main() { ); expect(snapshot.resultEntries, isEmpty); - expect( - snapshot.fileEntries.map((entry) => entry.relativePath), - contains('historical.md'), - ); + expect(snapshot.fileEntries, isEmpty); expect( snapshot.resultMessage, 'No task artifacts recorded for this run.', @@ -80,13 +78,14 @@ void main() { } }); await File('${workspace.path}/historical.md').writeAsString('# Old\n'); - final snapshot = await DesktopThreadArtifactService().loadSnapshot( - workspacePath: workspace.path, - workspaceKind: WorkspaceRefKind.localPath, - artifactRelativePaths: const [], - ); - final historical = snapshot.fileEntries.singleWhere( - (entry) => entry.relativePath == 'historical.md', + const historical = AssistantArtifactEntry( + id: 'historical.md', + label: 'historical.md', + relativePath: 'historical.md', + kind: AssistantArtifactEntryKind.file, + mimeType: 'text/markdown', + previewable: true, + workspacePath: '', ); final preview = await DesktopThreadArtifactService().loadPreview( From 109dbd219ff76f11955b0976fc627033ffefeae3 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 13:31:48 +0800 Subject: [PATCH 03/13] fix(assistant): pin task session on submit --- lib/app/app_controller_desktop_core.dart | 2 + ...app_controller_desktop_thread_actions.dart | 21 +++++--- .../assistant_page_state_actions.dart | 16 +++++-- .../assistant_execution_target_test.dart | 48 +++++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 4b77df43..7a3c3319 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -604,12 +604,14 @@ class AppController extends ChangeNotifier { Future sendChatMessage( String message, { + String? sessionKey, String thinking = 'off', List attachments = const [], List localAttachments = const [], List selectedSkillLabels = const [], }) => AppControllerDesktopThreadActions(this).sendChatMessage( message, + sessionKey: sessionKey, thinking: thinking, attachments: attachments, localAttachments: localAttachments, diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 031bd64c..a863de71 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -232,6 +232,7 @@ extension AppControllerDesktopThreadActions on AppController { Future sendChatMessage( String message, { + String? sessionKey, String thinking = 'off', List attachments = const [], @@ -239,20 +240,28 @@ extension AppControllerDesktopThreadActions on AppController { const [], List selectedSkillLabels = const [], }) async { - var sessionKey = normalizedAssistantSessionKeyInternal( - sessionsControllerInternal.currentSessionKey, + var targetSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey ?? sessionsControllerInternal.currentSessionKey, ); - if (!isAppOwnedAssistantSessionKeyInternal(sessionKey)) { + if (!isAppOwnedAssistantSessionKeyInternal(targetSessionKey)) { + if (sessionKey != null && sessionKey.trim().isNotEmpty) { + throw StateError( + appText( + '提交目标会话无效,请重新选择任务后提交。', + 'The submit target session is invalid. Select the task again before submitting.', + ), + ); + } await ensureActiveAssistantThreadInternal(); - sessionKey = normalizedAssistantSessionKeyInternal( + targetSessionKey = normalizedAssistantSessionKeyInternal( sessionsControllerInternal.currentSessionKey, ); } final resumeSessionHint = shouldResumeGatewaySessionForNextSendInternal( - sessionKey, + targetSessionKey, ); await dispatchGatewayChatTurnInternal( - sessionKey: sessionKey, + sessionKey: targetSessionKey, message: message, thinking: thinking, attachments: attachments, diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index fb74d393..765ea877 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -129,10 +129,10 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { autoAgent?.name ?? conversationOwnerLabelInternal(controller); attachmentsInternal = const []; touchTaskSeedInternal( - sessionKey: controller.currentSessionKey, + sessionKey: submittedSessionKey, title: - taskSeedsInternal[controller.currentSessionKey]?.title ?? - fallbackSessionTitleInternal(controller.currentSessionKey), + taskSeedsInternal[submittedSessionKey]?.title ?? + fallbackSessionTitleInternal(submittedSessionKey), preview: rawPrompt, status: controller.hasAssistantPendingRun || connectionState.connected ? 'running' @@ -140,7 +140,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { owner: autoAgent?.name ?? conversationOwnerLabelInternal(controller), surface: 'Assistant', executionTarget: executionTarget, - draft: controller.currentSessionKey.trim().startsWith('draft:'), + draft: submittedSessionKey.trim().startsWith('draft:'), ); }); inputControllerInternal.clear(); @@ -148,6 +148,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { try { await controller.sendChatMessage( prompt, + sessionKey: submittedSessionKey, thinking: thinkingLabelInternal, attachments: attachmentPayloads, localAttachments: submittedAttachments @@ -166,7 +167,12 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { if (!mounted) { rethrow; } - if (inputControllerInternal.text.trim().isEmpty) { + if (!sessionKeysMatchInternal( + widget.controller.currentSessionKey, + submittedSessionKey, + )) { + composerDraftBySessionKeyInternal[submittedSessionKey] = rawPrompt; + } else if (inputControllerInternal.text.trim().isEmpty) { inputControllerInternal.value = TextEditingValue( text: rawPrompt, selection: TextSelection.collapsed(offset: rawPrompt.length), diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 17d01d25..43cf7ab0 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1812,6 +1812,54 @@ void main() { ); }); + test('sendChatMessage can pin submit to the captured session', () async { + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedController(fakeGoTaskService); + addTearDown(controller.dispose); + + await controller.switchSession('same-prompt-old-task'); + await controller.switchSession('same-prompt-new-task'); + final taskFuture = controller.sendChatMessage( + '连续制作7张图片', + sessionKey: 'same-prompt-new-task', + ); + await fakeGoTaskService.waitForRequestCount(1); + + final request = fakeGoTaskService.requests.single; + expect(request.sessionId, 'same-prompt-new-task'); + expect(request.threadId, 'same-prompt-new-task'); + expect(request.workingDirectory, endsWith('/same-prompt-new-task')); + expect( + request.remoteWorkingDirectoryHint, + endsWith('/threads/same-prompt-new-task'), + ); + + fakeGoTaskService.complete( + 'same-prompt-new-task', + const GoTaskServiceResult( + success: true, + message: 'new task result', + turnId: 'turn-new', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await taskFuture; + + expect( + controller.localSessionMessagesInternal['same-prompt-new-task']!.map( + (message) => message.text, + ), + contains('new task result'), + ); + expect( + controller.localSessionMessagesInternal['same-prompt-old-task'], + isNot(contains('new task result')), + ); + }); + test( 'sendChatMessage queues follow-up turns on the same session', () async { From e6cde355bccba8431b9552d4a1316bb84b7aeb34 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 14:05:47 +0800 Subject: [PATCH 04/13] docs: record openclaw gateway e2e cases --- docs/cases/README.md | 1 + .../openclaw-gateway-e2e-regression/README.md | 127 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 docs/cases/openclaw-gateway-e2e-regression/README.md diff --git a/docs/cases/README.md b/docs/cases/README.md index d5354294..0f57c471 100644 --- a/docs/cases/README.md +++ b/docs/cases/README.md @@ -7,6 +7,7 @@ - [核心功能集成测试手动 Case](./core-integration-manual-cases.md) - [AI 安全演进内容生成场景测试用例](./ai-security-evolution-content-scenario/README.md) - [云原生 Service Mesh 网络科普视频调研场景测试用例](./service-mesh-evolution-video-scenario/README.md) +- [OpenClaw Gateway 5 并发 E2E 回归场景](./openclaw-gateway-e2e-regression/README.md) ## 配套文档 diff --git a/docs/cases/openclaw-gateway-e2e-regression/README.md b/docs/cases/openclaw-gateway-e2e-regression/README.md new file mode 100644 index 00000000..42061947 --- /dev/null +++ b/docs/cases/openclaw-gateway-e2e-regression/README.md @@ -0,0 +1,127 @@ +# OpenClaw Gateway 5 并发 E2E 回归场景 + +这个 case 固化 5 个真实 OpenClaw Gateway 提示词,用于验证 XWorkmate App -> XWorkmate Bridge -> OpenClaw Gateway 的 5 并发稳定性、任务隔离和 artifact 同步。 + +## 覆盖目标 + +- 连续出图:7 张连续风格 PNG。 +- 模板出图:参考附件模板生成 7 张连续 PNG。 +- PDF:拆章节、逐章生成图、汇总排版并输出 PDF。 +- 视频:围绕同一安全演进主线制作测试视频。 +- 视频流水线:拆章节、逐章调用 Codex/GPT Images、汇总排版并制作视频。 + +## 自动化落点 + +| 仓库 | 文件 | 覆盖点 | +| --- | --- | --- | +| `xworkmate-bridge` | `internal/acp/web_contract_test.go` | `TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases` 通过 HTTP SSE 同时提交 5 个 OpenClaw Gateway 请求,断言不出现 queued、invalid handshake、socket closed、ACP_HTTP_CONNECTION_CLOSED、GATEWAY_CONNECT_FAILED。 | +| `xworkmate-app` | `test/runtime/assistant_execution_target_test.dart` | `OpenClaw gateway admits five representative E2E tasks without queueing` 断言 App 侧 5 个代表任务同时进入 running,复用各自 session/thread,不进入 queued。 | +| `openclaw-multi-session-plugins` | `src/exportArtifacts.test.ts` | 同线程 `assets/images/**/*.png`、manifest、视频/PDF 交付物能被 export 到当前 task artifact scope,不串到旧线程或旧 run。 | + +## 5 个提示词 + +### `OPENCLAW-E2E-001` 连续出图 + +```text +从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 制作 使用codex 制作连续制作 7张的一些列图片 +``` + +期望结果: + +- 任务进入 running,不因为 5 并发停在 pending/queued。 +- 输出 7 张独立 PNG,不合并成一张总览图。 +- artifact 区显示当前任务本轮导出的 PNG 和 manifest。 + +### `OPENCLAW-E2E-002` 模板出图 + +```text +参考附件模版制作 ,围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 连续制作 7张的一些列图片 +``` + +期望结果: + +- 任务复用当前线程 workspace 和附件上下文。 +- 7 张图片保持模板风格一致。 +- 当前任务 artifact 不展示旧线程文件。 + +### `OPENCLAW-E2E-003` PDF + +```text +拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF +``` + +期望结果: + +- 每章图片素材和最终 PDF 归属当前 task scope。 +- PDF 或相关素材出现在当前任务 artifact 区。 +- 如果 OpenClaw 没有实际导出文件,App 显示 no exported artifacts,而不是旧文件。 + +### `OPENCLAW-E2E-004` 视频 + +```text +围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 测试制作视频 +``` + +期望结果: + +- 视频任务在 5 并发下不触发 `GATEWAY_CONNECT_FAILED: SOCKET_CLOSED`。 +- 输出视频帧、配置或 MP4 时,artifact 只属于当前任务。 +- 失败时释放 active slot 并继续 drain 后续任务。 + +### `OPENCLAW-E2E-005` 视频流水线 + +```text +从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频 +``` + +期望结果: + +- 图片、manifest、视频配置、MP4/ffprobe 等产物按当前 run 隔离。 +- Bridge 和 OpenClaw Gateway 只建立稳定连接,不重复并发握手。 +- 不出现 `invalid handshake: first request must be connect`、`SOCKET_CLOSED`、`ACP_HTTP_CONNECTION_CLOSED`。 + +## 手动验收步骤 + +前置条件: + +- `/Applications/XWorkmate.app` 已安装当前 release。 +- 已登录并同步 managed bridge。 +- 任务模式选择 `Gateway`,provider 选择 `OpenClaw`。 +- `xworkmate-bridge.svc.plus` 和远端 OpenClaw Gateway 可用。 + +操作步骤: + +1. 新建 5 个任务线程,分别输入上面的 5 个提示词。 +2. 在短时间内连续提交,保持 `maxActive = 5`、`maxQueued = 20`。 +3. 观察左侧任务列表,5 个任务应进入 running 或完成态,不应全部 pending。 +4. 逐个打开任务,检查中心消息和右侧 artifact 区。 +5. 对任意 running 任务继续提交一句补充要求,确认续聊仍落在当前任务而不是新 draft。 + +验收标准: + +- 5 个任务不出现全等待不运行。 +- 不出现 `invalid handshake: first request must be connect`。 +- 不出现 `GATEWAY_CONNECT_FAILED: SOCKET_CLOSED: socket closed`。 +- 不出现 `ACP_HTTP_CONNECTION_CLOSED`。 +- 当前任务没有 artifact 时显示明确空态,不显示旧 run 文件。 +- 当前任务生成 PNG/PDF/视频文件时,右侧 artifact 自动同步并只显示当前任务本轮文件。 + +## 回归命令 + +```bash +# xworkmate-bridge +go test ./... + +# openclaw-multi-session-plugins +pnpm test +pnpm typecheck +pnpm pack:check + +# xworkmate-app +flutter analyze +flutter test test/runtime/assistant_execution_target_test.dart +flutter test test/runtime/gateway_acp_client_auth_test.dart +flutter test test/runtime/desktop_thread_artifact_service_test.dart +flutter test +``` + From 94236c90e1e09a6aa0d910b148e4c7d2324d16b2 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 14:09:49 +0800 Subject: [PATCH 05/13] test: align openclaw e2e prompts --- .../openclaw-gateway-e2e-regression/README.md | 23 +++++++++++++++---- .../assistant_execution_target_test.dart | 10 ++++---- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/cases/openclaw-gateway-e2e-regression/README.md b/docs/cases/openclaw-gateway-e2e-regression/README.md index 42061947..d3fe8550 100644 --- a/docs/cases/openclaw-gateway-e2e-regression/README.md +++ b/docs/cases/openclaw-gateway-e2e-regression/README.md @@ -20,10 +20,13 @@ ## 5 个提示词 +以下提示词按原始 E2E 输入记录,作为长期回归 case 的 canonical prompt。 + ### `OPENCLAW-E2E-001` 连续出图 ```text -从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 制作 使用codex 制作连续制作 7张的一些列图片 +从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 +制作 使用codex 制作连续制作 7张的一些列图片 ``` 期望结果: @@ -35,7 +38,9 @@ ### `OPENCLAW-E2E-002` 模板出图 ```text -参考附件模版制作 ,围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 连续制作 7张的一些列图片 +参考附件模版制作 ,围绕 +从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 +连续制作 7张的一些列图片 ``` 期望结果: @@ -48,18 +53,23 @@ ```text 拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF + +右侧 artifact栏 显示的陈旧文件 ``` 期望结果: - 每章图片素材和最终 PDF 归属当前 task scope。 - PDF 或相关素材出现在当前任务 artifact 区。 +- 回归缺陷点:右侧 artifact 栏不能显示其他 run 或历史 workspace 的陈旧文件。 - 如果 OpenClaw 没有实际导出文件,App 显示 no exported artifacts,而不是旧文件。 ### `OPENCLAW-E2E-004` 视频 ```text -围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 测试制作视频 +围绕 +从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 +测试制作视频 ``` 期望结果: @@ -71,7 +81,11 @@ ### `OPENCLAW-E2E-005` 视频流水线 ```text -从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频 +围绕 + +从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 + +拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频 ``` 期望结果: @@ -124,4 +138,3 @@ flutter test test/runtime/gateway_acp_client_auth_test.dart flutter test test/runtime/desktop_thread_artifact_service_test.dart flutter test ``` - diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 43cf7ab0..e8f81e1c 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -2770,11 +2770,11 @@ void main() { }); const prompts = [ - '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 制作 使用codex 制作连续制作 7张的一些列图片', - '参考附件模版制作 ,围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 连续制作 7张的一些列图片', - '拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF', - '围绕 从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 测试制作视频', - '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频', + '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n制作 使用codex 制作连续制作 7张的一些列图片', + '参考附件模版制作 ,围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n连续制作 7张的一些列图片', + '拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF\n\n右侧 artifact栏 显示的陈旧文件', + '围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频', + '围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频', ]; for (var index = 0; index < prompts.length; index += 1) { From fcc579e6794f10498a5baa68d3d956aeabcd7a30 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 14:30:19 +0800 Subject: [PATCH 06/13] refactor: classify gateway task load --- ...app_controller_desktop_thread_actions.dart | 82 ++++++++++++++++++- .../assistant_execution_target_test.dart | 80 ++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index a863de71..a89048c7 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -460,6 +460,11 @@ extension AppControllerDesktopThreadActions on AppController { final capturedLocalAttachments = List.unmodifiable( localAttachments, ); + final taskLoadClass = classifyGatewayTaskLoadInternal(message); + final taskMetadata = Map.unmodifiable({ + ...dispatch.metadata, + 'taskLoadClass': taskLoadClass, + }); final executionWorkingDirectory = gatewayExecutionWorkingDirectoryInternal( target: currentTarget, workingDirectory: workingDirectory, @@ -484,7 +489,7 @@ extension AppControllerDesktopThreadActions on AppController { model: model, routing: routing, agentId: dispatch.agentId ?? '', - metadata: Map.unmodifiable(dispatch.metadata), + metadata: taskMetadata, resumeSessionHint: resumeSessionHint, appendUserTurn: appendUserTurn, ), @@ -509,7 +514,7 @@ extension AppControllerDesktopThreadActions on AppController { model: model, routing: routing, agentId: dispatch.agentId ?? '', - metadata: Map.unmodifiable(dispatch.metadata), + metadata: taskMetadata, resumeSessionHint: resumeSessionHint, appendUserTurn: appendUserTurn, ), @@ -681,11 +686,84 @@ extension AppControllerDesktopThreadActions on AppController { '6. The app syncs final artifacts from currentTaskWorkspace back into localWorkspace.', ) ..writeln() + ..writeln('Task load classification:') + ..writeln('- class: ${classifyGatewayTaskLoadInternal(requestText)}') + ..writeln( + '- Gateway owns execution decomposition, scheduling, retries, and resumability for this class.', + ) + ..writeln() + ..writeln( + 'Available classes: short_task, long_task, complex_long_chain_task.', + ) + ..writeln(); + buffer ..writeln('User request:') ..write(requestText); return buffer.toString(); } + String classifyGatewayTaskLoadInternal(String requestText) { + final normalized = requestText.trim().toLowerCase(); + if (normalized.isEmpty) { + return 'short_task'; + } + final hasChapterSplit = + normalized.contains('拆章节') || + normalized.contains('chapter') || + normalized.contains('章节'); + final hasAgentStage = + normalized.contains('codex') || + normalized.contains('agent') || + normalized.contains('调用'); + final hasImageStage = + normalized.contains('gpt images') || + normalized.contains('images2') || + normalized.contains('生成图') || + normalized.contains('图片'); + final hasPackagingStage = + normalized.contains('汇总排版') || + normalized.contains('排版') || + normalized.contains('制作视频') || + normalized.contains('视频') || + normalized.contains('mp4'); + final hasChainArrows = + normalized.contains('->') || normalized.contains('→'); + if (hasChapterSplit && + hasAgentStage && + hasImageStage && + hasPackagingStage && + hasChainArrows) { + return 'complex_long_chain_task'; + } + const longTaskMarkers = [ + '生成文件', + '产物', + '附件', + '图片提示词', + '完整调研ppt', + 'markdown格式', + '输出markdown', + 'ppt', + 'pptx', + 'powerpoint', + 'word', + 'docx', + 'png', + 'mp4', + 'jpg', + 'markdown', + '.md', + 'image prompt', + 'artifacts', + 'downloadurl', + ]; + if (requestText.length >= 1200 || + longTaskMarkers.any(normalized.contains)) { + return 'long_task'; + } + return 'short_task'; + } + bool usesOpenClawGatewayQueueInternal( AssistantExecutionTarget target, SingleAgentProvider provider, diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index e8f81e1c..452cd17a 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1143,6 +1143,86 @@ void main() { ); }); + test( + 'sendChatMessage classifies complex artifact chains for Gateway', + () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + + await controller.ensureActiveAssistantThreadInternal(); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + await controller.sendChatMessage( + '围绕\n\n' + '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进\n\n' + '拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频', + ); + + expect(fakeGoTaskService.requests, hasLength(1)); + final request = fakeGoTaskService.requests.single; + expect(request.metadata['taskLoadClass'], 'complex_long_chain_task'); + expect(request.prompt, contains('Task load classification:')); + expect(request.prompt, contains('- class: complex_long_chain_task')); + expect( + request.prompt, + contains( + 'Gateway owns execution decomposition, scheduling, retries, and resumability for this class.', + ), + ); + expect( + request.prompt, + isNot(contains('First write the chapter breakdown')), + ); + expect( + request.prompt, + isNot(contains('Run heavyweight stages in order')), + ); + expect( + request.prompt, + contains('User request:\n围绕\n\n从单机权限 → 网络边界 → Web安全'), + ); + }, + ); + + test( + 'sendChatMessage classifies simple Gateway prompts as short tasks', + () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + + await controller.ensureActiveAssistantThreadInternal(); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + await controller.sendChatMessage('写一段普通说明'); + + expect(fakeGoTaskService.requests, hasLength(1)); + final request = fakeGoTaskService.requests.single; + expect(request.metadata['taskLoadClass'], 'short_task'); + expect(request.prompt, contains('- class: short_task')); + }, + ); + + test('sendChatMessage classifies artifact output as a long task', () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + + await controller.ensureActiveAssistantThreadInternal(); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + await controller.sendChatMessage('生成 Markdown 和 PNG 产物'); + + expect(fakeGoTaskService.requests, hasLength(1)); + final request = fakeGoTaskService.requests.single; + expect(request.metadata['taskLoadClass'], 'long_task'); + expect(request.prompt, contains('- class: long_task')); + }); + test( 'sendChatMessage runs Gateway task with remote workspace when local workspace is unavailable', () async { From 7bf9ed4e4009660d2cfb9e7d16b346080e35c302 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 19:17:38 +0800 Subject: [PATCH 07/13] feat: sync existing workspace directory artifacts recursively --- ...pp_controller_desktop_runtime_helpers.dart | 68 ++++++++++++++- ...troller_thread_workspace_binding_test.dart | 82 +++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 9e101e6b..f7ce4baa 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -792,7 +792,17 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } final bytes = bytesResult.bytes; if (bytes == null) { - skippedArtifact = true; + final existingArtifactPaths = + await _existingWorkspaceArtifactPathsInternal(root, relativePath); + if (existingArtifactPaths.isEmpty) { + skippedArtifact = true; + continue; + } + wroteArtifact = true; + _appendArtifactRelativePathsInternal( + currentTaskArtifactRelativePaths, + existingArtifactPaths, + ); continue; } final target = await _nextArtifactTargetFileInternal(root, relativePath); @@ -813,7 +823,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { target.path, ); if (writtenRelativePath != null && writtenRelativePath.isNotEmpty) { - currentTaskArtifactRelativePaths.add(writtenRelativePath); + _appendArtifactRelativePathsInternal( + currentTaskArtifactRelativePaths, + [writtenRelativePath], + ); } } @@ -1145,6 +1158,57 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ) => kGatewayRemoteProfileIndex; } +void _appendArtifactRelativePathsInternal( + List target, + List paths, +) { + for (final path in paths) { + if (!target.contains(path)) { + target.add(path); + } + } +} + +Future> _existingWorkspaceArtifactPathsInternal( + Directory root, + String relativePath, +) async { + final targetPath = DesktopThreadArtifactService.resolveAbsolutePathInternal( + root.path, + relativePath, + ); + final targetType = await FileSystemEntity.type( + targetPath, + followLinks: false, + ); + if (targetType == FileSystemEntityType.file) { + final resolvedRelativePath = + DesktopThreadArtifactService.relativePathInternal( + root.path, + targetPath, + ); + return resolvedRelativePath == null || resolvedRelativePath.isEmpty + ? const [] + : [resolvedRelativePath]; + } + if (targetType != FileSystemEntityType.directory) { + return const []; + } + final files = await DesktopThreadArtifactService().collectFilesInternal( + Directory(targetPath), + ); + final paths = []; + for (final file in files) { + final resolvedRelativePath = + DesktopThreadArtifactService.relativePathInternal(root.path, file.path); + if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) { + paths.add(resolvedRelativePath); + } + } + paths.sort(); + return paths; +} + String _normalizeAuthorizationHeaderInternal(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 3025ceea..63a67609 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -589,6 +589,88 @@ void main() { }, ); + test('syncs existing workspace directory artifacts recursively', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-recursive-artifact-workspace-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + await Directory( + '${localWorkspace.path}/assets/images/chapters', + ).create(recursive: true); + await File( + '${localWorkspace.path}/assets/images/cover.png', + ).writeAsBytes([1, 2, 3]); + await File( + '${localWorkspace.path}/assets/images/chapters/chapter-1.png', + ).writeAsBytes([4, 5, 6]); + await File( + '${localWorkspace.path}/chapters/codex-chapter-breakdown.md', + ).create(recursive: true); + + controller.upsertTaskThreadInternal( + 'unit-fixture-task-a', + workspaceBinding: WorkspaceBinding( + workspaceId: 'unit-fixture-task-a', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + + final result = GoTaskServiceResult( + success: true, + message: 'generated files', + turnId: 'turn-recursive', + raw: { + 'artifacts': >[ + {'relativePath': 'assets/images/'}, + { + 'relativePath': 'chapters/codex-chapter-breakdown.md', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'unit-fixture-task-a', + result, + ); + + final thread = controller.requireTaskThreadForSessionInternal( + 'unit-fixture-task-a', + ); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.lastTaskArtifactRelativePaths, [ + 'assets/images/chapters/chapter-1.png', + 'assets/images/cover.png', + 'chapters/codex-chapter-breakdown.md', + ]); + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'unit-fixture-task-a', + ); + expect( + snapshot.resultEntries.map((entry) => entry.relativePath), + containsAll([ + 'assets/images/chapters/chapter-1.png', + 'assets/images/cover.png', + 'chapters/codex-chapter-breakdown.md', + ]), + ); + }); + test( 'downloads bridge URL artifacts into the local thread workspace', () async { From d814f79bb36fce8feb79c5a2bfffa47d5e56652d Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 30 May 2026 10:50:26 +0800 Subject: [PATCH 08/13] Use manual bridge config for ACP runtime --- ...pp_controller_desktop_runtime_helpers.dart | 35 ++++++++++ ...ime_controllers_settings_account_test.dart | 65 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 9e101e6b..38d0638e 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1016,6 +1016,16 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Uri? resolveBridgeAcpEndpointInternal() { + final selfHosted = + settingsControllerInternal.snapshot.acpBridgeServerModeConfig.selfHosted; + final selfHostedUrl = selfHosted.serverUrl.trim(); + if (selfHosted.isConfigured && selfHostedUrl.isNotEmpty) { + final uri = Uri.tryParse(selfHostedUrl); + if (uri != null && uri.hasScheme && uri.host.trim().isNotEmpty) { + return uri.replace(query: null, fragment: null); + } + } + final uri = Uri.parse(kManagedBridgeServerUrl); return uri.replace(query: null, fragment: null); } @@ -1029,6 +1039,11 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (bridgeEndpoint == null) { return false; } + final selfHosted = + settingsControllerInternal.snapshot.acpBridgeServerModeConfig.selfHosted; + if (selfHosted.isConfigured) { + return true; + } final accountSyncState = settingsControllerInternal.accountSyncState; if (settingsControllerInternal.accountSignedIn && accountSyncState?.tokenConfigured.bridge == true) { @@ -1072,6 +1087,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { normalizedHost == bridgeHost && (bridgePort <= 0 || endpoint.port == bridgePort); if (matchesBridgeEndpoint) { + final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal(); + if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) { + return manualBridgeToken; + } final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); if (bridgeToken != null && bridgeToken.isNotEmpty) { return bridgeToken; @@ -1097,6 +1116,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return null; } + Future _resolveManualBridgeAuthTokenInternal() async { + final selfHosted = + settingsControllerInternal.snapshot.acpBridgeServerModeConfig.selfHosted; + if (!selfHosted.isConfigured) { + return null; + } + final passwordRef = selfHosted.passwordRef.trim(); + if (passwordRef.isEmpty) { + return null; + } + final token = (await storeInternal.loadSecretValueByRef( + passwordRef, + ))?.trim(); + return token?.isNotEmpty == true ? token : null; + } + Future _resolveManagedBridgeAuthTokenInternal() async { final accountSyncState = settingsControllerInternal.accountSyncState; if (settingsControllerInternal.accountSignedIn && diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 5951127f..e5167247 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -441,6 +441,71 @@ void main() { }, ); + test('manual bridge config becomes the runtime ACP source', () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-runtime-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + try { + await storeRoot.delete(recursive: true); + } on FileSystemException { + // Temp cleanup is best effort here. The controller may still be + // releasing files when teardown starts. + } + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted + .copyWith( + serverUrl: 'https://private-bridge.svc.plus', + username: 'admin', + ), + ), + ), + ); + await store.saveSecretValueByRef( + AcpBridgeServerSelfHostedConfig.defaults().passwordRef, + 'manual-bridge-token', + ); + + final controller = AppController( + environmentOverride: const {}, + store: store, + ); + addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); + + expect( + controller.resolveGatewayAcpEndpointInternal()?.toString(), + 'https://private-bridge.svc.plus', + ); + expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isTrue); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://private-bridge.svc.plus/acp/rpc'), + ), + 'manual-bridge-token', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ), + isNull, + ); + }); + test( 'syncAccountSettings succeeds when bridge url metadata is missing', () async { From ee8bfa48fd9ca4fa30cbafab55d7f4ab860ed0d9 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 30 May 2026 12:04:54 +0800 Subject: [PATCH 09/13] chore: update core integration cases and runtime helpers --- docs/cases/core-integration-manual-cases.md | 40 ++++++++++++++++ ...pp_controller_desktop_runtime_helpers.dart | 46 ++++++++----------- ...troller_thread_workspace_binding_test.dart | 33 +++++++++---- 3 files changed, 83 insertions(+), 36 deletions(-) diff --git a/docs/cases/core-integration-manual-cases.md b/docs/cases/core-integration-manual-cases.md index 2605c83f..51cb4669 100644 --- a/docs/cases/core-integration-manual-cases.md +++ b/docs/cases/core-integration-manual-cases.md @@ -80,6 +80,46 @@ - 测试连接结果 - 是否需要本机服务日志人工对照 +### `MANUAL-ACP-004` 账号同步返回 `bridge_auth_token_unavailable` + +- 前置条件 + - 已登录 `svc.plus` 账号 + - 账户侧能正常拉起 session + - 本地 App 可访问 `https://accounts.svc.plus` +- 典型现象 + - 设置页顶部与账号面板显示 `xworkmate-bridge 连接失败` + - 账号同步状态为 `失败` + - 同步说明显示 `bridge auth token is unavailable` + - 详情信息里可见 `bridge_auth_token_unavailable` +- 排查步骤 + 1. 在 App 中触发账号同步或重新进入设置页 + 2. 直接请求 `GET /api/auth/xworkmate/profile/sync` + 3. 确认是否返回 `409` + 4. 检查本地 secure storage 中是否存在 `xworkmate.account.managed.bridge.auth_token` + 5. 检查 `xworkmate-app` 的 `accountSyncState` 是否被写成 `blocked` + 6. 回到 `accounts.svc.plus` 服务侧确认 shared bridge token 是否已注入 Vault 并可被读取 +- 期望结论 + - 若接口返回 `409 bridge_auth_token_unavailable`,问题不在 App 的 bridge 连接逻辑 + - 根因在 `accounts.svc.plus` 的 shared XWorkmate bridge token 供给链路 + - `BRIDGE_AUTH_TOKEN` 需要由 accounts 服务通过 Vault 读取并下发 +- 关联同步规则 + - 任务 workspace 需要递归同步全部子目录和文件 + - `dist/账户与身份安全演进史-GPT混排最终版.pdf` 这类深层产物应能回传到当前线程 artifact + - 如 skill 或任务已有忽略清单,则中间产物可按清单排除同步,常见对象包括草稿、临时文件、版本号中间稿和可重复生成缓存 + - 忽略清单建议以约定文件形式维护,例如 `.ignore.md` +- 恢复条件 + - `GET /api/auth/xworkmate/profile/sync` 返回 `200` + - 响应包含 `BRIDGE_SERVER_URL` 和 `BRIDGE_AUTH_TOKEN` + - App 重新同步后,`accountSyncState.syncState` 变为 `ready` + - 本地 managed secret `bridge.auth_token` 被写入 +- 建议记录项 + - 登录账号 + - `profile/sync` HTTP 状态码 + - 响应错误码或成功字段 + - 本地 `accountSyncState.syncState` + - `tokenConfigured.bridge` 是否为 `true` + - 是否已在 `accounts.svc.plus` 侧确认 Vault / bootstrap 配置 + ## 3. 本地执行型任务线程 ### `MANUAL-LOCAL-001` `powerpoint-pptx` diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index f7ce4baa..dc7904af 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -777,7 +777,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { var wroteArtifact = false; var failedArtifact = false; var skippedArtifact = false; - final currentTaskArtifactRelativePaths = []; for (final artifact in artifacts) { final relativePath = _sanitizeArtifactRelativePathInternal( artifact.relativePath, @@ -799,10 +798,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { continue; } wroteArtifact = true; - _appendArtifactRelativePathsInternal( - currentTaskArtifactRelativePaths, - existingArtifactPaths, - ); continue; } final target = await _nextArtifactTargetFileInternal(root, relativePath); @@ -817,17 +812,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { continue; } wroteArtifact = true; - final writtenRelativePath = - DesktopThreadArtifactService.relativePathInternal( - root.path, - target.path, - ); - if (writtenRelativePath != null && writtenRelativePath.isNotEmpty) { - _appendArtifactRelativePathsInternal( - currentTaskArtifactRelativePaths, - [writtenRelativePath], - ); - } } final syncStatus = wroteArtifact @@ -835,6 +819,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController { : failedArtifact ? 'download-failed' : 'no-artifacts'; + final currentTaskArtifactRelativePaths = wroteArtifact + ? await _collectWorkspaceArtifactRelativePathsInternal(root) + : const []; upsertTaskThreadInternal( normalizedSessionKey, lastArtifactSyncAtMs: syncedAtMs, @@ -1158,17 +1145,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ) => kGatewayRemoteProfileIndex; } -void _appendArtifactRelativePathsInternal( - List target, - List paths, -) { - for (final path in paths) { - if (!target.contains(path)) { - target.add(path); - } - } -} - Future> _existingWorkspaceArtifactPathsInternal( Directory root, String relativePath, @@ -1209,6 +1185,22 @@ Future> _existingWorkspaceArtifactPathsInternal( return paths; } +Future> _collectWorkspaceArtifactRelativePathsInternal( + Directory root, +) async { + final files = await DesktopThreadArtifactService().collectFilesInternal(root); + final paths = []; + for (final file in files) { + final resolvedRelativePath = + DesktopThreadArtifactService.relativePathInternal(root.path, file.path); + if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) { + paths.add(resolvedRelativePath); + } + } + paths.sort(); + return paths; +} + String _normalizeAuthorizationHeaderInternal(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 63a67609..26112044 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -492,9 +492,10 @@ void main() { final snapshot = await controller.loadAssistantArtifactSnapshot( sessionKey: 'unit-fixture-task-a', ); - expect(snapshot.resultEntries.map((entry) => entry.relativePath), [ - 'notes/hello.v2.txt', - ]); + expect( + snapshot.resultEntries.map((entry) => entry.relativePath), + containsAll(['notes/hello.v2.txt', 'notes/hello.txt']), + ); expect( snapshot.fileEntries.map((entry) => entry.relativePath), containsAll(['notes/hello.v2.txt', 'notes/hello.txt']), @@ -566,7 +567,10 @@ void main() { final currentRelativePaths = snapshot.resultEntries .map((entry) => entry.relativePath) .toList(growable: false); - expect(currentRelativePaths, ['current-task-report.md']); + expect( + currentRelativePaths, + containsAll(['current-task-report.md', 'old-task-report.md']), + ); expect( snapshot.fileEntries.map((entry) => entry.relativePath), containsAll(['current-task-report.md', 'old-task-report.md']), @@ -615,6 +619,10 @@ void main() { await File( '${localWorkspace.path}/chapters/codex-chapter-breakdown.md', ).create(recursive: true); + await Directory('${localWorkspace.path}/dist').create(recursive: true); + await File( + '${localWorkspace.path}/dist/账户与身份安全演进史-GPT混排最终版.pdf', + ).writeAsBytes([7, 8, 9]); controller.upsertTaskThreadInternal( 'unit-fixture-task-a', @@ -657,6 +665,7 @@ void main() { 'assets/images/chapters/chapter-1.png', 'assets/images/cover.png', 'chapters/codex-chapter-breakdown.md', + 'dist/账户与身份安全演进史-GPT混排最终版.pdf', ]); final snapshot = await controller.loadAssistantArtifactSnapshot( sessionKey: 'unit-fixture-task-a', @@ -667,6 +676,7 @@ void main() { 'assets/images/chapters/chapter-1.png', 'assets/images/cover.png', 'chapters/codex-chapter-breakdown.md', + 'dist/账户与身份安全演进史-GPT混排最终版.pdf', ]), ); }); @@ -989,12 +999,17 @@ void main() { await File('${localWorkspace.path}/reports/resume.bin').readAsBytes(), body, ); - expect( - controller - .requireTaskThreadForSessionInternal('unit-fixture-task-a') - .lastArtifactSyncStatus, - 'synced', + final thread = controller.requireTaskThreadForSessionInternal( + 'unit-fixture-task-a', ); + for ( + var attempt = 0; + attempt < 20 && thread.lastArtifactSyncStatus != 'synced'; + attempt += 1 + ) { + await Future.delayed(const Duration(milliseconds: 10)); + } + expect(thread.lastArtifactSyncStatus, 'synced'); }, ); From dc6a4103e7e59a2f001dbc679c9b96c223992f89 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 31 May 2026 12:27:47 +0800 Subject: [PATCH 10/13] fix: stabilize complex openclaw artifact tasks --- ...app_controller_desktop_thread_actions.dart | 40 +++++++++++++++++++ pubspec.yaml | 6 +-- .../assistant_execution_target_test.dart | 25 ++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index a89048c7..aa5b610c 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -461,9 +461,13 @@ extension AppControllerDesktopThreadActions on AppController { localAttachments, ); final taskLoadClass = classifyGatewayTaskLoadInternal(message); + final expectedArtifactExtensions = + expectedGatewayArtifactExtensionsInternal(message); final taskMetadata = Map.unmodifiable({ ...dispatch.metadata, 'taskLoadClass': taskLoadClass, + if (expectedArtifactExtensions.isNotEmpty) + 'expectedArtifactExtensions': expectedArtifactExtensions, }); final executionWorkingDirectory = gatewayExecutionWorkingDirectoryInternal( target: currentTarget, @@ -764,6 +768,42 @@ extension AppControllerDesktopThreadActions on AppController { return 'short_task'; } + List expectedGatewayArtifactExtensionsInternal(String requestText) { + final normalized = requestText.trim().toLowerCase(); + final result = []; + + void add(String value) { + final normalizedValue = value.trim().toLowerCase().replaceFirst( + RegExp(r'^\.'), + '', + ); + if (normalizedValue.isEmpty || result.contains(normalizedValue)) { + return; + } + result.add(normalizedValue); + } + + for (final match in RegExp( + r'\.([a-z0-9]{2,5})\b', + caseSensitive: false, + ).allMatches(normalized)) { + add(match.group(1) ?? ''); + } + for (final match in RegExp( + r'\b([a-z0-9]{2,5})\s*(?:格式|文件|产物|artifact|file|output)', + caseSensitive: false, + ).allMatches(normalized)) { + add(match.group(1) ?? ''); + } + for (final match in RegExp( + r'(?:输出|导出|生成|制作)\s*([a-z0-9]{2,5})', + caseSensitive: false, + ).allMatches(normalized)) { + add(match.group(1) ?? ''); + } + return List.unmodifiable(result); + } + bool usesOpenClawGatewayQueueInternal( AssistantExecutionTarget target, SingleAgentProvider provider, diff --git a/pubspec.yaml b/pubspec.yaml index 134073bb..440a1cb7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,9 +2,9 @@ name: xworkmate description: "XWorkmate desktop-first AI workspace shell." publish_to: 'none' -version: 1.1.3+1 -build-date: 2026-05-28 -build-id: 82d46f5 +version: 1.1.4 +build-date: 2026-05-30 +build-id: 94405c9 environment: sdk: ^3.11.0 diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 452cd17a..6668fc6d 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1186,6 +1186,31 @@ void main() { }, ); + test( + 'sendChatMessage declares expected artifacts for complex PDF chains', + () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + + await controller.ensureActiveAssistantThreadInternal(); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + await controller.sendChatMessage( + '围绕\n\n' + '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 800-1500字\n' + '拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 ->\n\n' + '最后 输出 PDF文件', + ); + + expect(fakeGoTaskService.requests, hasLength(1)); + final request = fakeGoTaskService.requests.single; + expect(request.metadata['taskLoadClass'], 'complex_long_chain_task'); + expect(request.metadata['expectedArtifactExtensions'], ['pdf']); + }, + ); + test( 'sendChatMessage classifies simple Gateway prompts as short tasks', () async { From 22a0376b009ac2e66da2d0117eca3881ea01a426 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 1 Jun 2026 10:02:13 +0800 Subject: [PATCH 11/13] fix: repair bridge login sync runtime state --- docs/testing/api-script-runbook.md | 12 ++- ...pp_controller_desktop_runtime_helpers.dart | 26 +++-- lib/features/settings/settings_page_core.dart | 95 +++++++++++++++++-- .../runtime_controllers_settings_account.dart | 4 + ...ime_controllers_settings_account_impl.dart | 18 ++++ scripts/ci/verify_api_interface_contract.sh | 68 +++++++++---- scripts/ci/verify_api_scenario_contract.sh | 2 - .../settings_about_bridge_metadata_test.dart | 5 +- .../settings/settings_account_panel_test.dart | 59 ++++++++++++ ...ime_controllers_settings_account_test.dart | 73 ++++++++++++++ 10 files changed, 322 insertions(+), 40 deletions(-) diff --git a/docs/testing/api-script-runbook.md b/docs/testing/api-script-runbook.md index 7fdae35f..16b85287 100644 --- a/docs/testing/api-script-runbook.md +++ b/docs/testing/api-script-runbook.md @@ -29,8 +29,9 @@ Last Updated: 2026-04-22 两份脚本都依赖以下环境变量: - `REVIEW_ACCOUNT_LOGIN_PASSWORD` -- `BRIDGE_AUTH_TOKEN` +- 可选 `BRIDGE_AUTH_TOKEN`,未提供时使用 profile sync 返回的 token - 可选 `BRIDGE_SERVER_URL` +- 可选 `BRIDGE_SERVER_URLS`,用于接口脚本同时验证多个 bridge host - 可选 `REVIEW_ACCOUNT_BASE_URL` 推荐直接在命令前临时注入: @@ -42,6 +43,14 @@ BRIDGE_SERVER_URL='https://xworkmate-bridge.svc.plus' \ bash scripts/ci/verify_api_interface_contract.sh ``` +双入口验证示例: + +```bash +REVIEW_ACCOUNT_LOGIN_PASSWORD='***REMOVED-CREDENTIAL***' \ +BRIDGE_SERVER_URLS='https://xworkmate-bridge.svc.plus,https://cn-xworkmate-bridge.svc.plus' \ +bash scripts/ci/verify_api_interface_contract.sh +``` + ## 3. 默认校验入口 推荐使用 `Makefile` 目标: @@ -64,6 +73,7 @@ make check-api-external - `POST /api/auth/login` - `GET /api/auth/session` - `GET /api/auth/xworkmate/profile/sync` +- `GET /api/ping` - `POST /acp/rpc` with `acp.capabilities` - `POST /acp/rpc` with `xworkmate.routing.resolve` diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 5d87e075..46364a6f 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1016,8 +1016,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Uri? resolveBridgeAcpEndpointInternal() { - final selfHosted = - settingsControllerInternal.snapshot.acpBridgeServerModeConfig.selfHosted; + final selfHosted = settingsControllerInternal + .snapshot + .acpBridgeServerModeConfig + .selfHosted; final selfHostedUrl = selfHosted.serverUrl.trim(); if (selfHosted.isConfigured && selfHostedUrl.isNotEmpty) { final uri = Uri.tryParse(selfHostedUrl); @@ -1039,16 +1041,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (bridgeEndpoint == null) { return false; } - final selfHosted = - settingsControllerInternal.snapshot.acpBridgeServerModeConfig.selfHosted; + final selfHosted = settingsControllerInternal + .snapshot + .acpBridgeServerModeConfig + .selfHosted; if (selfHosted.isConfigured) { return true; } final accountSyncState = settingsControllerInternal.accountSyncState; if (settingsControllerInternal.accountSignedIn && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && accountSyncState?.tokenConfigured.bridge == true) { return true; } + if (settingsControllerInternal.accountSignedIn) { + return false; + } final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); return envToken != null && envToken.isNotEmpty; } @@ -1117,8 +1125,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Future _resolveManualBridgeAuthTokenInternal() async { - final selfHosted = - settingsControllerInternal.snapshot.acpBridgeServerModeConfig.selfHosted; + final selfHosted = settingsControllerInternal + .snapshot + .acpBridgeServerModeConfig + .selfHosted; if (!selfHosted.isConfigured) { return null; } @@ -1135,12 +1145,16 @@ extension AppControllerDesktopRuntimeHelpers on AppController { Future _resolveManagedBridgeAuthTokenInternal() async { final accountSyncState = settingsControllerInternal.accountSyncState; if (settingsControllerInternal.accountSignedIn && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && accountSyncState?.tokenConfigured.bridge == true) { final bridgeToken = (await storeInternal.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ))?.trim(); return bridgeToken?.isNotEmpty == true ? bridgeToken : null; } + if (settingsControllerInternal.accountSignedIn) { + return null; + } final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); return envToken?.isNotEmpty == true ? envToken : null; diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 62ba5ebc..4282e586 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -58,6 +58,17 @@ Future> loadBridgeMetadataForSettingsAbout({ .decodeStream(response) .timeout(const Duration(seconds: 4)); if (response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode == HttpStatus.unauthorized || + response.statusCode == HttpStatus.forbidden) { + return const { + 'status': 'unauthorized', + 'message': 'Bridge authorization rejected', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; + } return const { 'status': 'unavailable', 'version': '', @@ -241,6 +252,11 @@ class _SettingsPageState extends State { _lastSavedAccountIdentifier = nextSettings.accountUsername; _lastSavedBridgeUrl = nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl; + if (isManualBridge && + nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) { + unawaited(_refreshBridgeCapabilities()); + await _refreshAboutSnapshot(); + } } Future _loginAccount(SettingsSnapshot settings) async { @@ -254,6 +270,7 @@ class _SettingsPageState extends State { password: _accountPasswordController.text, ); await _refreshBridgeCapabilities(); + await _verifyAccountBridgeRuntimeAccess(); } finally { _accountPasswordController.clear(); } @@ -261,11 +278,14 @@ class _SettingsPageState extends State { Future _syncAccount(SettingsSnapshot settings) async { await _persistAccountProfileSettings(settings, isManualBridge: false); - await widget.controller.settingsController.syncAccountSettings( - baseUrl: _accountBaseUrlController.text.trim(), - ); + final result = await widget.controller.settingsController + .syncAccountSettings(baseUrl: _accountBaseUrlController.text.trim()); await _refreshBridgeCapabilities(); - await _refreshAboutSnapshot(); + if (result.state == 'ready') { + await _verifyAccountBridgeRuntimeAccess(); + } else { + await _refreshAboutSnapshot(); + } } Future _verifyAccountMfa(SettingsSnapshot settings) async { @@ -276,6 +296,7 @@ class _SettingsPageState extends State { code: _accountMfaCodeController.text.trim(), ); await _refreshBridgeCapabilities(); + await _verifyAccountBridgeRuntimeAccess(); } finally { _accountMfaCodeController.clear(); } @@ -337,13 +358,16 @@ class _SettingsPageState extends State { } Future _loadAboutSnapshot() async { - final bridgeMetadata = await _loadBridgeMetadata(); + final bridgeEndpoint = + widget.controller.resolveGatewayAcpEndpointInternal() ?? + Uri.parse(kManagedBridgeServerUrl); + final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint); return SettingsAboutSnapshot( appVersion: kAppVersion, appBuildNumber: kAppBuildNumber, appBuildDate: kAppBuildDate, appCommit: kAppBuildCommit, - bridgeEndpoint: kManagedBridgeServerUrl, + bridgeEndpoint: bridgeEndpoint.toString(), bridgeStatus: _stringValue(bridgeMetadata['status']), bridgeVersion: _resolveBridgeVersion(bridgeMetadata), bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata), @@ -352,14 +376,69 @@ class _SettingsPageState extends State { ); } - Future> _loadBridgeMetadata() async { + Future> _loadBridgeMetadata(Uri bridgeEndpoint) async { return loadBridgeMetadataForSettingsAbout( - bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl), + bridgeEndpoint: bridgeEndpoint, authorizationResolver: widget.controller.resolveGatewayAcpAuthorizationHeaderInternal, ); } + Future _verifyAccountBridgeRuntimeAccess() async { + if (!widget.controller.settingsController.accountSignedIn) { + await _refreshAboutSnapshot(); + return; + } + final bridgeEndpoint = + widget.controller.resolveGatewayAcpEndpointInternal() ?? + Uri.parse(kManagedBridgeServerUrl); + final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint); + final status = _stringValue(bridgeMetadata['status']).toLowerCase(); + if (status == 'ok') { + if (mounted) { + setState(() { + _aboutSnapshot = _aboutSnapshotFromMetadata( + bridgeEndpoint, + bridgeMetadata, + ); + _aboutBusy = false; + }); + } + return; + } + if (status == 'unauthorized') { + await widget.controller.settingsController + .markAccountBridgeRuntimeUnavailable('Bridge authorization rejected'); + } + if (mounted) { + setState(() { + _aboutSnapshot = _aboutSnapshotFromMetadata( + bridgeEndpoint, + bridgeMetadata, + ); + _aboutBusy = false; + }); + } + } + + SettingsAboutSnapshot _aboutSnapshotFromMetadata( + Uri bridgeEndpoint, + Map bridgeMetadata, + ) { + return SettingsAboutSnapshot( + appVersion: kAppVersion, + appBuildNumber: kAppBuildNumber, + appBuildDate: kAppBuildDate, + appCommit: kAppBuildCommit, + bridgeEndpoint: bridgeEndpoint.toString(), + bridgeStatus: _stringValue(bridgeMetadata['status']), + bridgeVersion: _resolveBridgeVersion(bridgeMetadata), + bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata), + bridgeCommit: _stringValue(bridgeMetadata['commit']), + bridgeImage: _stringValue(bridgeMetadata['image']), + ); + } + @override Widget build(BuildContext context) { final controller = widget.controller; diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index 3a990e59..9cb76f28 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -107,6 +107,10 @@ extension SettingsControllerAccountExtension on SettingsController { Future syncAccountManagedSecrets({String baseUrl = ''}) => syncAccountSettings(baseUrl: baseUrl); + Future markAccountBridgeRuntimeUnavailable( + String message, + ) => markAccountBridgeRuntimeUnavailableInternal(this, message: message); + Future logoutAccount() => logoutAccountSettingsInternal(this); Future cancelAccountMfaChallenge() => diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 5c8300de..ed6b1d0a 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -423,6 +423,24 @@ Future logoutAccountSettingsInternal( } } +Future markAccountBridgeRuntimeUnavailableInternal( + SettingsController controller, { + required String message, +}) async { + final current = controller.accountSyncStateInternal; + final nextState = (current ?? AccountSyncState.defaults()).copyWith( + syncState: 'blocked', + syncMessage: message, + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncError: message, + profileScope: 'bridge', + ); + await _persistAccountSyncStateInternal(controller, nextState); + controller.accountStatusInternal = message; + controller.notifyListeners(); + return AccountSyncResult(state: 'blocked', message: message); +} + Future cancelAccountMfaChallengeSettingsInternal( SettingsController controller, ) async { diff --git a/scripts/ci/verify_api_interface_contract.sh b/scripts/ci/verify_api_interface_contract.sh index e2fb692e..1600f01b 100755 --- a/scripts/ci/verify_api_interface_contract.sh +++ b/scripts/ci/verify_api_interface_contract.sh @@ -5,6 +5,7 @@ ACCOUNTS_BASE_URL="${REVIEW_ACCOUNT_BASE_URL:-https://accounts.svc.plus}" REVIEW_ACCOUNT_LOGIN_NAME="${REVIEW_ACCOUNT_LOGIN_NAME:-review@svc.plus}" REVIEW_ACCOUNT_LOGIN_PASSWORD="${REVIEW_ACCOUNT_LOGIN_PASSWORD:-}" BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-}" +BRIDGE_SERVER_URLS="${BRIDGE_SERVER_URLS:-}" BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}" HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}" @@ -181,10 +182,8 @@ if not bridge_token: raise SystemExit("sync response did not include BRIDGE_AUTH_TOKEN") PY -bridge_server_url="${BRIDGE_SERVER_URL}" -if [[ -z "${bridge_server_url}" ]]; then - bridge_server_url="$( - RESPONSE_JSON="${sync_json}" python3 - <<'PY' +synced_bridge_server_url="$( + RESPONSE_JSON="${sync_json}" python3 - <<'PY' import json import os @@ -194,9 +193,18 @@ if not bridge_url: raise SystemExit("sync response did not include BRIDGE_SERVER_URL") print(bridge_url.rstrip("/")) PY - )" +)" + +bridge_server_urls=() +if [[ -n "${BRIDGE_SERVER_URLS}" ]]; then + while IFS= read -r candidate; do + [[ -n "${candidate}" ]] && bridge_server_urls+=("$(normalize_url "${candidate}")") + done < <(printf '%s\n' "${BRIDGE_SERVER_URLS}" | tr ',' '\n' | tr '[:space:]' '\n' | sed '/^$/d') +elif [[ -n "${BRIDGE_SERVER_URL}" ]]; then + bridge_server_urls+=("$(normalize_url "${BRIDGE_SERVER_URL}")") +else + bridge_server_urls+=("$(normalize_url "${synced_bridge_server_url}")") fi -bridge_server_url="$(normalize_url "${bridge_server_url}")" bridge_auth_token="${BRIDGE_AUTH_TOKEN}" if [[ -z "${bridge_auth_token}" ]]; then @@ -214,13 +222,29 @@ PY )" fi -capabilities_json="$( - json_post \ - "${bridge_server_url}/acp/rpc" \ - '{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \ - -H "Authorization: Bearer ${bridge_auth_token}" -)" -RESPONSE_JSON="${capabilities_json}" python3 - <<'PY' +verified_urls=() +for bridge_server_url in "${bridge_server_urls[@]}"; do + ping_json="$( + json_get \ + "${bridge_server_url}/api/ping" \ + -H "Authorization: Bearer ${bridge_auth_token}" + )" + RESPONSE_JSON="${ping_json}" python3 - <<'PY' +import json +import os + +payload = json.loads(os.environ["RESPONSE_JSON"]) +if payload.get("status") != "ok": + raise SystemExit("bridge ping status is not ok") +PY + + capabilities_json="$( + json_post \ + "${bridge_server_url}/acp/rpc" \ + '{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \ + -H "Authorization: Bearer ${bridge_auth_token}" + )" + RESPONSE_JSON="${capabilities_json}" python3 - <<'PY' import json import os @@ -232,10 +256,10 @@ if result.get("availableExecutionTargets") != ["agent", "gateway"]: raise SystemExit("unexpected availableExecutionTargets") PY -routing_json="$( - json_post \ - "${bridge_server_url}/acp/rpc" \ - '{ + routing_json="$( + json_post \ + "${bridge_server_url}/acp/rpc" \ + '{ "jsonrpc":"2.0", "id":"routing", "method":"xworkmate.routing.resolve", @@ -254,9 +278,9 @@ routing_json="$( } } }' \ - -H "Authorization: Bearer ${bridge_auth_token}" -)" -RESPONSE_JSON="${routing_json}" python3 - <<'PY' + -H "Authorization: Bearer ${bridge_auth_token}" + )" + RESPONSE_JSON="${routing_json}" python3 - <<'PY' import json import os @@ -267,5 +291,7 @@ if not isinstance(result, dict): if result.get("resolvedProviderId") != "codex": raise SystemExit("unexpected resolvedProviderId") PY + verified_urls+=("${bridge_server_url}") +done -printf 'API interface contract verified via %s\n' "${bridge_server_url}" +printf 'API interface contract verified via %s\n' "${verified_urls[*]}" diff --git a/scripts/ci/verify_api_scenario_contract.sh b/scripts/ci/verify_api_scenario_contract.sh index b305e182..e3c0acff 100755 --- a/scripts/ci/verify_api_scenario_contract.sh +++ b/scripts/ci/verify_api_scenario_contract.sh @@ -208,8 +208,6 @@ payload = json.loads(os.environ["RESPONSE_JSON"]) result = payload.get("result") or payload.get("payload") or {} if result.get("resolvedProviderId") != "codex": raise SystemExit("session.start did not resolve codex") -if not str(result.get("error") or "").strip(): - raise SystemExit("session.start in this environment should expose downstream error details") PY RESPONSE_JSON="${message_json}" python3 - <<'PY' diff --git a/test/features/settings/settings_about_bridge_metadata_test.dart b/test/features/settings/settings_about_bridge_metadata_test.dart index df9a9852..fc6ca716 100644 --- a/test/features/settings/settings_about_bridge_metadata_test.dart +++ b/test/features/settings/settings_about_bridge_metadata_test.dart @@ -108,7 +108,7 @@ void main() { expect(metadata['buildDate'], ''); }); - test('returns unavailable when authorized bridge ping fails', () async { + test('returns unauthorized when bridge rejects authorization', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() async { await server.close(force: true); @@ -126,7 +126,8 @@ void main() { authorizationResolver: (_) async => 'bridge-token', ); - expect(metadata['status'], 'unavailable'); + expect(metadata['status'], 'unauthorized'); + expect(metadata['message'], 'Bridge authorization rejected'); expect(metadata['version'], ''); expect(metadata['commit'], ''); expect(metadata['image'], ''); diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index a67d5b8e..251f1a94 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -114,6 +114,65 @@ void main() { expect(submittedPassword, 'typed-password'); }); + testWidgets('manual bridge save submits current field values', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + + var savedAsManualBridge = false; + var savedBridgeUrl = ''; + var savedBridgeToken = ''; + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: SettingsSnapshot.defaults(), + accountSession: null, + accountState: null, + accountBusy: false, + accountSignedIn: false, + accountMfaRequired: false, + accountBaseUrlController: controllers.baseUrl, + accountIdentifierController: controllers.identifier, + accountPasswordController: controllers.password, + accountMfaCodeController: controllers.mfaCode, + bridgeUrlController: controllers.bridgeUrl, + bridgeTokenController: controllers.bridgeToken, + onSaveAccountProfile: ({required bool isManualBridge}) async { + savedAsManualBridge = isManualBridge; + savedBridgeUrl = controllers.bridgeUrl.text; + savedBridgeToken = controllers.bridgeToken.text; + }, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onLogout: () async {}, + ), + ), + ); + + await tester.tap(find.text('手动 Bridge 配置')); + await tester.pump(); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-url-field')), + 'https://cn-xworkmate-bridge.svc.plus', + ); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-token-field')), + 'typed-manual-token', + ); + await tester.tap( + find.byKey(const ValueKey('settings-manual-bridge-save-button')), + ); + await tester.pump(); + + expect(savedAsManualBridge, isTrue); + expect(savedBridgeUrl, 'https://cn-xworkmate-bridge.svc.plus'); + expect(savedBridgeToken, 'typed-manual-token'); + }); + testWidgets( 'shows account sync status, resync, and exit in signed-in mode', (tester) async { diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index e5167247..435b758e 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -441,6 +441,79 @@ void main() { }, ); + test( + 'blocked managed bridge sync does not configure runtime authorization', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-managed-bridge-blocked-runtime-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveAccountSessionToken('session-token'); + await store.saveAccountSessionSummary( + const AccountSessionSummary( + userId: 'user-1', + email: 'review@svc.plus', + name: 'Review User', + role: 'reviewer', + mfaEnabled: true, + ), + ); + await store.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + ), + ), + ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); + + final controller = AppController( + environmentOverride: const { + 'BRIDGE_AUTH_TOKEN': 'env-token-must-not-recover-blocked-sync', + }, + store: store, + ); + addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); + + await controller.settingsControllerInternal + .markAccountBridgeRuntimeUnavailable( + 'Bridge authorization rejected', + ); + + expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ), + isNull, + ); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + 'bridge-token', + ); + }, + ); + test('manual bridge config becomes the runtime ACP source', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-manual-bridge-runtime-', From c2128fe5da789fdd717936ff4c95610fa19fb07b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 1 Jun 2026 13:11:30 +0800 Subject: [PATCH 12/13] Fix manual bridge save runtime config --- ...pp_controller_desktop_runtime_helpers.dart | 4 + lib/features/mobile/mobile_settings_page.dart | 59 +++-- .../settings/settings_account_panel.dart | 3 - lib/features/settings/settings_page_core.dart | 38 ++-- .../runtime_controllers_settings_account.dart | 17 ++ ...ime_controllers_settings_account_impl.dart | 41 ++++ .../mobile/mobile_settings_page_test.dart | 134 ++++++++++++ .../settings/settings_account_panel_test.dart | 201 ++++++++++++++++++ test/runtime/bridge_runtime_cleanup_test.dart | 75 +++++++ 9 files changed, 512 insertions(+), 60 deletions(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 46364a6f..a912061d 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1117,6 +1117,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return null; } + final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal(); + if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) { + return _normalizeAuthorizationHeaderInternal(manualBridgeToken); + } final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); if (bridgeToken != null && bridgeToken.isNotEmpty) { return _normalizeAuthorizationHeaderInternal(bridgeToken); diff --git a/lib/features/mobile/mobile_settings_page.dart b/lib/features/mobile/mobile_settings_page.dart index f898ba60..ce87270b 100644 --- a/lib/features/mobile/mobile_settings_page.dart +++ b/lib/features/mobile/mobile_settings_page.dart @@ -104,38 +104,27 @@ class _MobileSettingsPageState extends State { required bool isManualBridge, bool refreshAfterSave = true, }) async { - final bridgeConfig = settings.acpBridgeServerModeConfig; - final nextBridgeConfig = bridgeConfig.copyWith( - selfHosted: bridgeConfig.selfHosted.copyWith( - serverUrl: bridgeUrlController.text.trim(), - username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username, - ), - ); - final nextEffective = widget.controller.settingsController - .resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig); - final nextSettings = settings.copyWith( - accountBaseUrl: accountBaseUrlController.text.trim(), - accountUsername: accountIdentifierController.text.trim(), - acpBridgeServerModeConfig: nextBridgeConfig.copyWith( - effective: nextEffective, - ), - ); - if (isManualBridge && bridgeTokenController.text.isNotEmpty) { - await widget.controller.settingsController.saveSecretValueByRef( - nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, - bridgeTokenController.text, - provider: 'Bridge', - module: 'Manual', - ); - } + final nextSettings = await widget.controller.settingsController + .buildSavedAccountProfileSettings( + settings: settings, + accountBaseUrl: accountBaseUrlController.text, + accountIdentifier: accountIdentifierController.text, + bridgeServerUrl: bridgeUrlController.text, + bridgeToken: bridgeTokenController.text, + isManualBridge: isManualBridge, + ); await widget.controller.saveSettings( nextSettings, - refreshAfterSave: refreshAfterSave, + refreshAfterSave: isManualBridge ? false : refreshAfterSave, ); lastSavedAccountBaseUrl = nextSettings.accountBaseUrl; lastSavedAccountIdentifier = nextSettings.accountUsername; lastSavedBridgeUrl = nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl; + if (isManualBridge && + nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) { + unawaited(refreshBridgeCapabilities()); + } } Future loginAccount(SettingsSnapshot settings) async { @@ -245,16 +234,25 @@ class _MobileSettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( - onTap: () => controller.navigateTo(WorkspaceDestination.assistant), + onTap: () => controller.navigateTo( + WorkspaceDestination.assistant, + ), behavior: HitTestBehavior.opaque, child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: palette.textSecondary), + Icon( + Icons.arrow_back_ios_new_rounded, + size: 16, + color: palette.textSecondary, + ), const SizedBox(width: 6), Text( appText('返回对话主页', 'Back to Chat'), - style: TextStyle(color: palette.textSecondary, fontSize: 16), + style: TextStyle( + color: palette.textSecondary, + fontSize: 16, + ), ), ], ), @@ -262,9 +260,8 @@ class _MobileSettingsPageState extends State { const SizedBox(height: 24), Text( appText('设置', 'Settings'), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), if (availableTabs.length > 1) ...[ diff --git a/lib/features/settings/settings_account_panel.dart b/lib/features/settings/settings_account_panel.dart index 9b78ab95..d413ca7a 100644 --- a/lib/features/settings/settings_account_panel.dart +++ b/lib/features/settings/settings_account_panel.dart @@ -101,9 +101,6 @@ class _SettingsAccountPanelState extends State Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')), Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')), ], - onTap: (index) { - widget.onSaveAccountProfile(isManualBridge: index == 1); - }, ), const SizedBox(height: 24), SizedBox( diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 4282e586..64a641bf 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -220,34 +220,20 @@ class _SettingsPageState extends State { SettingsSnapshot settings, { required bool isManualBridge, }) async { - final bridgeConfig = settings.acpBridgeServerModeConfig; - var nextBridgeConfig = bridgeConfig.copyWith( - selfHosted: bridgeConfig.selfHosted.copyWith( - serverUrl: _bridgeUrlController.text.trim(), - username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username, - ), + final nextSettings = await widget.controller.settingsController + .buildSavedAccountProfileSettings( + settings: settings, + accountBaseUrl: _accountBaseUrlController.text, + accountIdentifier: _accountIdentifierController.text, + bridgeServerUrl: _bridgeUrlController.text, + bridgeToken: _bridgeTokenController.text, + isManualBridge: isManualBridge, + ); + await widget.controller.saveSettings( + nextSettings, + refreshAfterSave: !isManualBridge, ); - final nextEffective = widget.controller.settingsController - .resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig); - - final nextSettings = settings.copyWith( - accountBaseUrl: _accountBaseUrlController.text.trim(), - accountUsername: _accountIdentifierController.text.trim(), - acpBridgeServerModeConfig: nextBridgeConfig.copyWith( - effective: nextEffective, - ), - ); - if (isManualBridge && _bridgeTokenController.text.isNotEmpty) { - await widget.controller.settingsController.saveSecretValueByRef( - nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, - _bridgeTokenController.text, - provider: 'Bridge', - module: 'Manual', - ); - } - await widget.controller.saveSettings(nextSettings); - _lastSavedAccountBaseUrl = nextSettings.accountBaseUrl; _lastSavedAccountIdentifier = nextSettings.accountUsername; _lastSavedBridgeUrl = diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index 9cb76f28..8aaee25b 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -120,6 +120,23 @@ extension SettingsControllerAccountExtension on SettingsController { required AcpBridgeServerModeConfig config, }) => resolveAcpBridgeServerEffectiveConfigInternal(this, config: config); + Future buildSavedAccountProfileSettings({ + required SettingsSnapshot settings, + required String accountBaseUrl, + required String accountIdentifier, + required String bridgeServerUrl, + required String bridgeToken, + required bool isManualBridge, + }) => buildSavedAccountProfileSettingsInternal( + this, + settings: settings, + accountBaseUrl: accountBaseUrl, + accountIdentifier: accountIdentifier, + bridgeServerUrl: bridgeServerUrl, + bridgeToken: bridgeToken, + isManualBridge: isManualBridge, + ); + List buildSecretReferences() { final entries = [ ...secureRefsInternal.entries.map( diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index ed6b1d0a..04314cc3 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -612,6 +612,47 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( ); } +Future buildSavedAccountProfileSettingsInternal( + SettingsController controller, { + required SettingsSnapshot settings, + required String accountBaseUrl, + required String accountIdentifier, + required String bridgeServerUrl, + required String bridgeToken, + required bool isManualBridge, +}) async { + final bridgeConfig = settings.acpBridgeServerModeConfig; + final nextBridgeConfig = bridgeConfig.copyWith( + selfHosted: isManualBridge + ? bridgeConfig.selfHosted.copyWith( + serverUrl: bridgeServerUrl.trim(), + username: 'admin', + ) + : bridgeConfig.selfHosted, + ); + final nextEffective = resolveAcpBridgeServerEffectiveConfigInternal( + controller, + config: nextBridgeConfig, + ); + final nextSettings = settings.copyWith( + accountBaseUrl: accountBaseUrl.trim(), + accountUsername: accountIdentifier.trim(), + acpBridgeServerModeConfig: nextBridgeConfig.copyWith( + effective: nextEffective, + ), + ); + final trimmedBridgeToken = bridgeToken.trim(); + if (isManualBridge && trimmedBridgeToken.isNotEmpty) { + await controller.saveSecretValueByRef( + nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, + trimmedBridgeToken, + provider: 'Bridge', + module: 'Manual', + ); + } + return nextSettings; +} + int _parseExpiresAtMs(Object? value) { if (value is int) { return value; diff --git a/test/features/mobile/mobile_settings_page_test.dart b/test/features/mobile/mobile_settings_page_test.dart index 615880e8..30bdfae1 100644 --- a/test/features/mobile/mobile_settings_page_test.dart +++ b/test/features/mobile/mobile_settings_page_test.dart @@ -5,6 +5,8 @@ import 'package:xworkmate/app/app_shell_desktop.dart'; import 'package:xworkmate/features/mobile/mobile_settings_page.dart'; import 'package:xworkmate/runtime/account_runtime_client.dart'; import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; void main() { @@ -239,6 +241,78 @@ void main() { ); expect(find.text('mobile@svc.plus'), findsOneWidget); }); + + testWidgets('manual bridge save updates mobile runtime configuration', ( + tester, + ) async { + final store = _MemorySecureConfigStore(); + final controller = _NoopRefreshAppController(store: store); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS), + home: MediaQuery( + data: const MediaQueryData(size: Size(390, 844)), + child: Scaffold(body: MobileSettingsPage(controller: controller)), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + + final urlField = find.byKey( + const Key('mobile-settings-manual-bridge-url-field'), + ); + await tester.ensureVisible(urlField); + await tester.enterText( + find.descendant(of: urlField, matching: find.byType(TextFormField)), + 'http://127.0.0.1:1', + ); + final tokenField = find.byKey( + const Key('mobile-settings-manual-bridge-token-field'), + ); + await tester.enterText( + find.descendant(of: tokenField, matching: find.byType(TextFormField)), + 'mobile-manual-token', + ); + final saveButton = find.byKey( + const Key('mobile-settings-manual-bridge-save-button'), + ); + await tester.ensureVisible(saveButton); + tester.widget(saveButton).onPressed!(); + + for ( + var attempt = 0; + attempt < 20 && + controller + .settings + .acpBridgeServerModeConfig + .selfHosted + .serverUrl != + 'http://127.0.0.1:1'; + attempt += 1 + ) { + await tester.pump(const Duration(milliseconds: 50)); + } + + final bridgeConfig = controller.settings.acpBridgeServerModeConfig; + expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1'); + expect(bridgeConfig.effective.source, 'bridge'); + expect( + await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef), + 'mobile-manual-token', + ); + expect( + controller.resolveGatewayAcpEndpointInternal()?.toString(), + 'http://127.0.0.1:1', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('http://127.0.0.1:1/acp/rpc'), + ), + 'mobile-manual-token', + ); + }); }); } @@ -278,3 +352,63 @@ class _MobileFakeAccountRuntimeClient extends AccountRuntimeClient { return syncPayload; } } + +class _NoopRefreshAppController extends AppController { + _NoopRefreshAppController({required SecureConfigStore store}) + : super(environmentOverride: const {}, store: store); + + Future refreshAcpCapabilitiesInternal({ + bool forceRefresh = false, + bool persistMountTargets = false, + }) async {} + + Future refreshSingleAgentCapabilitiesInternal({ + bool forceRefresh = false, + }) async {} +} + +class _MemorySecureConfigStore extends SecureConfigStore { + _MemorySecureConfigStore() : super(enableSecureStorage: false); + + SettingsSnapshot _settings = SettingsSnapshot.defaults(); + final Map _secrets = {}; + + @override + Future initialize() async {} + + @override + Future loadSettingsSnapshot() async => _settings; + + @override + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + _settings = snapshot; + } + + @override + Future> loadSecureRefs() async => _secrets; + + @override + Future> loadAuditTrail() async => + const []; + + @override + Future appendAudit(SecretAuditEntry entry) async {} + + @override + Future loadSecretValueByRef(String refName) async => + _secrets[refName]; + + @override + Future saveSecretValueByRef(String refName, String value) async { + _secrets[refName] = value; + } + + @override + Future loadAccountSessionToken() async => null; + + @override + Future loadAccountSessionSummary() async => null; + + @override + Future loadAccountSyncState() async => null; +} diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index 251f1a94..bfca15a0 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/settings/settings_account_panel.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; import 'package:xworkmate/widgets/surface_card.dart'; @@ -173,6 +176,144 @@ void main() { expect(savedBridgeToken, 'typed-manual-token'); }); + testWidgets('switching to manual bridge tab does not save draft values', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + + var saveCount = 0; + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: SettingsSnapshot.defaults(), + accountSession: null, + accountState: null, + accountBusy: false, + accountSignedIn: false, + accountMfaRequired: false, + accountBaseUrlController: controllers.baseUrl, + accountIdentifierController: controllers.identifier, + accountPasswordController: controllers.password, + accountMfaCodeController: controllers.mfaCode, + bridgeUrlController: controllers.bridgeUrl, + bridgeTokenController: controllers.bridgeToken, + onSaveAccountProfile: ({required bool isManualBridge}) async { + saveCount += 1; + }, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onLogout: () async {}, + ), + ), + ); + + await tester.tap(find.text('手动 Bridge 配置')); + await tester.pump(); + + expect(saveCount, 0); + }); + + testWidgets('desktop manual bridge save updates runtime configuration', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + final store = _MemorySecureConfigStore(); + final controller = _NoopRefreshAppController(store: store); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: controller.settings, + accountSession: null, + accountState: null, + accountBusy: false, + accountSignedIn: false, + accountMfaRequired: false, + accountBaseUrlController: controllers.baseUrl, + accountIdentifierController: controllers.identifier, + accountPasswordController: controllers.password, + accountMfaCodeController: controllers.mfaCode, + bridgeUrlController: controllers.bridgeUrl, + bridgeTokenController: controllers.bridgeToken, + onSaveAccountProfile: ({required bool isManualBridge}) async { + final nextSettings = await controller.settingsController + .buildSavedAccountProfileSettings( + settings: controller.settings, + accountBaseUrl: controllers.baseUrl.text, + accountIdentifier: controllers.identifier.text, + bridgeServerUrl: controllers.bridgeUrl.text, + bridgeToken: controllers.bridgeToken.text, + isManualBridge: isManualBridge, + ); + await controller.saveSettings( + nextSettings, + refreshAfterSave: false, + ); + }, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onLogout: () async {}, + ), + ), + ); + + await tester.tap(find.text('手动 Bridge 配置')); + await tester.pump(); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-url-field')), + 'http://127.0.0.1:1', + ); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-token-field')), + 'typed-manual-token', + ); + await tester.tap( + find.byKey(const ValueKey('settings-manual-bridge-save-button')), + ); + + for ( + var attempt = 0; + attempt < 20 && + controller + .settings + .acpBridgeServerModeConfig + .selfHosted + .serverUrl != + 'http://127.0.0.1:1'; + attempt += 1 + ) { + await tester.pump(const Duration(milliseconds: 50)); + } + + final bridgeConfig = controller.settings.acpBridgeServerModeConfig; + expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1'); + expect(bridgeConfig.selfHosted.username, 'admin'); + expect(bridgeConfig.effective.source, 'bridge'); + expect(bridgeConfig.effective.endpoint, 'http://127.0.0.1:1'); + expect( + await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef), + 'typed-manual-token', + ); + expect( + controller.resolveGatewayAcpEndpointInternal()?.toString(), + 'http://127.0.0.1:1', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('http://127.0.0.1:1/acp/rpc'), + ), + 'typed-manual-token', + ); + }); + testWidgets( 'shows account sync status, resync, and exit in signed-in mode', (tester) async { @@ -562,3 +703,63 @@ class _TestControllers { bridgeToken.dispose(); } } + +class _NoopRefreshAppController extends AppController { + _NoopRefreshAppController({required SecureConfigStore store}) + : super(environmentOverride: const {}, store: store); + + Future refreshAcpCapabilitiesInternal({ + bool forceRefresh = false, + bool persistMountTargets = false, + }) async {} + + Future refreshSingleAgentCapabilitiesInternal({ + bool forceRefresh = false, + }) async {} +} + +class _MemorySecureConfigStore extends SecureConfigStore { + _MemorySecureConfigStore() : super(enableSecureStorage: false); + + SettingsSnapshot _settings = SettingsSnapshot.defaults(); + final Map _secrets = {}; + + @override + Future initialize() async {} + + @override + Future loadSettingsSnapshot() async => _settings; + + @override + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + _settings = snapshot; + } + + @override + Future> loadSecureRefs() async => _secrets; + + @override + Future> loadAuditTrail() async => + const []; + + @override + Future appendAudit(SecretAuditEntry entry) async {} + + @override + Future loadSecretValueByRef(String refName) async => + _secrets[refName]; + + @override + Future saveSecretValueByRef(String refName, String value) async { + _secrets[refName] = value; + } + + @override + Future loadAccountSessionToken() async => null; + + @override + Future loadAccountSessionSummary() async => null; + + @override + Future loadAccountSyncState() async => null; +} diff --git a/test/runtime/bridge_runtime_cleanup_test.dart b/test/runtime/bridge_runtime_cleanup_test.dart index 62799322..4562c9e6 100644 --- a/test/runtime/bridge_runtime_cleanup_test.dart +++ b/test/runtime/bridge_runtime_cleanup_test.dart @@ -168,6 +168,81 @@ void main() { }, ); + test( + 'manual bridge token authorizes runtime and artifact requests only for manual endpoint', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-artifact-auth-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + try { + await storeRoot.delete(recursive: true); + } on FileSystemException { + // Temp cleanup is best effort here. The controller may still be + // releasing files when teardown starts. + } + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted + .copyWith( + serverUrl: 'https://private-bridge.svc.plus', + username: 'admin', + ), + ), + ), + ); + await store.saveSecretValueByRef( + AcpBridgeServerSelfHostedConfig.defaults().passwordRef, + 'manual-bridge-token', + ); + + final controller = AppController( + environmentOverride: const {}, + store: store, + ); + addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); + + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://private-bridge.svc.plus/acp/rpc'), + ), + 'manual-bridge-token', + ); + expect( + await controller.resolveBridgeArtifactAuthorizationHeaderInternal( + Uri.parse('https://private-bridge.svc.plus/artifacts/file.pdf'), + ), + 'Bearer manual-bridge-token', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ), + isNull, + ); + expect( + await controller.resolveBridgeArtifactAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/artifacts/file.pdf'), + ), + isNull, + ); + }, + ); + test( 'runtime coordinator only exposes remote and offline gateway modes', () { From 4848c2d53b57676abe5a5c25e49af1af25731d55 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 1 Jun 2026 13:48:39 +0800 Subject: [PATCH 13/13] fix: use OpenClaw gateway protocol 4 --- lib/runtime/gateway_runtime_protocol.dart | 2 +- .../gateway_runtime_bridge_skills_test.dart | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/runtime/gateway_runtime_protocol.dart b/lib/runtime/gateway_runtime_protocol.dart index b3a66bb1..432b1a8e 100644 --- a/lib/runtime/gateway_runtime_protocol.dart +++ b/lib/runtime/gateway_runtime_protocol.dart @@ -18,7 +18,7 @@ import 'gateway_runtime_errors.dart'; import 'gateway_runtime_helpers.dart'; import 'gateway_runtime_core.dart'; -const kGatewayProtocolVersion = 3; +const kGatewayProtocolVersion = 4; const kDefaultOperatorConnectScopes = [ 'operator.admin', 'operator.read', diff --git a/test/runtime/gateway_runtime_bridge_skills_test.dart b/test/runtime/gateway_runtime_bridge_skills_test.dart index a2272db2..bd80d942 100644 --- a/test/runtime/gateway_runtime_bridge_skills_test.dart +++ b/test/runtime/gateway_runtime_bridge_skills_test.dart @@ -6,6 +6,7 @@ import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_acp_client.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { @@ -129,9 +130,10 @@ void main() { endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'), authorizationResolver: (_) async => 'bridge-token', ); + final identityStore = DeviceIdentityStore(store); final runtime = GatewayRuntime( store: store, - identityStore: DeviceIdentityStore(store), + identityStore: identityStore, sessionClient: GatewayAcpRuntimeSessionClient(client: acpClient), ); await runtime.initialize(); @@ -142,6 +144,18 @@ void main() { await tempDir.delete(recursive: true); }); + final directConnectParams = await runtime.buildConnectParamsInternal( + runtime, + profile: GatewayConnectionProfile.defaults(), + identity: await identityStore.loadOrCreate(), + nonce: 'nonce', + authToken: 'bridge-token', + authDeviceToken: '', + authPassword: '', + ); + expect(directConnectParams['minProtocol'], kGatewayProtocolVersion); + expect(directConnectParams['maxProtocol'], kGatewayProtocolVersion); + final controller = SkillsController(runtime); await controller.refresh(agentId: 'main');