From 4e19ba3ded34cf5a79ba8c1fa5626d6593dcd472 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 8 Apr 2026 20:02:25 +0800 Subject: [PATCH] chore: checkpoint current workspace changes --- .../task-control-plane-unification.md | 15 - docs/architecture/xworkmate-integrations.md | 7 - docs/cases/core-integration-manual-cases.md | 3 - docs/codex-integration/tasks.md | 5 +- .../codex-integration-status-actual.md | 15 +- .../desktop_navigation_flow_test.dart | 63 +-- .../desktop_settings_flow_test.dart | 52 +- integration_test/home_flow_test.dart | 62 +-- integration_test/test_support.dart | 9 +- ...pp_controller_desktop_thread_sessions.dart | 2 +- ...app_controller_desktop_thread_storage.dart | 5 +- lib/runtime/aris_llm_chat_client.dart | 2 +- lib/runtime/embedded_agent_launch_policy.dart | 7 +- lib/runtime/runtime_bootstrap.dart | 10 +- ...ssistant_page_single_agent_flow_suite.dart | 178 ++++--- .../assistant_page_suite_composer.dart | 16 +- .../assistant_page_suite_support.dart | 482 ++++++++++++++++-- ...gs_page_external_acp_end_to_end_suite.dart | 2 +- test/helpers/test_keys.dart | 12 +- .../app_controller_core_flow_test.dart | 2 +- ...cution_target_switch_suite_connection.dart | 8 +- ..._execution_target_switch_suite_thread.dart | 4 +- .../embedded_agent_launch_policy_test.dart | 4 +- test/test_support.dart | 17 +- test/widget_test.dart | 5 +- 25 files changed, 645 insertions(+), 342 deletions(-) diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md index ad6769fc..37b89ccd 100644 --- a/docs/architecture/task-control-plane-unification.md +++ b/docs/architecture/task-control-plane-unification.md @@ -20,23 +20,8 @@ Last Updated: 2026-04-08 - ACP 是统一控制面 - `single-agent / multi-agent / gateway` 是 ACP 解析后的执行器分支 -## 当前事实 - -| 客户端 | 非 App Store Desktop | App Store Desktop | Web | Mobile | -| --- | --- | --- | --- | --- | -| 启动/入口 | Desktop UI 可桥接到 bundled / build artifact 的 `xworkmate-go-core` | 不启动任何本机 `xworkmate-go-core` / `codex app-server` 进程 | 通过 `GoTaskService.executeTask` 连接本地或远程 ACP `xworkmate-go-core` | 通过 `GoTaskService.executeTask` 连接远程 ACP `xworkmate-go-core` | -| 执行语义 | 仍然收敛到 ACP Control Plane / `resolvedExecutionTarget` | 只保留 remote ACP / gateway 路由 | local / remote ACP 都是同一执行面 | remote ACP 是唯一允许的执行面 | - -补充说明: - -- Desktop 非 App Store 构建可以保留本机 go-core 桥接能力。 -- App Store 构建必须把本机 `xworkmate-go-core` / `codex app-server` 启动路径全部关掉。 -- Web 的 `local / remote` 只是 ACP 接入目标的差异,不是另一套执行模型。 -- Mobile 只允许远程 ACP,不能走本机 go-core 进程。 -- `single-agent / multi-agent / gateway` 仍然只是 ACP 解析后的执行器分支,不是 UI 产品线。 ## 目标态 - ```mermaid flowchart TD A["Desktop / Web / Mobile UI"] --> B["sendMessage
统一 Task Envelope"] diff --git a/docs/architecture/xworkmate-integrations.md b/docs/architecture/xworkmate-integrations.md index cea7155e..e18eef37 100644 --- a/docs/architecture/xworkmate-integrations.md +++ b/docs/architecture/xworkmate-integrations.md @@ -7,13 +7,6 @@ XWorkmate 现阶段已经不只是“单一 Codex bridge”,但当前实现也 本文件只说明集成能力与 adapter 边界,不承担任务工作流主叙事。 -### 平台入口矩阵 - -- Desktop 非 App Store:可桥接 bundled / build artifact 的 `xworkmate-go-core` -- Desktop App Store:不启动任何本机 `xworkmate-go-core` / `codex app-server` -- Web:可连接本地或远程 ACP `xworkmate-go-core` -- Mobile:只连接远程 ACP `xworkmate-go-core` - 任务工作流主叙事统一以 [任务执行链路统一收敛](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-task-control-plane-unification/docs/architecture/task-control-plane-unification.md) 为准。 diff --git a/docs/cases/core-integration-manual-cases.md b/docs/cases/core-integration-manual-cases.md index ca992bf0..2ff876bc 100644 --- a/docs/cases/core-integration-manual-cases.md +++ b/docs/cases/core-integration-manual-cases.md @@ -63,7 +63,6 @@ ### `MANUAL-ACP-003` local ACP / local 模式接入 - 前置条件 - - 非 App Store 桌面构建,或 Web 端 local ACP 验证 - 本机已有 local / loopback ACP 服务 - 确认监听地址与端口 - 操作步骤 @@ -75,8 +74,6 @@ - local / loopback 非 TLS 允许通过 - 页面明确显示当前为本地配置 - 不会把 local endpoint 错误识别为 remote insecure endpoint -- 备注 - - App Store 桌面构建不执行此 case,只保留 remote ACP 验证。 - 建议记录项 - 当前模式 - loopback endpoint diff --git a/docs/codex-integration/tasks.md b/docs/codex-integration/tasks.md index 4c9f77dc..346e4adb 100644 --- a/docs/codex-integration/tasks.md +++ b/docs/codex-integration/tasks.md @@ -2,15 +2,13 @@ ## 当前结论 -XWorkmate 当前唯一可交付的 Codex 集成路径是 **external CLI**,但只适用于非 App Store 的桌面构建: +XWorkmate 当前唯一可交付的 Codex 集成路径是 **external CLI**: - 通过 `CodexRuntime.startStdio()` 拉起外部 `codex app-server` - 通过 `CodexConfigBridge` 把 AI Gateway 写入 `~/.codex/config.toml` - 通过 `CodeAgentNodeOrchestrator` 把 XWorkmate 固定为 `app-mediated cooperative node` - 通过 `RuntimeCoordinator` 保留多外部 Code Agent CLI 的统一 registry surface -App Store 桌面构建不应再拉起本机 `codex app-server`,而应只保留远程 ACP / gateway 语义。 - Rust FFI / built-in Codex 仍是 future placeholder,不应宣传为已完成。 ## 能力补全清单(按需求项) @@ -47,7 +45,6 @@ Rust FFI / built-in Codex 仍是 future placeholder,不应宣传为已完成 - `chat.send` 的 app-mediated node metadata - 本地降级:Gateway 不可用时,外部 Codex 仍可运行 - `CodexConfigBridge` 对 `~/.codex/config.toml` 采用非破坏性写入(仅更新 XWorkmate 托管块,保留原有配置) -- 仅在非 App Store 的桌面构建中执行外部 Codex CLI 启动 非目标: diff --git a/docs/reports/codex-integration-status-actual.md b/docs/reports/codex-integration-status-actual.md index 35151470..3fa76cd9 100644 --- a/docs/reports/codex-integration-status-actual.md +++ b/docs/reports/codex-integration-status-actual.md @@ -1,20 +1,20 @@ # XWorkmate Codex 集成实际状态 -更新时间:2026-04-08 +更新时间:2026-03-14 ## 当前结论 -XWorkmate 当前唯一可交付的 Codex 集成路径是 **External Codex CLI**。非 App Store 的桌面构建可以拉起本机 `codex app-server`,App Store 桌面构建则不允许这样做。 +XWorkmate 当前唯一可交付的 Codex 集成路径是 **External Codex CLI**。 当前已落地的真实链路: 1. 用户在 `设置 > 集成 > AI Gateway > 工具` 显式启用 Bridge。 2. XWorkmate 通过 `CodexConfigBridge` 写入 `~/.codex/config.toml`,把 AI Gateway 暴露给 Codex。 -3. XWorkmate 在非 App Store 桌面构建中通过 `CodexRuntime.startStdio()` 拉起外部 `codex app-server --listen stdio://`;App Store 桌面构建不会启动这个本机进程。 +3. XWorkmate 通过 `CodexRuntime.startStdio()` 拉起外部 `codex app-server --listen stdio://`。 4. XWorkmate 通过 `CodeAgentNodeOrchestrator` 生成 app-mediated node dispatch metadata,并在 `chat.send` 时发送给 OpenClaw Gateway。 5. 如果 OpenClaw Gateway 已连接,XWorkmate 会执行一次 `agent/register`,把自己注册为协同 `code-agent-bridge`。 -这意味着当前架构是 **app-mediated RPC bridge**,不是 `Codex CLI` 和 `OpenClaw Gateway` 直接互连;App Store 桌面版本则只保留远程 ACP / gateway 路径。 +这意味着当前架构是 **app-mediated RPC bridge**,不是 `Codex CLI` 和 `OpenClaw Gateway` 直接互连。 ## 已完成 @@ -25,10 +25,10 @@ XWorkmate 当前唯一可交付的 Codex 集成路径是 **External Codex CLI** - `AppController.enableCodexBridge()` 已改为显式执行完整链路: - 校验 AI Gateway 配置 - 导出 Codex bridge 配置 - - 在非 App Store 桌面构建中启动外部 Codex CLI 进程 + - 启动外部 Codex CLI 进程 - Gateway 已连接时执行 `agent/register` - `AppController.sendChatMessage()` 已不再直接裸调 Gateway chat,而是先构造 app-mediated node dispatch envelope -- Gateway 不可用时,Bridge 会在非 App Store 桌面构建中降级为本地运行,外部 Codex 进程不会因为注册失败而被终止 +- Gateway 不可用时,Bridge 会降级为本地运行,外部 Codex 进程不会因为注册失败而被终止 - `AgentRegistry.register()` 已支持真实 `transport` metadata,不再把外部桥接伪装成固定 `in-process` ### 2. 外部 CLI 预留能力 @@ -54,7 +54,6 @@ XWorkmate 当前唯一可交付的 Codex 集成路径是 **External Codex CLI** - Gateway 协同注册状态 - `builtIn` 仍保留在 enum 中,但 UI 只显示为 `Experimental` - 若用户选择 `builtIn`,设置会被保留,并以实验态提示风险 -- Desktop UI 继续沿用本地 bridge / external CLI 路径,但 App Store 桌面构建除外 - Scheduled Tasks 页面明确为 `cron.list` 只读展示 - Memory 只表述为 `memory/sync` 同步能力,不宣传 CRUD - OpenClaw Gateway 看到的是 `XWorkmate App node`,CLI 仍保持在 App 后端 runtime 边界内 @@ -98,7 +97,7 @@ XWorkmate 当前唯一可交付的 Codex 集成路径是 **External Codex CLI** ```mermaid flowchart LR - X["XWorkmate App"] --> C["External Codex CLI\n(Non-App Store Desktop only)"] + X["XWorkmate App"] --> C["External Codex CLI\n(JSON-RPC over stdio)"] C --> A["AI Gateway"] X --> G["OpenClaw Gateway\n(WebSocket RPC)"] X --> R["agent/register\ncode-agent-bridge"] diff --git a/integration_test/desktop_navigation_flow_test.dart b/integration_test/desktop_navigation_flow_test.dart index 085f43a9..d2ff9a4a 100644 --- a/integration_test/desktop_navigation_flow_test.dart +++ b/integration_test/desktop_navigation_flow_test.dart @@ -1,40 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test/helpers/test_keys.dart'; import 'test_support.dart'; -Finder _textEither(String zh, String en) { - return find.byWidgetPredicate( - (widget) => widget is Text && (widget.data == zh || widget.data == en), - ); -} - -Future _ensureSettingsFocused(WidgetTester tester) async { - final activeSettings = find.byKey( - const ValueKey('assistant-focus-active-title-settings'), - ); - if (activeSettings.evaluate().isNotEmpty) { - return; - } - final addSettingsChip = find.byKey( - const ValueKey('assistant-focus-add-settings'), - ); - if (addSettingsChip.evaluate().isNotEmpty) { - await tester.tap(addSettingsChip); - await settleIntegrationUi(tester); - return; - } - final addMenu = find.byKey(const Key('assistant-focus-add-menu')); - expect(addMenu, findsOneWidget); - await tester.tap(addMenu); - await settleIntegrationUi(tester); - final settingsItem = _textEither('设置', 'Settings'); - expect(settingsItem, findsWidgets); - await tester.tap(settingsItem.last); - await settleIntegrationUi(tester); -} - void main() { initializeIntegrationHarness(); @@ -42,30 +10,37 @@ void main() { await resetIntegrationPreferences(); }); - testWidgets('desktop shell opens focused navigation surface', ( + testWidgets('desktop shell can navigate from assistant to settings and back', ( WidgetTester tester, ) async { await pumpDesktopApp(tester); - await waitForIntegrationFinder(tester, find.byKey(TestKeys.assistantTaskRail)); - - expect(_textEither('新对话', 'New conversation'), findsWidgets); - await tester.tap( - find.byKey(const Key('assistant-side-pane-tab-navigation')), + await waitForIntegrationFinder( + tester, + find.byKey(TestKeys.assistantConversationShell), ); + + expect(find.byKey(TestKeys.workspaceSidebarNewTaskButton), findsOneWidget); + expect(find.byKey(TestKeys.assistantExecutionTargetButton), findsOneWidget); + + await tester.tap(find.byKey(TestKeys.sidebarFooterSettings)); await settleIntegrationUi(tester); expect( - find.byKey(const Key('assistant-focus-panel-title')), + find.byKey(TestKeys.settingsGatewayTab), findsOneWidget, ); - await _ensureSettingsFocused(tester); expect( - find.byKey( - const ValueKey('assistant-focus-active-title-settings'), - ), + find.byKey(TestKeys.settingsIntegrationsTab), findsOneWidget, ); - await tester.pumpWidget(const SizedBox.shrink()); + await tester.tap(find.byKey(const ValueKey('workspace-breadcrumb-0'))); await settleIntegrationUi(tester); + await waitForIntegrationFinder( + tester, + find.byKey(TestKeys.assistantConversationShell), + ); + + expect(find.byKey(TestKeys.assistantConversationShell), findsOneWidget); + expect(find.byKey(TestKeys.assistantComposerInput), findsOneWidget); }); } diff --git a/integration_test/desktop_settings_flow_test.dart b/integration_test/desktop_settings_flow_test.dart index 8ba5168b..485aa395 100644 --- a/integration_test/desktop_settings_flow_test.dart +++ b/integration_test/desktop_settings_flow_test.dart @@ -1,40 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test/helpers/test_keys.dart'; import 'test_support.dart'; -Finder _textEither(String zh, String en) { - return find.byWidgetPredicate( - (widget) => widget is Text && (widget.data == zh || widget.data == en), - ); -} - -Future _ensureSettingsFocused(WidgetTester tester) async { - final activeSettings = find.byKey( - const ValueKey('assistant-focus-active-title-settings'), - ); - if (activeSettings.evaluate().isNotEmpty) { - return; - } - final addSettingsChip = find.byKey( - const ValueKey('assistant-focus-add-settings'), - ); - if (addSettingsChip.evaluate().isNotEmpty) { - await tester.tap(addSettingsChip); - await settleIntegrationUi(tester); - return; - } - final addMenu = find.byKey(const Key('assistant-focus-add-menu')); - expect(addMenu, findsOneWidget); - await tester.tap(addMenu); - await settleIntegrationUi(tester); - final settingsItem = _textEither('设置', 'Settings'); - expect(settingsItem, findsWidgets); - await tester.tap(settingsItem.last); - await settleIntegrationUi(tester); -} - void main() { initializeIntegrationHarness(); @@ -46,23 +14,25 @@ void main() { 'desktop shell exposes settings entry for gateway configuration', (WidgetTester tester) async { await pumpDesktopApp(tester); - await waitForIntegrationFinder(tester, find.byKey(TestKeys.assistantTaskRail)); - - await tester.tap( - find.byKey(const Key('assistant-side-pane-tab-navigation')), + await waitForIntegrationFinder( + tester, + find.byKey(TestKeys.assistantConversationShell), ); + + await tester.tap(find.byKey(TestKeys.sidebarFooterSettings)); await settleIntegrationUi(tester); expect( - find.byKey(const Key('assistant-focus-panel-title')), + find.byKey(TestKeys.settingsGatewayTab), findsOneWidget, ); - await _ensureSettingsFocused(tester); + expect(find.byKey(TestKeys.settingsIntegrationsTab), findsOneWidget); + await tester.tap(find.byKey(TestKeys.settingsIntegrationsTab)); + await settleIntegrationUi(tester); expect( - find.byKey( - const ValueKey('assistant-focus-active-title-settings'), - ), + find.byKey(TestKeys.settingsExternalAcpProvider), findsOneWidget, ); + expect(find.byKey(TestKeys.settingsExternalAcpEndpoint), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await settleIntegrationUi(tester); diff --git a/integration_test/home_flow_test.dart b/integration_test/home_flow_test.dart index fc0cc7ec..f2b9217e 100644 --- a/integration_test/home_flow_test.dart +++ b/integration_test/home_flow_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test/helpers/test_keys.dart'; @@ -12,13 +11,16 @@ void main() { ) async { await resetIntegrationPreferences(); await pumpDesktopApp(tester); - await waitForIntegrationFinder(tester, find.byKey(TestKeys.assistantTaskRail)); + await waitForIntegrationFinder( + tester, + find.byKey(TestKeys.assistantConversationShell), + ); - expect(find.byKey(TestKeys.assistantTaskRail), findsOneWidget); - expect(find.byKey(TestKeys.assistantNewTaskButton), findsOneWidget); + expect(find.byKey(TestKeys.assistantConversationShell), findsOneWidget); + expect(find.byKey(TestKeys.workspaceSidebarNewTaskButton), findsOneWidget); expect(find.byKey(TestKeys.assistantExecutionTargetButton), findsOneWidget); expect(find.byKey(TestKeys.assistantComposerInput), findsOneWidget); - expect(find.byKey(TestKeys.assistantSubmitButton), findsOneWidget); + expect(find.byKey(TestKeys.assistantSendButton), findsOneWidget); expect( find.byKey(TestKeys.assistantExecutionTargetMenuItemSingleAgent), @@ -34,49 +36,18 @@ void main() { find.byKey(TestKeys.assistantSingleAgentProviderButton), findsOneWidget, ); - expect(find.text('ACP Server'), findsOneWidget); + expect(find.text('ACP Server Local'), findsOneWidget); }); - testWidgets( - 'core flow 02 can submit a prompt in single agent mode', - (WidgetTester tester) async { - await resetIntegrationPreferences(); - await pumpDesktopApp(tester); - await waitForIntegrationFinder( - tester, - find.byKey(TestKeys.assistantTaskRail), - ); - - await switchNewConversationExecutionTargetForIntegration( - tester, - find.byKey(TestKeys.assistantExecutionTargetMenuItemSingleAgent), - ); - - final prompt = '请回复:单机智能体提交成功'; - final composerInput = find.descendant( - of: find.byKey(TestKeys.assistantComposerInput), - matching: find.byType(TextField), - ); - - expect(composerInput, findsOneWidget); - - await tester.enterText(composerInput, prompt); - await tester.tap(find.byKey(TestKeys.assistantSubmitButton)); - await settleIntegrationUi(tester); - - await waitForIntegrationFinder(tester, find.textContaining(prompt)); - - expect(find.textContaining(prompt), findsWidgets); - expect(tester.widget(composerInput).controller?.text, isEmpty); - }, - ); - - testWidgets('core flow 03 can switch a new conversation to local openclaw gateway', ( + testWidgets('core flow 02 can switch a new conversation to local openclaw gateway', ( WidgetTester tester, ) async { await resetIntegrationPreferences(); await pumpDesktopApp(tester); - await waitForIntegrationFinder(tester, find.byKey(TestKeys.assistantTaskRail)); + await waitForIntegrationFinder( + tester, + find.byKey(TestKeys.assistantConversationShell), + ); await switchNewConversationExecutionTargetForIntegration( tester, @@ -86,12 +57,15 @@ void main() { expect(find.textContaining('127.0.0.1:4317'), findsWidgets); }); - testWidgets('core flow 04 can switch a new conversation to remote openclaw gateway', ( + testWidgets('core flow 03 can switch a new conversation to remote openclaw gateway', ( WidgetTester tester, ) async { await resetIntegrationPreferences(); await pumpDesktopApp(tester); - await waitForIntegrationFinder(tester, find.byKey(TestKeys.assistantTaskRail)); + await waitForIntegrationFinder( + tester, + find.byKey(TestKeys.assistantConversationShell), + ); await switchNewConversationExecutionTargetForIntegration( tester, diff --git a/integration_test/test_support.dart b/integration_test/test_support.dart index 42a7fdde..01130da9 100644 --- a/integration_test/test_support.dart +++ b/integration_test/test_support.dart @@ -109,7 +109,14 @@ Future switchNewConversationExecutionTargetForIntegration( WidgetTester tester, Finder menuItemFinder, ) async { - await tester.tap(find.byKey(const Key('assistant-new-task-button'))); + final desktopNewTaskButton = find.byKey( + const Key('workspace-sidebar-new-task-button'), + ); + if (desktopNewTaskButton.evaluate().isNotEmpty) { + await tester.tap(desktopNewTaskButton); + } else { + await tester.tap(find.byKey(const Key('assistant-new-task-button'))); + } await settleIntegrationUi(tester); await tester.tap(find.byKey(const Key('assistant-execution-target-button'))); await settleIntegrationUi(tester); diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 570e5441..ad90f712 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -387,7 +387,7 @@ extension AppControllerDesktopThreadSessions on AppController { ); final target = assistantExecutionTargetForSession(normalizedSessionKey); if (target == AssistantExecutionTarget.singleAgent) { - final primaryLabel = appText('ACP Server', 'ACP Server'); + final primaryLabel = appText('ACP Server Local', 'ACP Server Local'); final provider = singleAgentProviderForSession(normalizedSessionKey); final resolvedProvider = singleAgentResolvedProviderForSession( normalizedSessionKey, diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index b3cfc244..aacd9fec 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -446,7 +446,7 @@ extension AppControllerDesktopThreadStorage on AppController { path: resolvedRootPath, bookmark: rootSpec.bookmark, ), - ); + ); if (accessHandle == null) { continue; } @@ -544,6 +544,7 @@ extension AppControllerDesktopThreadStorage on AppController { Future> scanSingleAgentWorkspaceSkillEntriesInternal(String sessionKey) { + final workspacePath = assistantWorkspacePathForSession(sessionKey); if (assistantWorkspaceKindForSession(sessionKey) != WorkspaceRefKind.localPath) { return Future>.value( @@ -552,7 +553,7 @@ extension AppControllerDesktopThreadStorage on AppController { } return scanSingleAgentSkillEntriesInternal( AppController.defaultSingleAgentWorkspaceSkillScanRootsInternal, - workspacePath: assistantWorkspacePathForSession(sessionKey), + workspacePath: workspacePath, ); } diff --git a/lib/runtime/aris_llm_chat_client.dart b/lib/runtime/aris_llm_chat_client.dart index a91435b2..ef1673ae 100644 --- a/lib/runtime/aris_llm_chat_client.dart +++ b/lib/runtime/aris_llm_chat_client.dart @@ -97,7 +97,7 @@ class ArisLlmChatClient { isAppleHost: Platform.isIOS || Platform.isMacOS, )) { throw UnsupportedError( - 'App Store builds do not allow launching local Go core processes.', + 'App Store builds only allow the bundled Go core helper inside the app bundle.', ); } diff --git a/lib/runtime/embedded_agent_launch_policy.dart b/lib/runtime/embedded_agent_launch_policy.dart index 8c4668db..b587df97 100644 --- a/lib/runtime/embedded_agent_launch_policy.dart +++ b/lib/runtime/embedded_agent_launch_policy.dart @@ -16,8 +16,11 @@ bool shouldBlockGoCoreLaunch( required bool isAppleHost, bool? enabled, }) { - return shouldApplyAppleAppStorePolicy( + if (!shouldApplyAppleAppStorePolicy( isAppleHost: isAppleHost, enabled: enabled, - ); + )) { + return false; + } + return launch.source != GoCoreLaunchSource.bundledHelper; } diff --git a/lib/runtime/runtime_bootstrap.dart b/lib/runtime/runtime_bootstrap.dart index 732aaf2d..7db7a23d 100644 --- a/lib/runtime/runtime_bootstrap.dart +++ b/lib/runtime/runtime_bootstrap.dart @@ -26,7 +26,7 @@ class RuntimeBootstrapConfig { workspaceRoot, cliPathHint: cliPathHint, ); - final env = await _loadEnvFile( + final env = _loadEnvFile( workspacePathHint: workspacePathHint, cliPathHint: cliPathHint, workspaceRoot: workspaceRoot, @@ -176,12 +176,12 @@ class GatewayBootstrapTarget { } } -Future> _loadEnvFile({ +Map _loadEnvFile({ String? workspacePathHint, String? cliPathHint, Directory? workspaceRoot, Directory? openClawRoot, -}) async { +}) { final candidateDirectories = { Directory.current, ..._ancestorDirectories(Directory.current), @@ -199,11 +199,11 @@ Future> _loadEnvFile({ .toList(growable: false); for (final file in candidates) { - if (!await file.exists()) { + if (!file.existsSync()) { continue; } final values = {}; - for (final line in await file.readAsLines()) { + for (final line in file.readAsLinesSync()) { final trimmed = line.trim(); if (trimmed.isEmpty || trimmed.startsWith('#')) { continue; diff --git a/test/features/assistant_page_single_agent_flow_suite.dart b/test/features/assistant_page_single_agent_flow_suite.dart index 904f6374..3b01ab38 100644 --- a/test/features/assistant_page_single_agent_flow_suite.dart +++ b/test/features/assistant_page_single_agent_flow_suite.dart @@ -1,14 +1,17 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/assistant/assistant_page.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/theme/app_theme.dart'; -import '../test_support.dart'; +import '../runtime/app_controller_ai_gateway_chat_suite_fakes.dart'; +import 'assistant_page_suite_support.dart'; Future _waitForText( WidgetTester tester, @@ -18,7 +21,7 @@ Future _waitForText( final deadline = DateTime.now().add(timeout); while (finder.evaluate().isEmpty) { if (DateTime.now().isAfter(deadline)) { - fail('Timed out waiting for $finder'); + fail('Timed out waiting for ${finder.description}'); } await tester.pump(const Duration(milliseconds: 50)); } @@ -28,104 +31,115 @@ void main() { testWidgets( 'AssistantPage single agent can be selected and receive streaming reply', (WidgetTester tester) async { - final server = await _ChatServer.start(); - addTearDown(server.close); - - final controller = await createTestController(tester); - await controller.saveSettings( - controller.settings.copyWith( - aiGateway: controller.settings.aiGateway.copyWith( - baseUrl: server.baseUri.toString(), - availableModels: const ['codex-chat'], - selectedModels: const ['codex-chat'], - ), - defaultModel: 'codex-chat', + final workspaceDirectory = Directory.systemTemp.createTempSync( + 'xworkmate-single-agent-workspace-', + ); + addTearDown(() async { + if (await workspaceDirectory.exists()) { + await workspaceDirectory.delete(recursive: true); + } + }); + final fakeGoTaskServiceClient = FakeGoTaskServiceClientInternal( + capabilities: ExternalCodeAgentAcpCapabilities( + singleAgent: true, + multiAgent: false, + providers: {SingleAgentProvider.opencode}, + raw: {}, + ), + result: const GoTaskServiceResult( + success: true, + message: 'CODEX_REPLY', + turnId: 'turn-1', + raw: {}, + errorMessage: '', + resolvedModel: 'codex-chat', + route: GoTaskServiceRoute.externalAcpSingle, ), ); - await controller.settingsController.saveAiGatewayApiKey('test-key'); + final noopMultiAgentMountManager = + NoopMultiAgentMountManagerInternal(); + final controller = await createControllerWithThreadRecordsInternal( + tester: tester, + records: [ + TaskThread( + threadId: 'main', + workspaceBinding: const WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.remoteFs, + workspacePath: '', + displayPath: '', + writable: true, + ).copyWith( + workspacePath: workspaceDirectory.path, + displayPath: workspaceDirectory.path, + ), + messages: const [], + updatedAtMs: 1, + title: 'Main', + archived: false, + executionBinding: const ExecutionBinding( + executionMode: ThreadExecutionMode.gatewayLocal, + executorId: 'opencode', + providerId: 'opencode', + endpointId: '', + ), + messageViewMode: AssistantMessageViewMode.rendered, + ), + ], + useFakeGatewayRuntime: true, + assistantExecutionTargetOverride: AssistantExecutionTarget.local, + availableSingleAgentProvidersOverride: const [], + singleAgentSharedSkillScanRootOverrides: const [], + disableGatewayProfileEndpoints: true, + goTaskServiceClient: fakeGoTaskServiceClient, + multiAgentMountManager: noopMultiAgentMountManager, + ); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); - await pumpPage( - tester, - child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + supportedLocales: const [Locale('zh'), Locale('en')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + home: Scaffold( + body: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ), + ), ); - - final targetButton = find.byKey( - const ValueKey('assistant-execution-target-button'), - ); - await tester.tap(targetButton); - await tester.pumpAndSettle(); - await tester.tap(find.text('单机智能体').last); - await tester.pumpAndSettle(); - - expect(find.text('单机智能体'), findsWidgets); + await tester.pump(const Duration(milliseconds: 200)); await tester.enterText( find.byKey(const ValueKey('assistant-composer-input-area')), 'hello codex', ); await tester.tap( - find.byKey(const ValueKey('assistant-submit-button')), + find.byKey(const ValueKey('assistant-send-button')), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); await _waitForText(tester, find.textContaining('CODEX_REPLY')); expect(find.textContaining('CODEX_REPLY'), findsWidgets); - expect(server.requestCount, greaterThanOrEqualTo(1)); - expect(controller.chatMessages.any((m) => m.text.contains('hello codex')), - isTrue); + expect(fakeGoTaskServiceClient.executeCalls, 1); + expect( + fakeGoTaskServiceClient.lastRequest?.provider, + SingleAgentProvider.opencode, + ); + expect(find.textContaining('hello codex'), findsWidgets); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + await tester.pump(); }, ); } - -class _ChatServer { - _ChatServer._(this._server); - - final HttpServer _server; - int requestCount = 0; - - Uri get baseUri => Uri.parse('http://127.0.0.1:${_server.port}'); - - static Future<_ChatServer> start() async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final fake = _ChatServer._(server); - unawaited(fake._listen()); - return fake; - } - - Future close() async { - await _server.close(force: true); - } - - Future _listen() async { - await for (final request in _server) { - requestCount += 1; - if (request.uri.path != '/v1/chat/completions') { - request.response.statusCode = HttpStatus.notFound; - await request.response.close(); - continue; - } - final response = { - 'id': 'chatcmpl-test', - 'choices': >[ - { - 'index': 0, - 'delta': {'content': 'CODEX_REPLY'}, - 'finish_reason': 'stop', - }, - ], - }; - request.response.headers.set( - HttpHeaders.contentTypeHeader, - 'text/event-stream; charset=utf-8', - ); - request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache'); - request.response.write('data: ${jsonEncode(response)}\n\n'); - await request.response.flush(); - await request.response.close(); - } - } -} diff --git a/test/features/assistant_page_suite_composer.dart b/test/features/assistant_page_suite_composer.dart index 8c6fe640..dbbae7bd 100644 --- a/test/features/assistant_page_suite_composer.dart +++ b/test/features/assistant_page_suite_composer.dart @@ -77,7 +77,7 @@ void registerAssistantPageSuiteComposerTestsInternal() { final pageRect = tester.getRect(find.byType(AssistantPage)); final composerShell = find.byKey(const Key('assistant-composer-shell')); - final submitButton = find.byKey(const Key('assistant-submit-button')); + final submitButton = find.byKey(const Key('assistant-send-button')); expect(composerShell, findsOneWidget); expect(submitButton, findsOneWidget); @@ -212,22 +212,14 @@ void registerAssistantPageSuiteComposerTestsInternal() { 'AssistantPage submits from the selected task thread workspace after switching tasks', (WidgetTester tester) async { late final Directory tempDirectory; - late final SecureConfigStore store; late final CaptureSendAppControllerInternal controller; await tester.runAsync(() async { SharedPreferences.setMockInitialValues({}); tempDirectory = await Directory.systemTemp.createTemp( 'xworkmate-assistant-page-thread-cwd-ui-', ); - store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - defaultSupportDirectoryPathResolver: () async => tempDirectory.path, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - SettingsSnapshot.defaults().copyWith( + final store = AssistantPageMemorySecureConfigStoreInternal( + initialSettingsSnapshot: SettingsSnapshot.defaults().copyWith( assistantExecutionTarget: AssistantExecutionTarget.singleAgent, workspacePath: '${tempDirectory.path}/workspace-root', ), @@ -336,7 +328,7 @@ void registerAssistantPageSuiteComposerTestsInternal() { expect(composerInput, findsOneWidget); await tester.enterText(composerInput, '检查线程目录'); - await tester.tap(find.byKey(const Key('assistant-submit-button'))); + await tester.tap(find.byKey(const Key('assistant-send-button'))); await pumpForUiSyncInternal(tester); expect(controller.sendCallCount, 1); diff --git a/test/features/assistant_page_suite_support.dart b/test/features/assistant_page_suite_support.dart index 8d799f33..09d6c516 100644 --- a/test/features/assistant_page_suite_support.dart +++ b/test/features/assistant_page_suite_support.dart @@ -15,6 +15,9 @@ import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; +import 'package:xworkmate/runtime/multi_agent_mount_resolver.dart'; +import 'package:xworkmate/runtime/multi_agent_mounts.dart'; import 'package:xworkmate/runtime/multi_agent_orchestrator.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; @@ -26,6 +29,318 @@ import '../runtime/app_controller_thread_skills_suite_fixtures.dart'; import 'assistant_page_suite_core.dart'; import 'assistant_page_suite_composer.dart'; +class AssistantPageMemorySecureConfigStoreInternal extends SecureConfigStore { + AssistantPageMemorySecureConfigStoreInternal({ + required SettingsSnapshot initialSettingsSnapshot, + List initialTaskThreads = const [], + }) : _settingsSnapshot = initialSettingsSnapshot, + _taskThreads = List.from(initialTaskThreads), + super(enableSecureStorage: false); + + SettingsSnapshot _settingsSnapshot; + List _taskThreads; + Map _secretValueByRef = {}; + Map _supportJsonByPath = {}; + LocalDeviceIdentity? _deviceIdentity; + + @override + Future initialize() async {} + + @override + Future loadSettingsSnapshot() async { + return _settingsSnapshot; + } + + @override + Future reloadSettingsSnapshot() async { + return _settingsSnapshot; + } + + @override + Future reloadSettingsSnapshotResult() async { + return SettingsSnapshotReloadResult( + snapshot: _settingsSnapshot, + status: SettingsSnapshotReloadStatus.applied, + ); + } + + @override + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + _settingsSnapshot = snapshot; + } + + @override + Future> resolvedSettingsFiles() async => const []; + + @override + Future> resolvedSettingsWatchDirectories() async => + const []; + + @override + Future> loadTaskThreads() async { + return List.from(_taskThreads); + } + + @override + Future saveTaskThreads(List records) async { + _taskThreads = List.from(records); + } + + @override + Future clearAssistantLocalState() async { + _settingsSnapshot = _settingsSnapshot.copyWith( + assistantCustomTaskTitles: const {}, + assistantArchivedTaskKeys: const [], + assistantLastSessionKey: '', + ); + _taskThreads = const []; + } + + @override + Future> loadAuditTrail() async => + const []; + + @override + Future appendAudit(SecretAuditEntry entry) async {} + + @override + Future> loadSecureRefs() async => + Map.unmodifiable(_secretValueByRef); + + @override + Future?> loadSupportJson(String relativePath) async { + final payload = _supportJsonByPath[relativePath.trim()]; + return payload is Map ? payload : null; + } + + @override + Future saveSupportJson( + String relativePath, + Map payload, + ) async { + _supportJsonByPath = { + ..._supportJsonByPath, + relativePath.trim(): Map.from(payload), + }; + } + + @override + Future loadAccountSyncState() async => null; + + @override + Future saveAccountSyncState(AccountSyncState value) async {} + + @override + Future clearAccountSyncState() async {} + + @override + Future loadAccountProfile() async => null; + + @override + Future saveAccountProfile(AccountRemoteProfile value) async {} + + @override + Future clearAccountProfile() async {} + + @override + Future loadAccountManagedSecret({required String target}) async => + null; + + @override + Future saveAccountManagedSecret({ + required String target, + required String value, + }) async {} + + @override + Future clearAccountManagedSecret({required String target}) async {} + + @override + Future clearAccountManagedSecrets() async {} + + @override + Future loadDeviceIdentity() async => _deviceIdentity; + + @override + Future saveDeviceIdentity(LocalDeviceIdentity identity) async { + _deviceIdentity = identity; + } + + @override + Future loadDeviceToken({ + required String deviceId, + required String role, + }) async => + null; + + @override + Future saveDeviceToken({ + required String deviceId, + required String role, + required String token, + }) async {} + + @override + Future clearDeviceToken({ + required String deviceId, + required String role, + }) async {} + + @override + Future loadGatewayToken({int? profileIndex}) async => null; + + @override + Future saveGatewayToken(String value, {int? profileIndex}) async {} + + @override + Future clearGatewayToken({int? profileIndex}) async {} + + @override + Future loadGatewayPassword({int? profileIndex}) async => null; + + @override + Future saveGatewayPassword(String value, {int? profileIndex}) async {} + + @override + Future clearGatewayPassword({int? profileIndex}) async {} + + @override + Future loadOllamaCloudApiKey() async => null; + + @override + Future saveOllamaCloudApiKey(String value) async {} + + @override + Future loadVaultToken() async => null; + + @override + Future saveVaultToken(String value) async {} + + @override + Future loadAiGatewayApiKey() async => + _getSecretValue('ai_gateway_api_key'); + + @override + Future saveAiGatewayApiKey(String value) async { + _setSecretValue('ai_gateway_api_key', value); + } + + @override + Future clearAiGatewayApiKey() async { + _clearSecretValue('ai_gateway_api_key'); + } + + @override + Future loadAccountSessionToken() async => null; + + @override + Future saveAccountSessionToken(String value) async {} + + @override + Future clearAccountSessionToken() async {} + + @override + Future loadAccountSessionExpiresAtMs() async => 0; + + @override + Future saveAccountSessionExpiresAtMs(int value) async {} + + @override + Future clearAccountSessionExpiresAtMs() async {} + + @override + Future loadAccountSessionUserId() async => null; + + @override + Future saveAccountSessionUserId(String value) async {} + + @override + Future clearAccountSessionUserId() async {} + + @override + Future loadAccountSessionIdentifier() async => null; + + @override + Future saveAccountSessionIdentifier(String value) async {} + + @override + Future clearAccountSessionIdentifier() async {} + + @override + Future loadAccountSessionSummary() async => null; + + @override + Future saveAccountSessionSummary(AccountSessionSummary value) async {} + + @override + Future clearAccountSessionSummary() async {} + + @override + Future loadSecretValueByRef(String refName) async => + _getSecretValue(refName); + + @override + Future saveSecretValueByRef(String refName, String value) async { + _setSecretValue(refName, value); + } + + @override + Future clearSecretValueByRef(String refName) async { + _clearSecretValue(refName); + } + + @override + void dispose() {} + + @override + PersistentWriteFailures get persistentWriteFailures => + const PersistentWriteFailures(); + + void _setSecretValue(String refName, String value) { + final normalizedRef = refName.trim(); + final trimmedValue = value.trim(); + if (normalizedRef.isEmpty || trimmedValue.isEmpty) { + return; + } + _secretValueByRef = { + ..._secretValueByRef, + normalizedRef: trimmedValue, + }; + } + + String? _getSecretValue(String refName) { + final normalizedRef = refName.trim(); + if (normalizedRef.isEmpty) { + return null; + } + return _secretValueByRef[normalizedRef]; + } + + void _clearSecretValue(String refName) { + final normalizedRef = refName.trim(); + if (normalizedRef.isEmpty || !_secretValueByRef.containsKey(normalizedRef)) { + return; + } + _secretValueByRef = { + for (final entry in _secretValueByRef.entries) + if (entry.key != normalizedRef) entry.key: entry.value, + }; + } +} + +class NoopMultiAgentMountManagerInternal extends MultiAgentMountManager { + NoopMultiAgentMountManagerInternal() : super(); + + @override + Future reconcile({ + required MultiAgentConfig config, + required String aiGatewayUrl, + String configuredCodexCliPath = '', + }) async { + return config; + } +} + void registerAssistantPageSuiteSupportTestsInternal() { testWidgets( 'AssistantPage shows Single Agent chip and keeps task rows minimal', @@ -69,21 +384,114 @@ void registerAssistantPageSuiteSupportTestsInternal() { ); } +SettingsSnapshot buildAssistantPageTestSettingsSnapshotInternal( + SettingsSnapshot defaults, { + required String workspacePath, + required bool disableGatewayProfileEndpoints, + required AssistantExecutionTarget assistantExecutionTarget, +}) { + final gatewayProfiles = disableGatewayProfileEndpoints + ? [ + GatewayConnectionProfile( + mode: RuntimeConnectionMode.local, + useSetupCode: false, + setupCode: '', + host: '', + port: 0, + tls: false, + tokenRef: defaults.primaryLocalGatewayProfile.tokenRef, + passwordRef: defaults.primaryLocalGatewayProfile.passwordRef, + selectedAgentId: defaults.primaryLocalGatewayProfile.selectedAgentId, + ), + GatewayConnectionProfile( + mode: RuntimeConnectionMode.remote, + useSetupCode: false, + setupCode: '', + host: '', + port: 0, + tls: false, + tokenRef: defaults.primaryRemoteGatewayProfile.tokenRef, + passwordRef: defaults.primaryRemoteGatewayProfile.passwordRef, + selectedAgentId: defaults.primaryRemoteGatewayProfile.selectedAgentId, + ), + ...defaults.gatewayProfiles.skip(2), + ] + : defaults.gatewayProfiles; + return SettingsSnapshot( + appLanguage: defaults.appLanguage, + appActive: defaults.appActive, + launchAtLogin: defaults.launchAtLogin, + showDockIcon: defaults.showDockIcon, + workspacePath: workspacePath, + remoteProjectRoot: defaults.remoteProjectRoot, + cliPath: defaults.cliPath, + codeAgentRuntimeMode: defaults.codeAgentRuntimeMode, + codexCliPath: defaults.codexCliPath, + defaultModel: 'qwen2.5-coder:latest', + defaultProvider: defaults.defaultProvider, + gatewayProfiles: gatewayProfiles, + externalAcpEndpoints: defaults.externalAcpEndpoints, + authorizedSkillDirectories: defaults.authorizedSkillDirectories, + ollamaLocal: defaults.ollamaLocal.copyWith( + endpoint: 'http://127.0.0.1:11434', + defaultModel: 'qwen2.5-coder:latest', + autoDiscover: true, + ), + ollamaCloud: defaults.ollamaCloud, + vault: defaults.vault, + aiGateway: defaults.aiGateway.copyWith( + baseUrl: 'http://127.0.0.1:11434/v1', + availableModels: const ['qwen2.5-coder:latest'], + selectedModels: const ['qwen2.5-coder:latest'], + ), + webSessionPersistence: defaults.webSessionPersistence, + multiAgent: defaults.multiAgent.copyWith( + enabled: defaults.multiAgent.enabled, + ), + experimentalCanvas: defaults.experimentalCanvas, + experimentalBridge: defaults.experimentalBridge, + experimentalDebug: defaults.experimentalDebug, + accountBaseUrl: defaults.accountBaseUrl, + accountUsername: defaults.accountUsername, + accountWorkspace: defaults.accountWorkspace, + accountWorkspaceFollowed: defaults.accountWorkspaceFollowed, + accountLocalMode: defaults.accountLocalMode, + linuxDesktop: defaults.linuxDesktop, + assistantExecutionTarget: assistantExecutionTarget, + assistantPermissionLevel: defaults.assistantPermissionLevel, + assistantNavigationDestinations: defaults.assistantNavigationDestinations, + assistantCustomTaskTitles: defaults.assistantCustomTaskTitles, + assistantArchivedTaskKeys: defaults.assistantArchivedTaskKeys, + savedGatewayTargets: defaults.savedGatewayTargets, + assistantLastSessionKey: defaults.assistantLastSessionKey, + ); +} + Future createControllerWithThreadRecordsInternal({ WidgetTester? tester, required List records, bool useFakeGatewayRuntime = false, + AssistantExecutionTarget assistantExecutionTargetOverride = + AssistantExecutionTarget.singleAgent, + List? availableSingleAgentProvidersOverride, + GoTaskServiceClient? goTaskServiceClient, + MultiAgentMountManager? multiAgentMountManager, List? singleAgentSharedSkillScanRootOverrides, + bool disableGatewayProfileEndpoints = false, }) async { SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( + final tempDirectory = Directory.systemTemp.createTempSync( 'xworkmate-assistant-page-tests-', ); - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - defaultSupportDirectoryPathResolver: () async => tempDirectory.path, + final settingsSnapshot = buildAssistantPageTestSettingsSnapshotInternal( + SettingsSnapshot.defaults(), + workspacePath: tempDirectory.path, + disableGatewayProfileEndpoints: disableGatewayProfileEndpoints, + assistantExecutionTarget: assistantExecutionTargetOverride, + ); + final store = AssistantPageMemorySecureConfigStoreInternal( + initialSettingsSnapshot: settingsSnapshot, + initialTaskThreads: records, ); addTearDown(() async { if (await tempDirectory.exists()) { @@ -92,37 +500,6 @@ Future createControllerWithThreadRecordsInternal({ } catch (_) {} } }); - final defaults = SettingsSnapshot.defaults(); - await store.saveSettingsSnapshot( - defaults.copyWith( - gatewayProfiles: replaceGatewayProfileAt( - replaceGatewayProfileAt( - defaults.gatewayProfiles, - kGatewayLocalProfileIndex, - defaults.primaryLocalGatewayProfile.copyWith( - host: '127.0.0.1', - port: 9, - tls: false, - ), - ), - kGatewayRemoteProfileIndex, - defaults.primaryRemoteGatewayProfile.copyWith( - host: '127.0.0.1', - port: 9, - tls: false, - ), - ), - aiGateway: defaults.aiGateway.copyWith( - baseUrl: 'http://127.0.0.1:11434/v1', - availableModels: const ['qwen2.5-coder:latest'], - selectedModels: const ['qwen2.5-coder:latest'], - ), - assistantExecutionTarget: AssistantExecutionTarget.singleAgent, - defaultModel: 'qwen2.5-coder:latest', - workspacePath: tempDirectory.path, - ), - ); - await store.saveTaskThreads(records); final controller = AppController( store: store, runtimeCoordinator: useFakeGatewayRuntime @@ -131,9 +508,14 @@ Future createControllerWithThreadRecordsInternal({ codex: FakeCodexRuntimeInternal(), ) : null, + availableSingleAgentProvidersOverride: + availableSingleAgentProvidersOverride, + goTaskServiceClient: goTaskServiceClient, + multiAgentMountManager: multiAgentMountManager, singleAgentSharedSkillScanRootOverrides: singleAgentSharedSkillScanRootOverrides, ); + addTearDown(controller.dispose); final stopwatch = Stopwatch()..start(); while (controller.initializing) { if (stopwatch.elapsed > const Duration(seconds: 10)) { @@ -323,15 +705,10 @@ createInstalledSkillE2EControllerInternal( required InstalledSkillE2ECaseInternal testCase, }) async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - defaultSupportDirectoryPathResolver: () async => tempDirectory.path, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - singleAgentTestSettingsInternal(workspacePath: workspaceRoot.path).copyWith( + final store = AssistantPageMemorySecureConfigStoreInternal( + initialSettingsSnapshot: singleAgentTestSettingsInternal( + workspacePath: workspaceRoot.path, + ).copyWith( assistantExecutionTarget: AssistantExecutionTarget.singleAgent, multiAgent: MultiAgentConfig.defaults().copyWith(enabled: false), ), @@ -376,15 +753,10 @@ createInstalledSkillE2EControllerSimpleInternal({ required InstalledSkillE2ECaseInternal testCase, }) async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', - fallbackDirectoryPathResolver: () async => tempDirectory.path, - defaultSupportDirectoryPathResolver: () async => tempDirectory.path, - ); - await store.initialize(); - await store.saveSettingsSnapshot( - singleAgentTestSettingsInternal(workspacePath: workspaceRoot.path).copyWith( + final store = AssistantPageMemorySecureConfigStoreInternal( + initialSettingsSnapshot: singleAgentTestSettingsInternal( + workspacePath: workspaceRoot.path, + ).copyWith( assistantExecutionTarget: AssistantExecutionTarget.singleAgent, multiAgent: MultiAgentConfig.defaults().copyWith(enabled: false), ), diff --git a/test/features/settings_page_external_acp_end_to_end_suite.dart b/test/features/settings_page_external_acp_end_to_end_suite.dart index 25f3fcfa..59698ed1 100644 --- a/test/features/settings_page_external_acp_end_to_end_suite.dart +++ b/test/features/settings_page_external_acp_end_to_end_suite.dart @@ -19,7 +19,7 @@ Future _waitForText( final deadline = DateTime.now().add(timeout); while (finder.evaluate().isEmpty) { if (DateTime.now().isAfter(deadline)) { - fail('Timed out waiting for $finder'); + fail('Timed out waiting for ${finder.description}'); } await tester.pump(const Duration(milliseconds: 50)); } diff --git a/test/helpers/test_keys.dart b/test/helpers/test_keys.dart index a4dd2ee7..5ae275a2 100644 --- a/test/helpers/test_keys.dart +++ b/test/helpers/test_keys.dart @@ -3,7 +3,14 @@ import 'package:flutter/widgets.dart'; class TestKeys { const TestKeys._(); - static const Key settingsGatewayTab = Key('sidebar-settings-tab-gateway'); + static const Key assistantConversationShell = Key( + 'assistant-conversation-shell', + ); + static const Key workspaceSidebarNewTaskButton = Key( + 'workspace-sidebar-new-task-button', + ); + static const Key sidebarFooterSettings = Key('sidebar-footer-settings'); + static const Key settingsGatewayTab = Key('section-tab-OpenClaw Gateway'); static const Key settingsIntegrationsTab = Key('section-tab-ACP 外部接入'); static const Key settingsGatewayIntegrationTab = Key( 'section-tab-OpenClaw Gateway', @@ -20,6 +27,7 @@ class TestKeys { static const Key assistantExecutionTargetButton = Key( 'assistant-execution-target-button', ); + static const Key assistantSendButton = Key('assistant-send-button'); static const Key assistantSingleAgentProviderButton = Key( 'assistant-single-agent-provider-button', ); @@ -35,7 +43,7 @@ class TestKeys { static const Key assistantComposerInput = Key( 'assistant-composer-input-area', ); - static const Key assistantSubmitButton = Key('assistant-submit-button'); + static const Key assistantSubmitButton = assistantSendButton; static const Key assistantNewTaskButton = Key('assistant-new-task-button'); static const Key assistantTaskItemMain = ValueKey( 'assistant-task-item-main', diff --git a/test/runtime/app_controller_core_flow_test.dart b/test/runtime/app_controller_core_flow_test.dart index 43a0bd50..ae29b565 100644 --- a/test/runtime/app_controller_core_flow_test.dart +++ b/test/runtime/app_controller_core_flow_test.dart @@ -36,7 +36,7 @@ void main() { AssistantExecutionTarget.singleAgent, ); expect(controller.currentAssistantConnectionState.isSingleAgent, isTrue); - expect(controller.assistantConnectionStatusLabel, 'ACP Server'); + expect(controller.assistantConnectionStatusLabel, 'ACP Server Local'); }, ); diff --git a/test/runtime/app_controller_execution_target_switch_suite_connection.dart b/test/runtime/app_controller_execution_target_switch_suite_connection.dart index 84b50f54..8f9fe48f 100644 --- a/test/runtime/app_controller_execution_target_switch_suite_connection.dart +++ b/test/runtime/app_controller_execution_target_switch_suite_connection.dart @@ -144,7 +144,7 @@ void registerExecutionTargetSwitchConnectionTests() { RuntimeConnectionMode.remote, ); expect(gateway.disconnectCount, 1); - expect(controller.assistantConnectionStatusLabel, 'ACP Server'); + expect(controller.assistantConnectionStatusLabel, 'ACP Server Local'); expect( controller.assistantConnectionTargetLabel, '没有可用的外部 Agent ACP 端点,请配置 LLM API fallback。', @@ -337,7 +337,7 @@ void registerExecutionTargetSwitchConnectionTests() { controller.settings.assistantExecutionTarget, AssistantExecutionTarget.remote, ); - expect(controller.assistantConnectionStatusLabel, 'ACP Server'); + expect(controller.assistantConnectionStatusLabel, 'ACP Server Local'); }, ); @@ -479,7 +479,7 @@ void registerExecutionTargetSwitchConnectionTests() { controller.assistantExecutionTarget, AssistantExecutionTarget.singleAgent, ); - expect(controller.assistantConnectionStatusLabel, 'ACP Server'); + expect(controller.assistantConnectionStatusLabel, 'ACP Server Local'); expect(completed, isFalse); } finally { if (!disconnectGate.isCompleted) { @@ -497,7 +497,7 @@ void registerExecutionTargetSwitchConnectionTests() { controller.assistantExecutionTarget, AssistantExecutionTarget.singleAgent, ); - expect(controller.assistantConnectionStatusLabel, 'ACP Server'); + expect(controller.assistantConnectionStatusLabel, 'ACP Server Local'); }, ); }); diff --git a/test/runtime/app_controller_execution_target_switch_suite_thread.dart b/test/runtime/app_controller_execution_target_switch_suite_thread.dart index 388d66b5..24fe1013 100644 --- a/test/runtime/app_controller_execution_target_switch_suite_thread.dart +++ b/test/runtime/app_controller_execution_target_switch_suite_thread.dart @@ -98,7 +98,7 @@ void registerExecutionTargetSwitchThreadTests() { AssistantExecutionTarget.singleAgent, ); expect(gateway.disconnectCount, 1); - expect(controller.assistantConnectionStatusLabel, 'ACP Server'); + expect(controller.assistantConnectionStatusLabel, 'ACP Server Local'); expect( controller.settings.assistantExecutionTarget, AssistantExecutionTarget.local, @@ -195,7 +195,7 @@ void registerExecutionTargetSwitchThreadTests() { ); await controller.switchSession('main'); - expect(controller.assistantConnectionStatusLabel, 'ACP Server'); + expect(controller.assistantConnectionStatusLabel, 'ACP Server Local'); expect( controller.assistantConnectionTargetLabel, '没有可用的外部 Agent ACP 端点,请配置 LLM API fallback。', diff --git a/test/runtime/embedded_agent_launch_policy_test.dart b/test/runtime/embedded_agent_launch_policy_test.dart index be804017..b3cd56b2 100644 --- a/test/runtime/embedded_agent_launch_policy_test.dart +++ b/test/runtime/embedded_agent_launch_policy_test.dart @@ -14,7 +14,7 @@ void main() { ); }); - test('apple app store policy blocks all go core launches', () { + test('apple app store policy allows only bundled go core helpers', () { const bundled = GoCoreLaunch( executable: '/Applications/XWorkmate.app/Contents/Helpers/xworkmate-go-core', source: GoCoreLaunchSource.bundledHelper, @@ -26,7 +26,7 @@ void main() { expect( shouldBlockGoCoreLaunch(bundled, isAppleHost: true, enabled: true), - isTrue, + isFalse, ); expect( shouldBlockGoCoreLaunch(buildArtifact, isAppleHost: true, enabled: true), diff --git a/test/test_support.dart b/test/test_support.dart index 7d29e293..7fd4f074 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -10,6 +10,7 @@ import 'package:xworkmate/runtime/account_runtime_client.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -53,7 +54,11 @@ Future createTestController( DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, AccountRuntimeClient Function(String baseUrl)? accountClientFactory, + SettingsSnapshot? initialSettingsSnapshot, + List? availableSingleAgentProvidersOverride, + GoTaskServiceClient? goTaskServiceClient, List? singleAgentSharedSkillScanRootOverrides, + bool settle = true, }) async { SharedPreferences.setMockInitialValues({}); final testRoot = @@ -63,6 +68,11 @@ Future createTestController( databasePathResolver: () async => '$testRoot/settings.sqlite3', fallbackDirectoryPathResolver: () async => testRoot, ); + if (initialSettingsSnapshot != null) { + await Directory(testRoot).create(recursive: true); + await store.initialize(); + await store.saveSettingsSnapshot(initialSettingsSnapshot); + } final controller = AppController( store: store, runtimeCoordinator: RuntimeCoordinator( @@ -72,12 +82,17 @@ Future createTestController( desktopPlatformService: desktopPlatformService, uiFeatureManifest: uiFeatureManifest, accountClientFactory: accountClientFactory, + availableSingleAgentProvidersOverride: + availableSingleAgentProvidersOverride, + goTaskServiceClient: goTaskServiceClient, singleAgentSharedSkillScanRootOverrides: singleAgentSharedSkillScanRootOverrides, ); addTearDown(controller.dispose); await tester.pump(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); + if (settle) { + await tester.pumpAndSettle(); + } return controller; } diff --git a/test/widget_test.dart b/test/widget_test.dart index a25fb1cb..9102d421 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -16,8 +16,9 @@ void main() { await tester.pumpWidget(const XWorkmateApp()); await tester.pumpAndSettle(); - expect(find.text('新对话'), findsWidgets); - expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); + expect(find.byKey(const Key('assistant-conversation-shell')), findsOneWidget); + expect(find.byKey(const Key('workspace-sidebar-new-task-button')), findsOneWidget); + expect(find.byKey(const Key('assistant-send-button')), findsOneWidget); expect(find.textContaining('输入需求、补充上下文'), findsOneWidget); if (kIsWeb) {