chore: checkpoint current workspace changes
This commit is contained in:
parent
34e1e1250e
commit
69a339e91d
@ -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"]
|
||||
|
||||
@ -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)
|
||||
为准。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 启动
|
||||
|
||||
非目标:
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -36,7 +36,7 @@ void main() {
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(controller.currentAssistantConnectionState.isSingleAgent, isTrue);
|
||||
expect(controller.assistantConnectionStatusLabel, 'ACP Server');
|
||||
expect(controller.assistantConnectionStatusLabel, 'ACP Server Local');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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。',
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user