chore: checkpoint current workspace changes

This commit is contained in:
Haitao Pan 2026-04-08 20:02:25 +08:00
parent 34e1e1250e
commit 69a339e91d
25 changed files with 645 additions and 342 deletions

View File

@ -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<br/>统一 Task Envelope"]

View File

@ -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)
为准。

View File

@ -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

View File

@ -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 启动
非目标:

View File

@ -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"]

View File

@ -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<void> _ensureSettingsFocused(WidgetTester tester) async {
final activeSettings = find.byKey(
const ValueKey<String>('assistant-focus-active-title-settings'),
);
if (activeSettings.evaluate().isNotEmpty) {
return;
}
final addSettingsChip = find.byKey(
const ValueKey<String>('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<String>('assistant-focus-active-title-settings'),
),
find.byKey(TestKeys.settingsIntegrationsTab),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.tap(find.byKey(const ValueKey<String>('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);
});
}

View File

@ -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<void> _ensureSettingsFocused(WidgetTester tester) async {
final activeSettings = find.byKey(
const ValueKey<String>('assistant-focus-active-title-settings'),
);
if (activeSettings.evaluate().isNotEmpty) {
return;
}
final addSettingsChip = find.byKey(
const ValueKey<String>('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<String>('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);

View File

@ -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<TextField>(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,

View File

@ -109,7 +109,14 @@ Future<void> 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);

View File

@ -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,

View File

@ -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<List<AssistantThreadSkillEntry>>
scanSingleAgentWorkspaceSkillEntriesInternal(String sessionKey) {
final workspacePath = assistantWorkspacePathForSession(sessionKey);
if (assistantWorkspaceKindForSession(sessionKey) !=
WorkspaceRefKind.localPath) {
return Future<List<AssistantThreadSkillEntry>>.value(
@ -552,7 +553,7 @@ extension AppControllerDesktopThreadStorage on AppController {
}
return scanSingleAgentSkillEntriesInternal(
AppController.defaultSingleAgentWorkspaceSkillScanRootsInternal,
workspacePath: assistantWorkspacePathForSession(sessionKey),
workspacePath: workspacePath,
);
}

View File

@ -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.',
);
}

View File

@ -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;
}

View File

@ -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<Map<String, String>> _loadEnvFile({
Map<String, String> _loadEnvFile({
String? workspacePathHint,
String? cliPathHint,
Directory? workspaceRoot,
Directory? openClawRoot,
}) async {
}) {
final candidateDirectories = <Directory>{
Directory.current,
..._ancestorDirectories(Directory.current),
@ -199,11 +199,11 @@ Future<Map<String, String>> _loadEnvFile({
.toList(growable: false);
for (final file in candidates) {
if (!await file.exists()) {
if (!file.existsSync()) {
continue;
}
final values = <String, String>{};
for (final line in await file.readAsLines()) {
for (final line in file.readAsLinesSync()) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) {
continue;

View File

@ -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<void> _waitForText(
WidgetTester tester,
@ -18,7 +21,7 @@ Future<void> _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 <String>['codex-chat'],
selectedModels: const <String>['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>{SingleAgentProvider.opencode},
raw: <String, dynamic>{},
),
result: const GoTaskServiceResult(
success: true,
message: 'CODEX_REPLY',
turnId: 'turn-1',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: 'codex-chat',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await controller.settingsController.saveAiGatewayApiKey('test-key');
final noopMultiAgentMountManager =
NoopMultiAgentMountManagerInternal();
final controller = await createControllerWithThreadRecordsInternal(
tester: tester,
records: <TaskThread>[
TaskThread(
threadId: 'main',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: '',
displayPath: '',
writable: true,
).copyWith(
workspacePath: workspaceDirectory.path,
displayPath: workspaceDirectory.path,
),
messages: const <GatewayChatMessage>[],
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 <SingleAgentProvider>[],
singleAgentSharedSkillScanRootOverrides: const <String>[],
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>[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<String>('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<String>('assistant-composer-input-area')),
'hello codex',
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-submit-button')),
find.byKey(const ValueKey<String>('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<void> close() async {
await _server.close(force: true);
}
Future<void> _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 = <String, dynamic>{
'id': 'chatcmpl-test',
'choices': <Map<String, dynamic>>[
<String, dynamic>{
'index': 0,
'delta': <String, dynamic>{'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();
}
}
}

View File

@ -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(<String, Object>{});
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);

View File

@ -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<TaskThread> initialTaskThreads = const <TaskThread>[],
}) : _settingsSnapshot = initialSettingsSnapshot,
_taskThreads = List<TaskThread>.from(initialTaskThreads),
super(enableSecureStorage: false);
SettingsSnapshot _settingsSnapshot;
List<TaskThread> _taskThreads;
Map<String, String> _secretValueByRef = <String, String>{};
Map<String, dynamic> _supportJsonByPath = <String, dynamic>{};
LocalDeviceIdentity? _deviceIdentity;
@override
Future<void> initialize() async {}
@override
Future<SettingsSnapshot> loadSettingsSnapshot() async {
return _settingsSnapshot;
}
@override
Future<SettingsSnapshot> reloadSettingsSnapshot() async {
return _settingsSnapshot;
}
@override
Future<SettingsSnapshotReloadResult> reloadSettingsSnapshotResult() async {
return SettingsSnapshotReloadResult(
snapshot: _settingsSnapshot,
status: SettingsSnapshotReloadStatus.applied,
);
}
@override
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
_settingsSnapshot = snapshot;
}
@override
Future<List<File>> resolvedSettingsFiles() async => const <File>[];
@override
Future<List<Directory>> resolvedSettingsWatchDirectories() async =>
const <Directory>[];
@override
Future<List<TaskThread>> loadTaskThreads() async {
return List<TaskThread>.from(_taskThreads);
}
@override
Future<void> saveTaskThreads(List<TaskThread> records) async {
_taskThreads = List<TaskThread>.from(records);
}
@override
Future<void> clearAssistantLocalState() async {
_settingsSnapshot = _settingsSnapshot.copyWith(
assistantCustomTaskTitles: const <String, String>{},
assistantArchivedTaskKeys: const <String>[],
assistantLastSessionKey: '',
);
_taskThreads = const <TaskThread>[];
}
@override
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
const <SecretAuditEntry>[];
@override
Future<void> appendAudit(SecretAuditEntry entry) async {}
@override
Future<Map<String, String>> loadSecureRefs() async =>
Map<String, String>.unmodifiable(_secretValueByRef);
@override
Future<Map<String, dynamic>?> loadSupportJson(String relativePath) async {
final payload = _supportJsonByPath[relativePath.trim()];
return payload is Map<String, dynamic> ? payload : null;
}
@override
Future<void> saveSupportJson(
String relativePath,
Map<String, dynamic> payload,
) async {
_supportJsonByPath = <String, dynamic>{
..._supportJsonByPath,
relativePath.trim(): Map<String, dynamic>.from(payload),
};
}
@override
Future<AccountSyncState?> loadAccountSyncState() async => null;
@override
Future<void> saveAccountSyncState(AccountSyncState value) async {}
@override
Future<void> clearAccountSyncState() async {}
@override
Future<AccountRemoteProfile?> loadAccountProfile() async => null;
@override
Future<void> saveAccountProfile(AccountRemoteProfile value) async {}
@override
Future<void> clearAccountProfile() async {}
@override
Future<String?> loadAccountManagedSecret({required String target}) async =>
null;
@override
Future<void> saveAccountManagedSecret({
required String target,
required String value,
}) async {}
@override
Future<void> clearAccountManagedSecret({required String target}) async {}
@override
Future<void> clearAccountManagedSecrets() async {}
@override
Future<LocalDeviceIdentity?> loadDeviceIdentity() async => _deviceIdentity;
@override
Future<void> saveDeviceIdentity(LocalDeviceIdentity identity) async {
_deviceIdentity = identity;
}
@override
Future<String?> loadDeviceToken({
required String deviceId,
required String role,
}) async =>
null;
@override
Future<void> saveDeviceToken({
required String deviceId,
required String role,
required String token,
}) async {}
@override
Future<void> clearDeviceToken({
required String deviceId,
required String role,
}) async {}
@override
Future<String?> loadGatewayToken({int? profileIndex}) async => null;
@override
Future<void> saveGatewayToken(String value, {int? profileIndex}) async {}
@override
Future<void> clearGatewayToken({int? profileIndex}) async {}
@override
Future<String?> loadGatewayPassword({int? profileIndex}) async => null;
@override
Future<void> saveGatewayPassword(String value, {int? profileIndex}) async {}
@override
Future<void> clearGatewayPassword({int? profileIndex}) async {}
@override
Future<String?> loadOllamaCloudApiKey() async => null;
@override
Future<void> saveOllamaCloudApiKey(String value) async {}
@override
Future<String?> loadVaultToken() async => null;
@override
Future<void> saveVaultToken(String value) async {}
@override
Future<String?> loadAiGatewayApiKey() async =>
_getSecretValue('ai_gateway_api_key');
@override
Future<void> saveAiGatewayApiKey(String value) async {
_setSecretValue('ai_gateway_api_key', value);
}
@override
Future<void> clearAiGatewayApiKey() async {
_clearSecretValue('ai_gateway_api_key');
}
@override
Future<String?> loadAccountSessionToken() async => null;
@override
Future<void> saveAccountSessionToken(String value) async {}
@override
Future<void> clearAccountSessionToken() async {}
@override
Future<int> loadAccountSessionExpiresAtMs() async => 0;
@override
Future<void> saveAccountSessionExpiresAtMs(int value) async {}
@override
Future<void> clearAccountSessionExpiresAtMs() async {}
@override
Future<String?> loadAccountSessionUserId() async => null;
@override
Future<void> saveAccountSessionUserId(String value) async {}
@override
Future<void> clearAccountSessionUserId() async {}
@override
Future<String?> loadAccountSessionIdentifier() async => null;
@override
Future<void> saveAccountSessionIdentifier(String value) async {}
@override
Future<void> clearAccountSessionIdentifier() async {}
@override
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
@override
Future<void> saveAccountSessionSummary(AccountSessionSummary value) async {}
@override
Future<void> clearAccountSessionSummary() async {}
@override
Future<String?> loadSecretValueByRef(String refName) async =>
_getSecretValue(refName);
@override
Future<void> saveSecretValueByRef(String refName, String value) async {
_setSecretValue(refName, value);
}
@override
Future<void> 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 = <String, String>{
..._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 = <String, String>{
for (final entry in _secretValueByRef.entries)
if (entry.key != normalizedRef) entry.key: entry.value,
};
}
}
class NoopMultiAgentMountManagerInternal extends MultiAgentMountManager {
NoopMultiAgentMountManagerInternal() : super();
@override
Future<MultiAgentConfig> 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>[
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 <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['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<AppController> createControllerWithThreadRecordsInternal({
WidgetTester? tester,
required List<TaskThread> records,
bool useFakeGatewayRuntime = false,
AssistantExecutionTarget assistantExecutionTargetOverride =
AssistantExecutionTarget.singleAgent,
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
GoTaskServiceClient? goTaskServiceClient,
MultiAgentMountManager? multiAgentMountManager,
List<String>? singleAgentSharedSkillScanRootOverrides,
bool disableGatewayProfileEndpoints = false,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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<AppController> 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 <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['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<AppController> 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(<String, Object>{});
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(<String, Object>{});
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),
),

View File

@ -19,7 +19,7 @@ Future<void> _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));
}

View File

@ -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<String>(
'assistant-task-item-main',

View File

@ -36,7 +36,7 @@ void main() {
AssistantExecutionTarget.singleAgent,
);
expect(controller.currentAssistantConnectionState.isSingleAgent, isTrue);
expect(controller.assistantConnectionStatusLabel, 'ACP Server');
expect(controller.assistantConnectionStatusLabel, 'ACP Server Local');
},
);

View File

@ -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');
},
);
});

View File

@ -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。',

View File

@ -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),

View File

@ -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<AppController> createTestController(
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
SettingsSnapshot? initialSettingsSnapshot,
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
GoTaskServiceClient? goTaskServiceClient,
List<String>? singleAgentSharedSkillScanRootOverrides,
bool settle = true,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final testRoot =
@ -63,6 +68,11 @@ Future<AppController> 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<AppController> 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;
}

View File

@ -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) {