Merge branch 'release/v1.1.4'
# Conflicts: # test/features/assistant/assistant_page_session_binding_test.dart
This commit is contained in:
commit
6d37812c10
@ -7,6 +7,7 @@
|
||||
- [核心功能集成测试手动 Case](./core-integration-manual-cases.md)
|
||||
- [AI 安全演进内容生成场景测试用例](./ai-security-evolution-content-scenario/README.md)
|
||||
- [云原生 Service Mesh 网络科普视频调研场景测试用例](./service-mesh-evolution-video-scenario/README.md)
|
||||
- [OpenClaw Gateway 5 并发 E2E 回归场景](./openclaw-gateway-e2e-regression/README.md)
|
||||
|
||||
## 配套文档
|
||||
|
||||
|
||||
@ -80,6 +80,46 @@
|
||||
- 测试连接结果
|
||||
- 是否需要本机服务日志人工对照
|
||||
|
||||
### `MANUAL-ACP-004` 账号同步返回 `bridge_auth_token_unavailable`
|
||||
|
||||
- 前置条件
|
||||
- 已登录 `svc.plus` 账号
|
||||
- 账户侧能正常拉起 session
|
||||
- 本地 App 可访问 `https://accounts.svc.plus`
|
||||
- 典型现象
|
||||
- 设置页顶部与账号面板显示 `xworkmate-bridge 连接失败`
|
||||
- 账号同步状态为 `失败`
|
||||
- 同步说明显示 `bridge auth token is unavailable`
|
||||
- 详情信息里可见 `bridge_auth_token_unavailable`
|
||||
- 排查步骤
|
||||
1. 在 App 中触发账号同步或重新进入设置页
|
||||
2. 直接请求 `GET /api/auth/xworkmate/profile/sync`
|
||||
3. 确认是否返回 `409`
|
||||
4. 检查本地 secure storage 中是否存在 `xworkmate.account.managed.bridge.auth_token`
|
||||
5. 检查 `xworkmate-app` 的 `accountSyncState` 是否被写成 `blocked`
|
||||
6. 回到 `accounts.svc.plus` 服务侧确认 shared bridge token 是否已注入 Vault 并可被读取
|
||||
- 期望结论
|
||||
- 若接口返回 `409 bridge_auth_token_unavailable`,问题不在 App 的 bridge 连接逻辑
|
||||
- 根因在 `accounts.svc.plus` 的 shared XWorkmate bridge token 供给链路
|
||||
- `BRIDGE_AUTH_TOKEN` 需要由 accounts 服务通过 Vault 读取并下发
|
||||
- 关联同步规则
|
||||
- 任务 workspace 需要递归同步全部子目录和文件
|
||||
- `dist/账户与身份安全演进史-GPT混排最终版.pdf` 这类深层产物应能回传到当前线程 artifact
|
||||
- 如 skill 或任务已有忽略清单,则中间产物可按清单排除同步,常见对象包括草稿、临时文件、版本号中间稿和可重复生成缓存
|
||||
- 忽略清单建议以约定文件形式维护,例如 `.ignore.md`
|
||||
- 恢复条件
|
||||
- `GET /api/auth/xworkmate/profile/sync` 返回 `200`
|
||||
- 响应包含 `BRIDGE_SERVER_URL` 和 `BRIDGE_AUTH_TOKEN`
|
||||
- App 重新同步后,`accountSyncState.syncState` 变为 `ready`
|
||||
- 本地 managed secret `bridge.auth_token` 被写入
|
||||
- 建议记录项
|
||||
- 登录账号
|
||||
- `profile/sync` HTTP 状态码
|
||||
- 响应错误码或成功字段
|
||||
- 本地 `accountSyncState.syncState`
|
||||
- `tokenConfigured.bridge` 是否为 `true`
|
||||
- 是否已在 `accounts.svc.plus` 侧确认 Vault / bootstrap 配置
|
||||
|
||||
## 3. 本地执行型任务线程
|
||||
|
||||
### `MANUAL-LOCAL-001` `powerpoint-pptx`
|
||||
|
||||
140
docs/cases/openclaw-gateway-e2e-regression/README.md
Normal file
140
docs/cases/openclaw-gateway-e2e-regression/README.md
Normal file
@ -0,0 +1,140 @@
|
||||
# OpenClaw Gateway 5 并发 E2E 回归场景
|
||||
|
||||
这个 case 固化 5 个真实 OpenClaw Gateway 提示词,用于验证 XWorkmate App -> XWorkmate Bridge -> OpenClaw Gateway 的 5 并发稳定性、任务隔离和 artifact 同步。
|
||||
|
||||
## 覆盖目标
|
||||
|
||||
- 连续出图:7 张连续风格 PNG。
|
||||
- 模板出图:参考附件模板生成 7 张连续 PNG。
|
||||
- PDF:拆章节、逐章生成图、汇总排版并输出 PDF。
|
||||
- 视频:围绕同一安全演进主线制作测试视频。
|
||||
- 视频流水线:拆章节、逐章调用 Codex/GPT Images、汇总排版并制作视频。
|
||||
|
||||
## 自动化落点
|
||||
|
||||
| 仓库 | 文件 | 覆盖点 |
|
||||
| --- | --- | --- |
|
||||
| `xworkmate-bridge` | `internal/acp/web_contract_test.go` | `TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases` 通过 HTTP SSE 同时提交 5 个 OpenClaw Gateway 请求,断言不出现 queued、invalid handshake、socket closed、ACP_HTTP_CONNECTION_CLOSED、GATEWAY_CONNECT_FAILED。 |
|
||||
| `xworkmate-app` | `test/runtime/assistant_execution_target_test.dart` | `OpenClaw gateway admits five representative E2E tasks without queueing` 断言 App 侧 5 个代表任务同时进入 running,复用各自 session/thread,不进入 queued。 |
|
||||
| `openclaw-multi-session-plugins` | `src/exportArtifacts.test.ts` | 同线程 `assets/images/**/*.png`、manifest、视频/PDF 交付物能被 export 到当前 task artifact scope,不串到旧线程或旧 run。 |
|
||||
|
||||
## 5 个提示词
|
||||
|
||||
以下提示词按原始 E2E 输入记录,作为长期回归 case 的 canonical prompt。
|
||||
|
||||
### `OPENCLAW-E2E-001` 连续出图
|
||||
|
||||
```text
|
||||
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进
|
||||
制作 使用codex 制作连续制作 7张的一些列图片
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
- 任务进入 running,不因为 5 并发停在 pending/queued。
|
||||
- 输出 7 张独立 PNG,不合并成一张总览图。
|
||||
- artifact 区显示当前任务本轮导出的 PNG 和 manifest。
|
||||
|
||||
### `OPENCLAW-E2E-002` 模板出图
|
||||
|
||||
```text
|
||||
参考附件模版制作 ,围绕
|
||||
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进
|
||||
连续制作 7张的一些列图片
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
- 任务复用当前线程 workspace 和附件上下文。
|
||||
- 7 张图片保持模板风格一致。
|
||||
- 当前任务 artifact 不展示旧线程文件。
|
||||
|
||||
### `OPENCLAW-E2E-003` PDF
|
||||
|
||||
```text
|
||||
拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF
|
||||
|
||||
右侧 artifact栏 显示的陈旧文件
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
- 每章图片素材和最终 PDF 归属当前 task scope。
|
||||
- PDF 或相关素材出现在当前任务 artifact 区。
|
||||
- 回归缺陷点:右侧 artifact 栏不能显示其他 run 或历史 workspace 的陈旧文件。
|
||||
- 如果 OpenClaw 没有实际导出文件,App 显示 no exported artifacts,而不是旧文件。
|
||||
|
||||
### `OPENCLAW-E2E-004` 视频
|
||||
|
||||
```text
|
||||
围绕
|
||||
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下
|
||||
测试制作视频
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
- 视频任务在 5 并发下不触发 `GATEWAY_CONNECT_FAILED: SOCKET_CLOSED`。
|
||||
- 输出视频帧、配置或 MP4 时,artifact 只属于当前任务。
|
||||
- 失败时释放 active slot 并继续 drain 后续任务。
|
||||
|
||||
### `OPENCLAW-E2E-005` 视频流水线
|
||||
|
||||
```text
|
||||
围绕
|
||||
|
||||
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进
|
||||
|
||||
拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
- 图片、manifest、视频配置、MP4/ffprobe 等产物按当前 run 隔离。
|
||||
- Bridge 和 OpenClaw Gateway 只建立稳定连接,不重复并发握手。
|
||||
- 不出现 `invalid handshake: first request must be connect`、`SOCKET_CLOSED`、`ACP_HTTP_CONNECTION_CLOSED`。
|
||||
|
||||
## 手动验收步骤
|
||||
|
||||
前置条件:
|
||||
|
||||
- `/Applications/XWorkmate.app` 已安装当前 release。
|
||||
- 已登录并同步 managed bridge。
|
||||
- 任务模式选择 `Gateway`,provider 选择 `OpenClaw`。
|
||||
- `xworkmate-bridge.svc.plus` 和远端 OpenClaw Gateway 可用。
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 新建 5 个任务线程,分别输入上面的 5 个提示词。
|
||||
2. 在短时间内连续提交,保持 `maxActive = 5`、`maxQueued = 20`。
|
||||
3. 观察左侧任务列表,5 个任务应进入 running 或完成态,不应全部 pending。
|
||||
4. 逐个打开任务,检查中心消息和右侧 artifact 区。
|
||||
5. 对任意 running 任务继续提交一句补充要求,确认续聊仍落在当前任务而不是新 draft。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 5 个任务不出现全等待不运行。
|
||||
- 不出现 `invalid handshake: first request must be connect`。
|
||||
- 不出现 `GATEWAY_CONNECT_FAILED: SOCKET_CLOSED: socket closed`。
|
||||
- 不出现 `ACP_HTTP_CONNECTION_CLOSED`。
|
||||
- 当前任务没有 artifact 时显示明确空态,不显示旧 run 文件。
|
||||
- 当前任务生成 PNG/PDF/视频文件时,右侧 artifact 自动同步并只显示当前任务本轮文件。
|
||||
|
||||
## 回归命令
|
||||
|
||||
```bash
|
||||
# xworkmate-bridge
|
||||
go test ./...
|
||||
|
||||
# openclaw-multi-session-plugins
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
pnpm pack:check
|
||||
|
||||
# xworkmate-app
|
||||
flutter analyze
|
||||
flutter test test/runtime/assistant_execution_target_test.dart
|
||||
flutter test test/runtime/gateway_acp_client_auth_test.dart
|
||||
flutter test test/runtime/desktop_thread_artifact_service_test.dart
|
||||
flutter test
|
||||
```
|
||||
@ -29,8 +29,9 @@ Last Updated: 2026-04-22
|
||||
两份脚本都依赖以下环境变量:
|
||||
|
||||
- `REVIEW_ACCOUNT_LOGIN_PASSWORD`
|
||||
- `BRIDGE_AUTH_TOKEN`
|
||||
- 可选 `BRIDGE_AUTH_TOKEN`,未提供时使用 profile sync 返回的 token
|
||||
- 可选 `BRIDGE_SERVER_URL`
|
||||
- 可选 `BRIDGE_SERVER_URLS`,用于接口脚本同时验证多个 bridge host
|
||||
- 可选 `REVIEW_ACCOUNT_BASE_URL`
|
||||
|
||||
推荐直接在命令前临时注入:
|
||||
@ -42,6 +43,14 @@ BRIDGE_SERVER_URL='https://xworkmate-bridge.svc.plus' \
|
||||
bash scripts/ci/verify_api_interface_contract.sh
|
||||
```
|
||||
|
||||
双入口验证示例:
|
||||
|
||||
```bash
|
||||
REVIEW_ACCOUNT_LOGIN_PASSWORD='***REMOVED-CREDENTIAL***' \
|
||||
BRIDGE_SERVER_URLS='https://xworkmate-bridge.svc.plus,https://cn-xworkmate-bridge.svc.plus' \
|
||||
bash scripts/ci/verify_api_interface_contract.sh
|
||||
```
|
||||
|
||||
## 3. 默认校验入口
|
||||
|
||||
推荐使用 `Makefile` 目标:
|
||||
@ -64,6 +73,7 @@ make check-api-external
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/auth/session`
|
||||
- `GET /api/auth/xworkmate/profile/sync`
|
||||
- `GET /api/ping`
|
||||
- `POST /acp/rpc` with `acp.capabilities`
|
||||
- `POST /acp/rpc` with `xworkmate.routing.resolve`
|
||||
|
||||
|
||||
@ -604,12 +604,14 @@ class AppController extends ChangeNotifier {
|
||||
|
||||
Future<void> sendChatMessage(
|
||||
String message, {
|
||||
String? sessionKey,
|
||||
String thinking = 'off',
|
||||
List<GatewayChatAttachmentPayload> attachments = const [],
|
||||
List<CollaborationAttachment> localAttachments = const [],
|
||||
List<String> selectedSkillLabels = const [],
|
||||
}) => AppControllerDesktopThreadActions(this).sendChatMessage(
|
||||
message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
attachments: attachments,
|
||||
localAttachments: localAttachments,
|
||||
|
||||
@ -777,7 +777,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
var wroteArtifact = false;
|
||||
var failedArtifact = false;
|
||||
var skippedArtifact = false;
|
||||
final currentTaskArtifactRelativePaths = <String>[];
|
||||
for (final artifact in artifacts) {
|
||||
final relativePath = _sanitizeArtifactRelativePathInternal(
|
||||
artifact.relativePath,
|
||||
@ -792,7 +791,13 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}
|
||||
final bytes = bytesResult.bytes;
|
||||
if (bytes == null) {
|
||||
skippedArtifact = true;
|
||||
final existingArtifactPaths =
|
||||
await _existingWorkspaceArtifactPathsInternal(root, relativePath);
|
||||
if (existingArtifactPaths.isEmpty) {
|
||||
skippedArtifact = true;
|
||||
continue;
|
||||
}
|
||||
wroteArtifact = true;
|
||||
continue;
|
||||
}
|
||||
final target = await _nextArtifactTargetFileInternal(root, relativePath);
|
||||
@ -807,14 +812,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
continue;
|
||||
}
|
||||
wroteArtifact = true;
|
||||
final writtenRelativePath =
|
||||
DesktopThreadArtifactService.relativePathInternal(
|
||||
root.path,
|
||||
target.path,
|
||||
);
|
||||
if (writtenRelativePath != null && writtenRelativePath.isNotEmpty) {
|
||||
currentTaskArtifactRelativePaths.add(writtenRelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
final syncStatus = wroteArtifact
|
||||
@ -822,6 +819,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
: failedArtifact
|
||||
? 'download-failed'
|
||||
: 'no-artifacts';
|
||||
final currentTaskArtifactRelativePaths = wroteArtifact
|
||||
? await _collectWorkspaceArtifactRelativePathsInternal(root)
|
||||
: const <String>[];
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
lastArtifactSyncAtMs: syncedAtMs,
|
||||
@ -1016,6 +1016,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}
|
||||
|
||||
Uri? resolveBridgeAcpEndpointInternal() {
|
||||
final selfHosted = settingsControllerInternal
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted;
|
||||
final selfHostedUrl = selfHosted.serverUrl.trim();
|
||||
if (selfHosted.isConfigured && selfHostedUrl.isNotEmpty) {
|
||||
final uri = Uri.tryParse(selfHostedUrl);
|
||||
if (uri != null && uri.hasScheme && uri.host.trim().isNotEmpty) {
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
}
|
||||
|
||||
final uri = Uri.parse(kManagedBridgeServerUrl);
|
||||
return uri.replace(query: null, fragment: null);
|
||||
}
|
||||
@ -1029,11 +1041,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
if (bridgeEndpoint == null) {
|
||||
return false;
|
||||
}
|
||||
final selfHosted = settingsControllerInternal
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted;
|
||||
if (selfHosted.isConfigured) {
|
||||
return true;
|
||||
}
|
||||
final accountSyncState = settingsControllerInternal.accountSyncState;
|
||||
if (settingsControllerInternal.accountSignedIn &&
|
||||
accountSyncState?.syncState.trim().toLowerCase() == 'ready' &&
|
||||
accountSyncState?.tokenConfigured.bridge == true) {
|
||||
return true;
|
||||
}
|
||||
if (settingsControllerInternal.accountSignedIn) {
|
||||
return false;
|
||||
}
|
||||
final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN');
|
||||
return envToken != null && envToken.isNotEmpty;
|
||||
}
|
||||
@ -1072,6 +1095,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
normalizedHost == bridgeHost &&
|
||||
(bridgePort <= 0 || endpoint.port == bridgePort);
|
||||
if (matchesBridgeEndpoint) {
|
||||
final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal();
|
||||
if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) {
|
||||
return manualBridgeToken;
|
||||
}
|
||||
final bridgeToken = await _resolveManagedBridgeAuthTokenInternal();
|
||||
if (bridgeToken != null && bridgeToken.isNotEmpty) {
|
||||
return bridgeToken;
|
||||
@ -1090,6 +1117,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return null;
|
||||
}
|
||||
|
||||
final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal();
|
||||
if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) {
|
||||
return _normalizeAuthorizationHeaderInternal(manualBridgeToken);
|
||||
}
|
||||
final bridgeToken = await _resolveManagedBridgeAuthTokenInternal();
|
||||
if (bridgeToken != null && bridgeToken.isNotEmpty) {
|
||||
return _normalizeAuthorizationHeaderInternal(bridgeToken);
|
||||
@ -1097,15 +1128,37 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String?> _resolveManualBridgeAuthTokenInternal() async {
|
||||
final selfHosted = settingsControllerInternal
|
||||
.snapshot
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted;
|
||||
if (!selfHosted.isConfigured) {
|
||||
return null;
|
||||
}
|
||||
final passwordRef = selfHosted.passwordRef.trim();
|
||||
if (passwordRef.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final token = (await storeInternal.loadSecretValueByRef(
|
||||
passwordRef,
|
||||
))?.trim();
|
||||
return token?.isNotEmpty == true ? token : null;
|
||||
}
|
||||
|
||||
Future<String?> _resolveManagedBridgeAuthTokenInternal() async {
|
||||
final accountSyncState = settingsControllerInternal.accountSyncState;
|
||||
if (settingsControllerInternal.accountSignedIn &&
|
||||
accountSyncState?.syncState.trim().toLowerCase() == 'ready' &&
|
||||
accountSyncState?.tokenConfigured.bridge == true) {
|
||||
final bridgeToken = (await storeInternal.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
))?.trim();
|
||||
return bridgeToken?.isNotEmpty == true ? bridgeToken : null;
|
||||
}
|
||||
if (settingsControllerInternal.accountSignedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN');
|
||||
return envToken?.isNotEmpty == true ? envToken : null;
|
||||
@ -1145,6 +1198,62 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
) => kGatewayRemoteProfileIndex;
|
||||
}
|
||||
|
||||
Future<List<String>> _existingWorkspaceArtifactPathsInternal(
|
||||
Directory root,
|
||||
String relativePath,
|
||||
) async {
|
||||
final targetPath = DesktopThreadArtifactService.resolveAbsolutePathInternal(
|
||||
root.path,
|
||||
relativePath,
|
||||
);
|
||||
final targetType = await FileSystemEntity.type(
|
||||
targetPath,
|
||||
followLinks: false,
|
||||
);
|
||||
if (targetType == FileSystemEntityType.file) {
|
||||
final resolvedRelativePath =
|
||||
DesktopThreadArtifactService.relativePathInternal(
|
||||
root.path,
|
||||
targetPath,
|
||||
);
|
||||
return resolvedRelativePath == null || resolvedRelativePath.isEmpty
|
||||
? const <String>[]
|
||||
: <String>[resolvedRelativePath];
|
||||
}
|
||||
if (targetType != FileSystemEntityType.directory) {
|
||||
return const <String>[];
|
||||
}
|
||||
final files = await DesktopThreadArtifactService().collectFilesInternal(
|
||||
Directory(targetPath),
|
||||
);
|
||||
final paths = <String>[];
|
||||
for (final file in files) {
|
||||
final resolvedRelativePath =
|
||||
DesktopThreadArtifactService.relativePathInternal(root.path, file.path);
|
||||
if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) {
|
||||
paths.add(resolvedRelativePath);
|
||||
}
|
||||
}
|
||||
paths.sort();
|
||||
return paths;
|
||||
}
|
||||
|
||||
Future<List<String>> _collectWorkspaceArtifactRelativePathsInternal(
|
||||
Directory root,
|
||||
) async {
|
||||
final files = await DesktopThreadArtifactService().collectFilesInternal(root);
|
||||
final paths = <String>[];
|
||||
for (final file in files) {
|
||||
final resolvedRelativePath =
|
||||
DesktopThreadArtifactService.relativePathInternal(root.path, file.path);
|
||||
if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) {
|
||||
paths.add(resolvedRelativePath);
|
||||
}
|
||||
}
|
||||
paths.sort();
|
||||
return paths;
|
||||
}
|
||||
|
||||
String _normalizeAuthorizationHeaderInternal(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
|
||||
@ -232,6 +232,7 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
|
||||
Future<void> sendChatMessage(
|
||||
String message, {
|
||||
String? sessionKey,
|
||||
String thinking = 'off',
|
||||
List<GatewayChatAttachmentPayload> attachments =
|
||||
const <GatewayChatAttachmentPayload>[],
|
||||
@ -239,20 +240,28 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
const <CollaborationAttachment>[],
|
||||
List<String> selectedSkillLabels = const <String>[],
|
||||
}) async {
|
||||
var sessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionsControllerInternal.currentSessionKey,
|
||||
var targetSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionKey ?? sessionsControllerInternal.currentSessionKey,
|
||||
);
|
||||
if (!isAppOwnedAssistantSessionKeyInternal(sessionKey)) {
|
||||
if (!isAppOwnedAssistantSessionKeyInternal(targetSessionKey)) {
|
||||
if (sessionKey != null && sessionKey.trim().isNotEmpty) {
|
||||
throw StateError(
|
||||
appText(
|
||||
'提交目标会话无效,请重新选择任务后提交。',
|
||||
'The submit target session is invalid. Select the task again before submitting.',
|
||||
),
|
||||
);
|
||||
}
|
||||
await ensureActiveAssistantThreadInternal();
|
||||
sessionKey = normalizedAssistantSessionKeyInternal(
|
||||
targetSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionsControllerInternal.currentSessionKey,
|
||||
);
|
||||
}
|
||||
final resumeSessionHint = shouldResumeGatewaySessionForNextSendInternal(
|
||||
sessionKey,
|
||||
targetSessionKey,
|
||||
);
|
||||
await dispatchGatewayChatTurnInternal(
|
||||
sessionKey: sessionKey,
|
||||
sessionKey: targetSessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
attachments: attachments,
|
||||
@ -452,9 +461,13 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
localAttachments,
|
||||
);
|
||||
final taskLoadClass = classifyGatewayTaskLoadInternal(message);
|
||||
final expectedArtifactExtensions =
|
||||
expectedGatewayArtifactExtensionsInternal(message);
|
||||
final taskMetadata = Map<String, dynamic>.unmodifiable(<String, dynamic>{
|
||||
...dispatch.metadata,
|
||||
'taskLoadClass': taskLoadClass,
|
||||
if (expectedArtifactExtensions.isNotEmpty)
|
||||
'expectedArtifactExtensions': expectedArtifactExtensions,
|
||||
});
|
||||
final executionWorkingDirectory = gatewayExecutionWorkingDirectoryInternal(
|
||||
target: currentTarget,
|
||||
@ -755,6 +768,42 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
return 'short_task';
|
||||
}
|
||||
|
||||
List<String> expectedGatewayArtifactExtensionsInternal(String requestText) {
|
||||
final normalized = requestText.trim().toLowerCase();
|
||||
final result = <String>[];
|
||||
|
||||
void add(String value) {
|
||||
final normalizedValue = value.trim().toLowerCase().replaceFirst(
|
||||
RegExp(r'^\.'),
|
||||
'',
|
||||
);
|
||||
if (normalizedValue.isEmpty || result.contains(normalizedValue)) {
|
||||
return;
|
||||
}
|
||||
result.add(normalizedValue);
|
||||
}
|
||||
|
||||
for (final match in RegExp(
|
||||
r'\.([a-z0-9]{2,5})\b',
|
||||
caseSensitive: false,
|
||||
).allMatches(normalized)) {
|
||||
add(match.group(1) ?? '');
|
||||
}
|
||||
for (final match in RegExp(
|
||||
r'\b([a-z0-9]{2,5})\s*(?:格式|文件|产物|artifact|file|output)',
|
||||
caseSensitive: false,
|
||||
).allMatches(normalized)) {
|
||||
add(match.group(1) ?? '');
|
||||
}
|
||||
for (final match in RegExp(
|
||||
r'(?:输出|导出|生成|制作)\s*([a-z0-9]{2,5})',
|
||||
caseSensitive: false,
|
||||
).allMatches(normalized)) {
|
||||
add(match.group(1) ?? '');
|
||||
}
|
||||
return List<String>.unmodifiable(result);
|
||||
}
|
||||
|
||||
bool usesOpenClawGatewayQueueInternal(
|
||||
AssistantExecutionTarget target,
|
||||
SingleAgentProvider provider,
|
||||
|
||||
@ -85,11 +85,6 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
if (rawPrompt.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (controller.assistantSessionHasPendingRun(
|
||||
controller.currentSessionKey,
|
||||
)) {
|
||||
await createNewThreadInternal();
|
||||
}
|
||||
final submittedSessionKey = controller.currentSessionKey;
|
||||
|
||||
final autoAgent = pickAutoAgentInternal(controller, rawPrompt);
|
||||
@ -134,10 +129,10 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
autoAgent?.name ?? conversationOwnerLabelInternal(controller);
|
||||
attachmentsInternal = const <ComposerAttachmentInternal>[];
|
||||
touchTaskSeedInternal(
|
||||
sessionKey: controller.currentSessionKey,
|
||||
sessionKey: submittedSessionKey,
|
||||
title:
|
||||
taskSeedsInternal[controller.currentSessionKey]?.title ??
|
||||
fallbackSessionTitleInternal(controller.currentSessionKey),
|
||||
taskSeedsInternal[submittedSessionKey]?.title ??
|
||||
fallbackSessionTitleInternal(submittedSessionKey),
|
||||
preview: rawPrompt,
|
||||
status: controller.hasAssistantPendingRun || connectionState.connected
|
||||
? 'running'
|
||||
@ -145,7 +140,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
owner: autoAgent?.name ?? conversationOwnerLabelInternal(controller),
|
||||
surface: 'Assistant',
|
||||
executionTarget: executionTarget,
|
||||
draft: controller.currentSessionKey.trim().startsWith('draft:'),
|
||||
draft: submittedSessionKey.trim().startsWith('draft:'),
|
||||
);
|
||||
});
|
||||
inputControllerInternal.clear();
|
||||
@ -153,6 +148,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
try {
|
||||
await controller.sendChatMessage(
|
||||
prompt,
|
||||
sessionKey: submittedSessionKey,
|
||||
thinking: thinkingLabelInternal,
|
||||
attachments: attachmentPayloads,
|
||||
localAttachments: submittedAttachments
|
||||
@ -171,7 +167,12 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
if (!mounted) {
|
||||
rethrow;
|
||||
}
|
||||
if (inputControllerInternal.text.trim().isEmpty) {
|
||||
if (!sessionKeysMatchInternal(
|
||||
widget.controller.currentSessionKey,
|
||||
submittedSessionKey,
|
||||
)) {
|
||||
composerDraftBySessionKeyInternal[submittedSessionKey] = rawPrompt;
|
||||
} else if (inputControllerInternal.text.trim().isEmpty) {
|
||||
inputControllerInternal.value = TextEditingValue(
|
||||
text: rawPrompt,
|
||||
selection: TextSelection.collapsed(offset: rawPrompt.length),
|
||||
|
||||
@ -104,38 +104,27 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
|
||||
required bool isManualBridge,
|
||||
bool refreshAfterSave = true,
|
||||
}) async {
|
||||
final bridgeConfig = settings.acpBridgeServerModeConfig;
|
||||
final nextBridgeConfig = bridgeConfig.copyWith(
|
||||
selfHosted: bridgeConfig.selfHosted.copyWith(
|
||||
serverUrl: bridgeUrlController.text.trim(),
|
||||
username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username,
|
||||
),
|
||||
);
|
||||
final nextEffective = widget.controller.settingsController
|
||||
.resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig);
|
||||
final nextSettings = settings.copyWith(
|
||||
accountBaseUrl: accountBaseUrlController.text.trim(),
|
||||
accountUsername: accountIdentifierController.text.trim(),
|
||||
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
|
||||
effective: nextEffective,
|
||||
),
|
||||
);
|
||||
if (isManualBridge && bridgeTokenController.text.isNotEmpty) {
|
||||
await widget.controller.settingsController.saveSecretValueByRef(
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
|
||||
bridgeTokenController.text,
|
||||
provider: 'Bridge',
|
||||
module: 'Manual',
|
||||
);
|
||||
}
|
||||
final nextSettings = await widget.controller.settingsController
|
||||
.buildSavedAccountProfileSettings(
|
||||
settings: settings,
|
||||
accountBaseUrl: accountBaseUrlController.text,
|
||||
accountIdentifier: accountIdentifierController.text,
|
||||
bridgeServerUrl: bridgeUrlController.text,
|
||||
bridgeToken: bridgeTokenController.text,
|
||||
isManualBridge: isManualBridge,
|
||||
);
|
||||
await widget.controller.saveSettings(
|
||||
nextSettings,
|
||||
refreshAfterSave: refreshAfterSave,
|
||||
refreshAfterSave: isManualBridge ? false : refreshAfterSave,
|
||||
);
|
||||
lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
|
||||
lastSavedAccountIdentifier = nextSettings.accountUsername;
|
||||
lastSavedBridgeUrl =
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
if (isManualBridge &&
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) {
|
||||
unawaited(refreshBridgeCapabilities());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginAccount(SettingsSnapshot settings) async {
|
||||
@ -245,16 +234,25 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => controller.navigateTo(WorkspaceDestination.assistant),
|
||||
onTap: () => controller.navigateTo(
|
||||
WorkspaceDestination.assistant,
|
||||
),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: palette.textSecondary),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
size: 16,
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
appText('返回对话主页', 'Back to Chat'),
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 16),
|
||||
style: TextStyle(
|
||||
color: palette.textSecondary,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -262,9 +260,8 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
appText('设置', 'Settings'),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (availableTabs.length > 1) ...[
|
||||
|
||||
@ -101,9 +101,6 @@ class _SettingsAccountPanelState extends State<SettingsAccountPanel>
|
||||
Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')),
|
||||
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
|
||||
],
|
||||
onTap: (index) {
|
||||
widget.onSaveAccountProfile(isManualBridge: index == 1);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
|
||||
@ -58,6 +58,17 @@ Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
|
||||
.decodeStream(response)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
if (response.statusCode == HttpStatus.unauthorized ||
|
||||
response.statusCode == HttpStatus.forbidden) {
|
||||
return const <String, dynamic>{
|
||||
'status': 'unauthorized',
|
||||
'message': 'Bridge authorization rejected',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
@ -209,38 +220,29 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SettingsSnapshot settings, {
|
||||
required bool isManualBridge,
|
||||
}) async {
|
||||
final bridgeConfig = settings.acpBridgeServerModeConfig;
|
||||
var nextBridgeConfig = bridgeConfig.copyWith(
|
||||
selfHosted: bridgeConfig.selfHosted.copyWith(
|
||||
serverUrl: _bridgeUrlController.text.trim(),
|
||||
username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username,
|
||||
),
|
||||
final nextSettings = await widget.controller.settingsController
|
||||
.buildSavedAccountProfileSettings(
|
||||
settings: settings,
|
||||
accountBaseUrl: _accountBaseUrlController.text,
|
||||
accountIdentifier: _accountIdentifierController.text,
|
||||
bridgeServerUrl: _bridgeUrlController.text,
|
||||
bridgeToken: _bridgeTokenController.text,
|
||||
isManualBridge: isManualBridge,
|
||||
);
|
||||
await widget.controller.saveSettings(
|
||||
nextSettings,
|
||||
refreshAfterSave: !isManualBridge,
|
||||
);
|
||||
|
||||
final nextEffective = widget.controller.settingsController
|
||||
.resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig);
|
||||
|
||||
final nextSettings = settings.copyWith(
|
||||
accountBaseUrl: _accountBaseUrlController.text.trim(),
|
||||
accountUsername: _accountIdentifierController.text.trim(),
|
||||
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
|
||||
effective: nextEffective,
|
||||
),
|
||||
);
|
||||
if (isManualBridge && _bridgeTokenController.text.isNotEmpty) {
|
||||
await widget.controller.settingsController.saveSecretValueByRef(
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
|
||||
_bridgeTokenController.text,
|
||||
provider: 'Bridge',
|
||||
module: 'Manual',
|
||||
);
|
||||
}
|
||||
await widget.controller.saveSettings(nextSettings);
|
||||
|
||||
_lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
|
||||
_lastSavedAccountIdentifier = nextSettings.accountUsername;
|
||||
_lastSavedBridgeUrl =
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
if (isManualBridge &&
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) {
|
||||
unawaited(_refreshBridgeCapabilities());
|
||||
await _refreshAboutSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loginAccount(SettingsSnapshot settings) async {
|
||||
@ -254,6 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
password: _accountPasswordController.text,
|
||||
);
|
||||
await _refreshBridgeCapabilities();
|
||||
await _verifyAccountBridgeRuntimeAccess();
|
||||
} finally {
|
||||
_accountPasswordController.clear();
|
||||
}
|
||||
@ -261,11 +264,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
Future<void> _syncAccount(SettingsSnapshot settings) async {
|
||||
await _persistAccountProfileSettings(settings, isManualBridge: false);
|
||||
await widget.controller.settingsController.syncAccountSettings(
|
||||
baseUrl: _accountBaseUrlController.text.trim(),
|
||||
);
|
||||
final result = await widget.controller.settingsController
|
||||
.syncAccountSettings(baseUrl: _accountBaseUrlController.text.trim());
|
||||
await _refreshBridgeCapabilities();
|
||||
await _refreshAboutSnapshot();
|
||||
if (result.state == 'ready') {
|
||||
await _verifyAccountBridgeRuntimeAccess();
|
||||
} else {
|
||||
await _refreshAboutSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyAccountMfa(SettingsSnapshot settings) async {
|
||||
@ -276,6 +282,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
code: _accountMfaCodeController.text.trim(),
|
||||
);
|
||||
await _refreshBridgeCapabilities();
|
||||
await _verifyAccountBridgeRuntimeAccess();
|
||||
} finally {
|
||||
_accountMfaCodeController.clear();
|
||||
}
|
||||
@ -337,13 +344,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<SettingsAboutSnapshot> _loadAboutSnapshot() async {
|
||||
final bridgeMetadata = await _loadBridgeMetadata();
|
||||
final bridgeEndpoint =
|
||||
widget.controller.resolveGatewayAcpEndpointInternal() ??
|
||||
Uri.parse(kManagedBridgeServerUrl);
|
||||
final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint);
|
||||
return SettingsAboutSnapshot(
|
||||
appVersion: kAppVersion,
|
||||
appBuildNumber: kAppBuildNumber,
|
||||
appBuildDate: kAppBuildDate,
|
||||
appCommit: kAppBuildCommit,
|
||||
bridgeEndpoint: kManagedBridgeServerUrl,
|
||||
bridgeEndpoint: bridgeEndpoint.toString(),
|
||||
bridgeStatus: _stringValue(bridgeMetadata['status']),
|
||||
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
|
||||
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
|
||||
@ -352,14 +362,69 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _loadBridgeMetadata() async {
|
||||
Future<Map<String, dynamic>> _loadBridgeMetadata(Uri bridgeEndpoint) async {
|
||||
return loadBridgeMetadataForSettingsAbout(
|
||||
bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl),
|
||||
bridgeEndpoint: bridgeEndpoint,
|
||||
authorizationResolver:
|
||||
widget.controller.resolveGatewayAcpAuthorizationHeaderInternal,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _verifyAccountBridgeRuntimeAccess() async {
|
||||
if (!widget.controller.settingsController.accountSignedIn) {
|
||||
await _refreshAboutSnapshot();
|
||||
return;
|
||||
}
|
||||
final bridgeEndpoint =
|
||||
widget.controller.resolveGatewayAcpEndpointInternal() ??
|
||||
Uri.parse(kManagedBridgeServerUrl);
|
||||
final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint);
|
||||
final status = _stringValue(bridgeMetadata['status']).toLowerCase();
|
||||
if (status == 'ok') {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aboutSnapshot = _aboutSnapshotFromMetadata(
|
||||
bridgeEndpoint,
|
||||
bridgeMetadata,
|
||||
);
|
||||
_aboutBusy = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status == 'unauthorized') {
|
||||
await widget.controller.settingsController
|
||||
.markAccountBridgeRuntimeUnavailable('Bridge authorization rejected');
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aboutSnapshot = _aboutSnapshotFromMetadata(
|
||||
bridgeEndpoint,
|
||||
bridgeMetadata,
|
||||
);
|
||||
_aboutBusy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SettingsAboutSnapshot _aboutSnapshotFromMetadata(
|
||||
Uri bridgeEndpoint,
|
||||
Map<String, dynamic> bridgeMetadata,
|
||||
) {
|
||||
return SettingsAboutSnapshot(
|
||||
appVersion: kAppVersion,
|
||||
appBuildNumber: kAppBuildNumber,
|
||||
appBuildDate: kAppBuildDate,
|
||||
appCommit: kAppBuildCommit,
|
||||
bridgeEndpoint: bridgeEndpoint.toString(),
|
||||
bridgeStatus: _stringValue(bridgeMetadata['status']),
|
||||
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
|
||||
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
|
||||
bridgeCommit: _stringValue(bridgeMetadata['commit']),
|
||||
bridgeImage: _stringValue(bridgeMetadata['image']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
|
||||
@ -60,7 +60,9 @@ class DesktopThreadArtifactService {
|
||||
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
|
||||
artifactRelativePaths,
|
||||
);
|
||||
final allFiles = await collectFilesInternal(root);
|
||||
final allFiles = taskArtifactPaths.isEmpty
|
||||
? const <File>[]
|
||||
: await collectFilesInternal(root);
|
||||
final fileEntries = await buildEntriesInternal(allFiles, normalizedRef);
|
||||
final taskFiles = taskArtifactPaths.isEmpty
|
||||
? const <File>[]
|
||||
@ -91,7 +93,9 @@ class DesktopThreadArtifactService {
|
||||
? 'No task artifacts recorded for this run.'
|
||||
: 'No current task artifacts found. Showing all files for this thread.'
|
||||
: '';
|
||||
final filesMessage = fileEntries.isEmpty
|
||||
final filesMessage = taskArtifactPaths.isEmpty
|
||||
? ''
|
||||
: fileEntries.isEmpty
|
||||
? 'No files found in the recorded working directory.'
|
||||
: '';
|
||||
final changesMessage = changes.isEmpty
|
||||
|
||||
@ -107,6 +107,10 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
Future<AccountSyncResult> syncAccountManagedSecrets({String baseUrl = ''}) =>
|
||||
syncAccountSettings(baseUrl: baseUrl);
|
||||
|
||||
Future<AccountSyncResult> markAccountBridgeRuntimeUnavailable(
|
||||
String message,
|
||||
) => markAccountBridgeRuntimeUnavailableInternal(this, message: message);
|
||||
|
||||
Future<void> logoutAccount() => logoutAccountSettingsInternal(this);
|
||||
|
||||
Future<void> cancelAccountMfaChallenge() =>
|
||||
@ -116,6 +120,23 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
required AcpBridgeServerModeConfig config,
|
||||
}) => resolveAcpBridgeServerEffectiveConfigInternal(this, config: config);
|
||||
|
||||
Future<SettingsSnapshot> buildSavedAccountProfileSettings({
|
||||
required SettingsSnapshot settings,
|
||||
required String accountBaseUrl,
|
||||
required String accountIdentifier,
|
||||
required String bridgeServerUrl,
|
||||
required String bridgeToken,
|
||||
required bool isManualBridge,
|
||||
}) => buildSavedAccountProfileSettingsInternal(
|
||||
this,
|
||||
settings: settings,
|
||||
accountBaseUrl: accountBaseUrl,
|
||||
accountIdentifier: accountIdentifier,
|
||||
bridgeServerUrl: bridgeServerUrl,
|
||||
bridgeToken: bridgeToken,
|
||||
isManualBridge: isManualBridge,
|
||||
);
|
||||
|
||||
List<SecretReferenceEntry> buildSecretReferences() {
|
||||
final entries = <SecretReferenceEntry>[
|
||||
...secureRefsInternal.entries.map(
|
||||
|
||||
@ -423,6 +423,24 @@ Future<void> logoutAccountSettingsInternal(
|
||||
}
|
||||
}
|
||||
|
||||
Future<AccountSyncResult> markAccountBridgeRuntimeUnavailableInternal(
|
||||
SettingsController controller, {
|
||||
required String message,
|
||||
}) async {
|
||||
final current = controller.accountSyncStateInternal;
|
||||
final nextState = (current ?? AccountSyncState.defaults()).copyWith(
|
||||
syncState: 'blocked',
|
||||
syncMessage: message,
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncError: message,
|
||||
profileScope: 'bridge',
|
||||
);
|
||||
await _persistAccountSyncStateInternal(controller, nextState);
|
||||
controller.accountStatusInternal = message;
|
||||
controller.notifyListeners();
|
||||
return AccountSyncResult(state: 'blocked', message: message);
|
||||
}
|
||||
|
||||
Future<void> cancelAccountMfaChallengeSettingsInternal(
|
||||
SettingsController controller,
|
||||
) async {
|
||||
@ -594,6 +612,47 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal(
|
||||
);
|
||||
}
|
||||
|
||||
Future<SettingsSnapshot> buildSavedAccountProfileSettingsInternal(
|
||||
SettingsController controller, {
|
||||
required SettingsSnapshot settings,
|
||||
required String accountBaseUrl,
|
||||
required String accountIdentifier,
|
||||
required String bridgeServerUrl,
|
||||
required String bridgeToken,
|
||||
required bool isManualBridge,
|
||||
}) async {
|
||||
final bridgeConfig = settings.acpBridgeServerModeConfig;
|
||||
final nextBridgeConfig = bridgeConfig.copyWith(
|
||||
selfHosted: isManualBridge
|
||||
? bridgeConfig.selfHosted.copyWith(
|
||||
serverUrl: bridgeServerUrl.trim(),
|
||||
username: 'admin',
|
||||
)
|
||||
: bridgeConfig.selfHosted,
|
||||
);
|
||||
final nextEffective = resolveAcpBridgeServerEffectiveConfigInternal(
|
||||
controller,
|
||||
config: nextBridgeConfig,
|
||||
);
|
||||
final nextSettings = settings.copyWith(
|
||||
accountBaseUrl: accountBaseUrl.trim(),
|
||||
accountUsername: accountIdentifier.trim(),
|
||||
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
|
||||
effective: nextEffective,
|
||||
),
|
||||
);
|
||||
final trimmedBridgeToken = bridgeToken.trim();
|
||||
if (isManualBridge && trimmedBridgeToken.isNotEmpty) {
|
||||
await controller.saveSecretValueByRef(
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
|
||||
trimmedBridgeToken,
|
||||
provider: 'Bridge',
|
||||
module: 'Manual',
|
||||
);
|
||||
}
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
int _parseExpiresAtMs(Object? value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
|
||||
@ -2,9 +2,9 @@ name: xworkmate
|
||||
description: "XWorkmate desktop-first AI workspace shell."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.1.3+1
|
||||
build-date: 2026-05-28
|
||||
build-id: 82d46f5
|
||||
version: 1.1.4
|
||||
build-date: 2026-05-30
|
||||
build-id: 94405c9
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
@ -5,6 +5,7 @@ ACCOUNTS_BASE_URL="${REVIEW_ACCOUNT_BASE_URL:-https://accounts.svc.plus}"
|
||||
REVIEW_ACCOUNT_LOGIN_NAME="${REVIEW_ACCOUNT_LOGIN_NAME:-review@svc.plus}"
|
||||
REVIEW_ACCOUNT_LOGIN_PASSWORD="${REVIEW_ACCOUNT_LOGIN_PASSWORD:-}"
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-}"
|
||||
BRIDGE_SERVER_URLS="${BRIDGE_SERVER_URLS:-}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}"
|
||||
|
||||
@ -181,10 +182,8 @@ if not bridge_token:
|
||||
raise SystemExit("sync response did not include BRIDGE_AUTH_TOKEN")
|
||||
PY
|
||||
|
||||
bridge_server_url="${BRIDGE_SERVER_URL}"
|
||||
if [[ -z "${bridge_server_url}" ]]; then
|
||||
bridge_server_url="$(
|
||||
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
|
||||
synced_bridge_server_url="$(
|
||||
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@ -194,9 +193,18 @@ if not bridge_url:
|
||||
raise SystemExit("sync response did not include BRIDGE_SERVER_URL")
|
||||
print(bridge_url.rstrip("/"))
|
||||
PY
|
||||
)"
|
||||
)"
|
||||
|
||||
bridge_server_urls=()
|
||||
if [[ -n "${BRIDGE_SERVER_URLS}" ]]; then
|
||||
while IFS= read -r candidate; do
|
||||
[[ -n "${candidate}" ]] && bridge_server_urls+=("$(normalize_url "${candidate}")")
|
||||
done < <(printf '%s\n' "${BRIDGE_SERVER_URLS}" | tr ',' '\n' | tr '[:space:]' '\n' | sed '/^$/d')
|
||||
elif [[ -n "${BRIDGE_SERVER_URL}" ]]; then
|
||||
bridge_server_urls+=("$(normalize_url "${BRIDGE_SERVER_URL}")")
|
||||
else
|
||||
bridge_server_urls+=("$(normalize_url "${synced_bridge_server_url}")")
|
||||
fi
|
||||
bridge_server_url="$(normalize_url "${bridge_server_url}")"
|
||||
|
||||
bridge_auth_token="${BRIDGE_AUTH_TOKEN}"
|
||||
if [[ -z "${bridge_auth_token}" ]]; then
|
||||
@ -214,13 +222,29 @@ PY
|
||||
)"
|
||||
fi
|
||||
|
||||
capabilities_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${capabilities_json}" python3 - <<'PY'
|
||||
verified_urls=()
|
||||
for bridge_server_url in "${bridge_server_urls[@]}"; do
|
||||
ping_json="$(
|
||||
json_get \
|
||||
"${bridge_server_url}/api/ping" \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${ping_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
payload = json.loads(os.environ["RESPONSE_JSON"])
|
||||
if payload.get("status") != "ok":
|
||||
raise SystemExit("bridge ping status is not ok")
|
||||
PY
|
||||
|
||||
capabilities_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${capabilities_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@ -232,10 +256,10 @@ if result.get("availableExecutionTargets") != ["agent", "gateway"]:
|
||||
raise SystemExit("unexpected availableExecutionTargets")
|
||||
PY
|
||||
|
||||
routing_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{
|
||||
routing_json="$(
|
||||
json_post \
|
||||
"${bridge_server_url}/acp/rpc" \
|
||||
'{
|
||||
"jsonrpc":"2.0",
|
||||
"id":"routing",
|
||||
"method":"xworkmate.routing.resolve",
|
||||
@ -254,9 +278,9 @@ routing_json="$(
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${routing_json}" python3 - <<'PY'
|
||||
-H "Authorization: Bearer ${bridge_auth_token}"
|
||||
)"
|
||||
RESPONSE_JSON="${routing_json}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@ -267,5 +291,7 @@ if not isinstance(result, dict):
|
||||
if result.get("resolvedProviderId") != "codex":
|
||||
raise SystemExit("unexpected resolvedProviderId")
|
||||
PY
|
||||
verified_urls+=("${bridge_server_url}")
|
||||
done
|
||||
|
||||
printf 'API interface contract verified via %s\n' "${bridge_server_url}"
|
||||
printf 'API interface contract verified via %s\n' "${verified_urls[*]}"
|
||||
|
||||
@ -208,8 +208,6 @@ payload = json.loads(os.environ["RESPONSE_JSON"])
|
||||
result = payload.get("result") or payload.get("payload") or {}
|
||||
if result.get("resolvedProviderId") != "codex":
|
||||
raise SystemExit("session.start did not resolve codex")
|
||||
if not str(result.get("error") or "").strip():
|
||||
raise SystemExit("session.start in this environment should expose downstream error details")
|
||||
PY
|
||||
|
||||
RESPONSE_JSON="${message_json}" python3 - <<'PY'
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_main.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_state_actions.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
@ -175,6 +178,58 @@ void main() {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
});
|
||||
|
||||
testWidgets('keeps follow-up submit bound to the running task', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final pageKey = GlobalKey<AssistantPageStateInternal>();
|
||||
|
||||
const sessionKey = 'running-task';
|
||||
await controller.sessionsController.switchSession(sessionKey);
|
||||
controller.aiGatewayPendingSessionKeysInternal.add(sessionKey);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Material(
|
||||
child: SizedBox(
|
||||
width: 1280,
|
||||
height: 760,
|
||||
child: AssistantPage(
|
||||
key: pageKey,
|
||||
controller: controller,
|
||||
showStandaloneTaskRail: false,
|
||||
onOpenDetail: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final state = pageKey.currentState!;
|
||||
state.inputControllerInternal.text = 'continue current task';
|
||||
|
||||
unawaited(
|
||||
state.submitPromptInternal().catchError((_) {
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.currentSessionKey, sessionKey);
|
||||
expect(
|
||||
controller.assistantSessions.map((session) => session.key),
|
||||
isNot(contains(startsWith('draft:'))),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpAssistantPage(
|
||||
|
||||
@ -5,6 +5,8 @@ import 'package:xworkmate/app/app_shell_desktop.dart';
|
||||
import 'package:xworkmate/features/mobile/mobile_settings_page.dart';
|
||||
import 'package:xworkmate/runtime/account_runtime_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_controllers.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
@ -239,6 +241,78 @@ void main() {
|
||||
);
|
||||
expect(find.text('mobile@svc.plus'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('manual bridge save updates mobile runtime configuration', (
|
||||
tester,
|
||||
) async {
|
||||
final store = _MemorySecureConfigStore();
|
||||
final controller = _NoopRefreshAppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(size: Size(390, 844)),
|
||||
child: Scaffold(body: MobileSettingsPage(controller: controller)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
|
||||
final urlField = find.byKey(
|
||||
const Key('mobile-settings-manual-bridge-url-field'),
|
||||
);
|
||||
await tester.ensureVisible(urlField);
|
||||
await tester.enterText(
|
||||
find.descendant(of: urlField, matching: find.byType(TextFormField)),
|
||||
'http://127.0.0.1:1',
|
||||
);
|
||||
final tokenField = find.byKey(
|
||||
const Key('mobile-settings-manual-bridge-token-field'),
|
||||
);
|
||||
await tester.enterText(
|
||||
find.descendant(of: tokenField, matching: find.byType(TextFormField)),
|
||||
'mobile-manual-token',
|
||||
);
|
||||
final saveButton = find.byKey(
|
||||
const Key('mobile-settings-manual-bridge-save-button'),
|
||||
);
|
||||
await tester.ensureVisible(saveButton);
|
||||
tester.widget<FilledButton>(saveButton).onPressed!();
|
||||
|
||||
for (
|
||||
var attempt = 0;
|
||||
attempt < 20 &&
|
||||
controller
|
||||
.settings
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted
|
||||
.serverUrl !=
|
||||
'http://127.0.0.1:1';
|
||||
attempt += 1
|
||||
) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
final bridgeConfig = controller.settings.acpBridgeServerModeConfig;
|
||||
expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1');
|
||||
expect(bridgeConfig.effective.source, 'bridge');
|
||||
expect(
|
||||
await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef),
|
||||
'mobile-manual-token',
|
||||
);
|
||||
expect(
|
||||
controller.resolveGatewayAcpEndpointInternal()?.toString(),
|
||||
'http://127.0.0.1:1',
|
||||
);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('http://127.0.0.1:1/acp/rpc'),
|
||||
),
|
||||
'mobile-manual-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -278,3 +352,63 @@ class _MobileFakeAccountRuntimeClient extends AccountRuntimeClient {
|
||||
return syncPayload;
|
||||
}
|
||||
}
|
||||
|
||||
class _NoopRefreshAppController extends AppController {
|
||||
_NoopRefreshAppController({required SecureConfigStore store})
|
||||
: super(environmentOverride: const <String, String>{}, store: store);
|
||||
|
||||
Future<void> refreshAcpCapabilitiesInternal({
|
||||
bool forceRefresh = false,
|
||||
bool persistMountTargets = false,
|
||||
}) async {}
|
||||
|
||||
Future<void> refreshSingleAgentCapabilitiesInternal({
|
||||
bool forceRefresh = false,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
class _MemorySecureConfigStore extends SecureConfigStore {
|
||||
_MemorySecureConfigStore() : super(enableSecureStorage: false);
|
||||
|
||||
SettingsSnapshot _settings = SettingsSnapshot.defaults();
|
||||
final Map<String, String> _secrets = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
|
||||
|
||||
@override
|
||||
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
|
||||
_settings = snapshot;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> loadSecureRefs() async => _secrets;
|
||||
|
||||
@override
|
||||
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
|
||||
const <SecretAuditEntry>[];
|
||||
|
||||
@override
|
||||
Future<void> appendAudit(SecretAuditEntry entry) async {}
|
||||
|
||||
@override
|
||||
Future<String?> loadSecretValueByRef(String refName) async =>
|
||||
_secrets[refName];
|
||||
|
||||
@override
|
||||
Future<void> saveSecretValueByRef(String refName, String value) async {
|
||||
_secrets[refName] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> loadAccountSessionToken() async => null;
|
||||
|
||||
@override
|
||||
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
|
||||
|
||||
@override
|
||||
Future<AccountSyncState?> loadAccountSyncState() async => null;
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ void main() {
|
||||
expect(metadata['buildDate'], '');
|
||||
});
|
||||
|
||||
test('returns unavailable when authorized bridge ping fails', () async {
|
||||
test('returns unauthorized when bridge rejects authorization', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async {
|
||||
await server.close(force: true);
|
||||
@ -126,7 +126,8 @@ void main() {
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
|
||||
expect(metadata['status'], 'unavailable');
|
||||
expect(metadata['status'], 'unauthorized');
|
||||
expect(metadata['message'], 'Bridge authorization rejected');
|
||||
expect(metadata['version'], '');
|
||||
expect(metadata['commit'], '');
|
||||
expect(metadata['image'], '');
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/settings/settings_account_panel.dart';
|
||||
import 'package:xworkmate/runtime/runtime_controllers.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
import 'package:xworkmate/widgets/surface_card.dart';
|
||||
|
||||
@ -114,6 +117,203 @@ void main() {
|
||||
expect(submittedPassword, 'typed-password');
|
||||
});
|
||||
|
||||
testWidgets('manual bridge save submits current field values', (
|
||||
tester,
|
||||
) async {
|
||||
final controllers = _TestControllers();
|
||||
addTearDown(controllers.dispose);
|
||||
|
||||
var savedAsManualBridge = false;
|
||||
var savedBridgeUrl = '';
|
||||
var savedBridgeToken = '';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SettingsAccountPanel(
|
||||
settings: SettingsSnapshot.defaults(),
|
||||
accountSession: null,
|
||||
accountState: null,
|
||||
accountBusy: false,
|
||||
accountSignedIn: false,
|
||||
accountMfaRequired: false,
|
||||
accountBaseUrlController: controllers.baseUrl,
|
||||
accountIdentifierController: controllers.identifier,
|
||||
accountPasswordController: controllers.password,
|
||||
accountMfaCodeController: controllers.mfaCode,
|
||||
bridgeUrlController: controllers.bridgeUrl,
|
||||
bridgeTokenController: controllers.bridgeToken,
|
||||
onSaveAccountProfile: ({required bool isManualBridge}) async {
|
||||
savedAsManualBridge = isManualBridge;
|
||||
savedBridgeUrl = controllers.bridgeUrl.text;
|
||||
savedBridgeToken = controllers.bridgeToken.text;
|
||||
},
|
||||
onLogin: () async {},
|
||||
onVerifyMfa: () async {},
|
||||
onCancelMfa: () async {},
|
||||
onSync: () async {},
|
||||
onLogout: () async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('手动 Bridge 配置'));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-url-field')),
|
||||
'https://cn-xworkmate-bridge.svc.plus',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-token-field')),
|
||||
'typed-manual-token',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-save-button')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(savedAsManualBridge, isTrue);
|
||||
expect(savedBridgeUrl, 'https://cn-xworkmate-bridge.svc.plus');
|
||||
expect(savedBridgeToken, 'typed-manual-token');
|
||||
});
|
||||
|
||||
testWidgets('switching to manual bridge tab does not save draft values', (
|
||||
tester,
|
||||
) async {
|
||||
final controllers = _TestControllers();
|
||||
addTearDown(controllers.dispose);
|
||||
|
||||
var saveCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SettingsAccountPanel(
|
||||
settings: SettingsSnapshot.defaults(),
|
||||
accountSession: null,
|
||||
accountState: null,
|
||||
accountBusy: false,
|
||||
accountSignedIn: false,
|
||||
accountMfaRequired: false,
|
||||
accountBaseUrlController: controllers.baseUrl,
|
||||
accountIdentifierController: controllers.identifier,
|
||||
accountPasswordController: controllers.password,
|
||||
accountMfaCodeController: controllers.mfaCode,
|
||||
bridgeUrlController: controllers.bridgeUrl,
|
||||
bridgeTokenController: controllers.bridgeToken,
|
||||
onSaveAccountProfile: ({required bool isManualBridge}) async {
|
||||
saveCount += 1;
|
||||
},
|
||||
onLogin: () async {},
|
||||
onVerifyMfa: () async {},
|
||||
onCancelMfa: () async {},
|
||||
onSync: () async {},
|
||||
onLogout: () async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('手动 Bridge 配置'));
|
||||
await tester.pump();
|
||||
|
||||
expect(saveCount, 0);
|
||||
});
|
||||
|
||||
testWidgets('desktop manual bridge save updates runtime configuration', (
|
||||
tester,
|
||||
) async {
|
||||
final controllers = _TestControllers();
|
||||
addTearDown(controllers.dispose);
|
||||
final store = _MemorySecureConfigStore();
|
||||
final controller = _NoopRefreshAppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SettingsAccountPanel(
|
||||
settings: controller.settings,
|
||||
accountSession: null,
|
||||
accountState: null,
|
||||
accountBusy: false,
|
||||
accountSignedIn: false,
|
||||
accountMfaRequired: false,
|
||||
accountBaseUrlController: controllers.baseUrl,
|
||||
accountIdentifierController: controllers.identifier,
|
||||
accountPasswordController: controllers.password,
|
||||
accountMfaCodeController: controllers.mfaCode,
|
||||
bridgeUrlController: controllers.bridgeUrl,
|
||||
bridgeTokenController: controllers.bridgeToken,
|
||||
onSaveAccountProfile: ({required bool isManualBridge}) async {
|
||||
final nextSettings = await controller.settingsController
|
||||
.buildSavedAccountProfileSettings(
|
||||
settings: controller.settings,
|
||||
accountBaseUrl: controllers.baseUrl.text,
|
||||
accountIdentifier: controllers.identifier.text,
|
||||
bridgeServerUrl: controllers.bridgeUrl.text,
|
||||
bridgeToken: controllers.bridgeToken.text,
|
||||
isManualBridge: isManualBridge,
|
||||
);
|
||||
await controller.saveSettings(
|
||||
nextSettings,
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
},
|
||||
onLogin: () async {},
|
||||
onVerifyMfa: () async {},
|
||||
onCancelMfa: () async {},
|
||||
onSync: () async {},
|
||||
onLogout: () async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('手动 Bridge 配置'));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-url-field')),
|
||||
'http://127.0.0.1:1',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-token-field')),
|
||||
'typed-manual-token',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('settings-manual-bridge-save-button')),
|
||||
);
|
||||
|
||||
for (
|
||||
var attempt = 0;
|
||||
attempt < 20 &&
|
||||
controller
|
||||
.settings
|
||||
.acpBridgeServerModeConfig
|
||||
.selfHosted
|
||||
.serverUrl !=
|
||||
'http://127.0.0.1:1';
|
||||
attempt += 1
|
||||
) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
final bridgeConfig = controller.settings.acpBridgeServerModeConfig;
|
||||
expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1');
|
||||
expect(bridgeConfig.selfHosted.username, 'admin');
|
||||
expect(bridgeConfig.effective.source, 'bridge');
|
||||
expect(bridgeConfig.effective.endpoint, 'http://127.0.0.1:1');
|
||||
expect(
|
||||
await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef),
|
||||
'typed-manual-token',
|
||||
);
|
||||
expect(
|
||||
controller.resolveGatewayAcpEndpointInternal()?.toString(),
|
||||
'http://127.0.0.1:1',
|
||||
);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('http://127.0.0.1:1/acp/rpc'),
|
||||
),
|
||||
'typed-manual-token',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows account sync status, resync, and exit in signed-in mode',
|
||||
(tester) async {
|
||||
@ -503,3 +703,63 @@ class _TestControllers {
|
||||
bridgeToken.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _NoopRefreshAppController extends AppController {
|
||||
_NoopRefreshAppController({required SecureConfigStore store})
|
||||
: super(environmentOverride: const <String, String>{}, store: store);
|
||||
|
||||
Future<void> refreshAcpCapabilitiesInternal({
|
||||
bool forceRefresh = false,
|
||||
bool persistMountTargets = false,
|
||||
}) async {}
|
||||
|
||||
Future<void> refreshSingleAgentCapabilitiesInternal({
|
||||
bool forceRefresh = false,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
class _MemorySecureConfigStore extends SecureConfigStore {
|
||||
_MemorySecureConfigStore() : super(enableSecureStorage: false);
|
||||
|
||||
SettingsSnapshot _settings = SettingsSnapshot.defaults();
|
||||
final Map<String, String> _secrets = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
|
||||
|
||||
@override
|
||||
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
|
||||
_settings = snapshot;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> loadSecureRefs() async => _secrets;
|
||||
|
||||
@override
|
||||
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
|
||||
const <SecretAuditEntry>[];
|
||||
|
||||
@override
|
||||
Future<void> appendAudit(SecretAuditEntry entry) async {}
|
||||
|
||||
@override
|
||||
Future<String?> loadSecretValueByRef(String refName) async =>
|
||||
_secrets[refName];
|
||||
|
||||
@override
|
||||
Future<void> saveSecretValueByRef(String refName, String value) async {
|
||||
_secrets[refName] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> loadAccountSessionToken() async => null;
|
||||
|
||||
@override
|
||||
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
|
||||
|
||||
@override
|
||||
Future<AccountSyncState?> loadAccountSyncState() async => null;
|
||||
}
|
||||
|
||||
@ -492,9 +492,10 @@ void main() {
|
||||
final snapshot = await controller.loadAssistantArtifactSnapshot(
|
||||
sessionKey: 'unit-fixture-task-a',
|
||||
);
|
||||
expect(snapshot.resultEntries.map((entry) => entry.relativePath), <String>[
|
||||
'notes/hello.v2.txt',
|
||||
]);
|
||||
expect(
|
||||
snapshot.resultEntries.map((entry) => entry.relativePath),
|
||||
containsAll(<String>['notes/hello.v2.txt', 'notes/hello.txt']),
|
||||
);
|
||||
expect(
|
||||
snapshot.fileEntries.map((entry) => entry.relativePath),
|
||||
containsAll(<String>['notes/hello.v2.txt', 'notes/hello.txt']),
|
||||
@ -566,7 +567,10 @@ void main() {
|
||||
final currentRelativePaths = snapshot.resultEntries
|
||||
.map((entry) => entry.relativePath)
|
||||
.toList(growable: false);
|
||||
expect(currentRelativePaths, <String>['current-task-report.md']);
|
||||
expect(
|
||||
currentRelativePaths,
|
||||
containsAll(<String>['current-task-report.md', 'old-task-report.md']),
|
||||
);
|
||||
expect(
|
||||
snapshot.fileEntries.map((entry) => entry.relativePath),
|
||||
containsAll(<String>['current-task-report.md', 'old-task-report.md']),
|
||||
@ -589,6 +593,94 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('syncs existing workspace directory artifacts recursively', () async {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final localWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-recursive-artifact-workspace-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await localWorkspace.exists()) {
|
||||
await localWorkspace.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
await Directory(
|
||||
'${localWorkspace.path}/assets/images/chapters',
|
||||
).create(recursive: true);
|
||||
await File(
|
||||
'${localWorkspace.path}/assets/images/cover.png',
|
||||
).writeAsBytes(<int>[1, 2, 3]);
|
||||
await File(
|
||||
'${localWorkspace.path}/assets/images/chapters/chapter-1.png',
|
||||
).writeAsBytes(<int>[4, 5, 6]);
|
||||
await File(
|
||||
'${localWorkspace.path}/chapters/codex-chapter-breakdown.md',
|
||||
).create(recursive: true);
|
||||
await Directory('${localWorkspace.path}/dist').create(recursive: true);
|
||||
await File(
|
||||
'${localWorkspace.path}/dist/账户与身份安全演进史-GPT混排最终版.pdf',
|
||||
).writeAsBytes(<int>[7, 8, 9]);
|
||||
|
||||
controller.upsertTaskThreadInternal(
|
||||
'unit-fixture-task-a',
|
||||
workspaceBinding: WorkspaceBinding(
|
||||
workspaceId: 'unit-fixture-task-a',
|
||||
workspaceKind: WorkspaceKind.localFs,
|
||||
workspacePath: localWorkspace.path,
|
||||
displayPath: localWorkspace.path,
|
||||
writable: true,
|
||||
),
|
||||
);
|
||||
|
||||
final result = GoTaskServiceResult(
|
||||
success: true,
|
||||
message: 'generated files',
|
||||
turnId: 'turn-recursive',
|
||||
raw: <String, dynamic>{
|
||||
'artifacts': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'relativePath': 'assets/images/'},
|
||||
<String, dynamic>{
|
||||
'relativePath': 'chapters/codex-chapter-breakdown.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
);
|
||||
|
||||
await controller.persistGoTaskArtifactsForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
result,
|
||||
);
|
||||
|
||||
final thread = controller.requireTaskThreadForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
expect(thread.lastArtifactSyncStatus, 'synced');
|
||||
expect(thread.lastTaskArtifactRelativePaths, <String>[
|
||||
'assets/images/chapters/chapter-1.png',
|
||||
'assets/images/cover.png',
|
||||
'chapters/codex-chapter-breakdown.md',
|
||||
'dist/账户与身份安全演进史-GPT混排最终版.pdf',
|
||||
]);
|
||||
final snapshot = await controller.loadAssistantArtifactSnapshot(
|
||||
sessionKey: 'unit-fixture-task-a',
|
||||
);
|
||||
expect(
|
||||
snapshot.resultEntries.map((entry) => entry.relativePath),
|
||||
containsAll(<String>[
|
||||
'assets/images/chapters/chapter-1.png',
|
||||
'assets/images/cover.png',
|
||||
'chapters/codex-chapter-breakdown.md',
|
||||
'dist/账户与身份安全演进史-GPT混排最终版.pdf',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'downloads bridge URL artifacts into the local thread workspace',
|
||||
() async {
|
||||
@ -907,12 +999,17 @@ void main() {
|
||||
await File('${localWorkspace.path}/reports/resume.bin').readAsBytes(),
|
||||
body,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
|
||||
.lastArtifactSyncStatus,
|
||||
'synced',
|
||||
final thread = controller.requireTaskThreadForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
for (
|
||||
var attempt = 0;
|
||||
attempt < 20 && thread.lastArtifactSyncStatus != 'synced';
|
||||
attempt += 1
|
||||
) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
expect(thread.lastArtifactSyncStatus, 'synced');
|
||||
},
|
||||
);
|
||||
|
||||
@ -1310,10 +1407,8 @@ void main() {
|
||||
sessionKey: 'unit-fixture-task-a',
|
||||
);
|
||||
expect(snapshot.resultEntries, isEmpty);
|
||||
expect(
|
||||
snapshot.fileEntries.map((entry) => entry.relativePath),
|
||||
contains('old-task-report.md'),
|
||||
);
|
||||
expect(snapshot.fileEntries, isEmpty);
|
||||
expect(snapshot.resultMessage, 'No task artifacts recorded for this run.');
|
||||
});
|
||||
|
||||
test('skips download URL artifacts outside the bridge host', () async {
|
||||
|
||||
@ -1186,6 +1186,31 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendChatMessage declares expected artifacts for complex PDF chains',
|
||||
() async {
|
||||
final fakeGoTaskService = _RecordingGoTaskServiceClient();
|
||||
final controller = _connectedGatewayController(fakeGoTaskService);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.ensureActiveAssistantThreadInternal();
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
);
|
||||
await controller.sendChatMessage(
|
||||
'围绕\n\n'
|
||||
'从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 800-1500字\n'
|
||||
'拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 ->\n\n'
|
||||
'最后 输出 PDF文件',
|
||||
);
|
||||
|
||||
expect(fakeGoTaskService.requests, hasLength(1));
|
||||
final request = fakeGoTaskService.requests.single;
|
||||
expect(request.metadata['taskLoadClass'], 'complex_long_chain_task');
|
||||
expect(request.metadata['expectedArtifactExtensions'], <String>['pdf']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendChatMessage classifies simple Gateway prompts as short tasks',
|
||||
() async {
|
||||
@ -1892,6 +1917,125 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('sendChatMessage can pin submit to the captured session', () async {
|
||||
final fakeGoTaskService = _BlockingGoTaskServiceClient();
|
||||
final controller = _connectedController(fakeGoTaskService);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.switchSession('same-prompt-old-task');
|
||||
await controller.switchSession('same-prompt-new-task');
|
||||
final taskFuture = controller.sendChatMessage(
|
||||
'连续制作7张图片',
|
||||
sessionKey: 'same-prompt-new-task',
|
||||
);
|
||||
await fakeGoTaskService.waitForRequestCount(1);
|
||||
|
||||
final request = fakeGoTaskService.requests.single;
|
||||
expect(request.sessionId, 'same-prompt-new-task');
|
||||
expect(request.threadId, 'same-prompt-new-task');
|
||||
expect(request.workingDirectory, endsWith('/same-prompt-new-task'));
|
||||
expect(
|
||||
request.remoteWorkingDirectoryHint,
|
||||
endsWith('/threads/same-prompt-new-task'),
|
||||
);
|
||||
|
||||
fakeGoTaskService.complete(
|
||||
'same-prompt-new-task',
|
||||
const GoTaskServiceResult(
|
||||
success: true,
|
||||
message: 'new task result',
|
||||
turnId: 'turn-new',
|
||||
raw: <String, dynamic>{},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
);
|
||||
await taskFuture;
|
||||
|
||||
expect(
|
||||
controller.localSessionMessagesInternal['same-prompt-new-task']!.map(
|
||||
(message) => message.text,
|
||||
),
|
||||
contains('new task result'),
|
||||
);
|
||||
expect(
|
||||
controller.localSessionMessagesInternal['same-prompt-old-task'],
|
||||
isNot(contains('new task result')),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'sendChatMessage queues follow-up turns on the same session',
|
||||
() async {
|
||||
final fakeGoTaskService = _BlockingGoTaskServiceClient();
|
||||
final controller = _connectedController(fakeGoTaskService);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.switchSession('running-task');
|
||||
final firstFuture = controller.sendChatMessage('first turn');
|
||||
await fakeGoTaskService.waitForRequestCount(1);
|
||||
expect(fakeGoTaskService.requests.single.sessionId, 'running-task');
|
||||
expect(fakeGoTaskService.requests.single.threadId, 'running-task');
|
||||
expect(
|
||||
controller.assistantSessionHasPendingRun('running-task'),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final secondFuture = controller.sendChatMessage('follow up');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(fakeGoTaskService.requests, hasLength(1));
|
||||
expect(controller.currentSessionKey, 'running-task');
|
||||
|
||||
fakeGoTaskService.complete(
|
||||
'running-task',
|
||||
const GoTaskServiceResult(
|
||||
success: true,
|
||||
message: 'first result',
|
||||
turnId: 'turn-1',
|
||||
raw: <String, dynamic>{},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
);
|
||||
await firstFuture;
|
||||
await fakeGoTaskService.waitForRequestCount(2);
|
||||
|
||||
final followUpRequest = fakeGoTaskService.requests.last;
|
||||
expect(followUpRequest.sessionId, 'running-task');
|
||||
expect(followUpRequest.threadId, 'running-task');
|
||||
expect(followUpRequest.prompt, contains('follow up'));
|
||||
expect(controller.currentSessionKey, 'running-task');
|
||||
|
||||
fakeGoTaskService.complete(
|
||||
'running-task',
|
||||
const GoTaskServiceResult(
|
||||
success: true,
|
||||
message: 'second result',
|
||||
turnId: 'turn-2',
|
||||
raw: <String, dynamic>{},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
);
|
||||
await secondFuture;
|
||||
|
||||
expect(
|
||||
controller.localSessionMessagesInternal['running-task']!.map(
|
||||
(message) => message.text,
|
||||
),
|
||||
containsAll(<String>[
|
||||
'first turn',
|
||||
'first result',
|
||||
'follow up',
|
||||
'second result',
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'background task completion does not overwrite the selected session',
|
||||
() async {
|
||||
@ -2272,9 +2416,10 @@ void main() {
|
||||
);
|
||||
expect(taskBSnapshot.fileEntries, isEmpty);
|
||||
expect(
|
||||
taskBSnapshot.filesMessage,
|
||||
'No files found in the recorded working directory.',
|
||||
taskBSnapshot.resultMessage,
|
||||
'No task artifacts recorded for this run.',
|
||||
);
|
||||
expect(taskBSnapshot.filesMessage, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
@ -2413,9 +2558,10 @@ void main() {
|
||||
sessionKey: 'terminal-failure-task',
|
||||
);
|
||||
expect(snapshot.resultEntries, isEmpty);
|
||||
expect(snapshot.fileEntries, isEmpty);
|
||||
expect(
|
||||
snapshot.fileEntries.map((entry) => entry.relativePath),
|
||||
contains('first.md'),
|
||||
snapshot.resultMessage,
|
||||
'No task artifacts recorded for this run.',
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -2596,7 +2742,7 @@ void main() {
|
||||
await expectLater(
|
||||
controller
|
||||
.sendChatMessage('active prompt $index')
|
||||
.timeout(const Duration(milliseconds: 250)),
|
||||
.timeout(const Duration(seconds: 2)),
|
||||
completes,
|
||||
);
|
||||
await fakeGoTaskService.waitForRequestCount(index + 1);
|
||||
@ -2616,7 +2762,7 @@ void main() {
|
||||
'queued prompt',
|
||||
attachments: <GatewayChatAttachmentPayload>[queuedAttachment],
|
||||
)
|
||||
.timeout(const Duration(milliseconds: 250)),
|
||||
.timeout(const Duration(seconds: 2)),
|
||||
completes,
|
||||
);
|
||||
await _waitForThreadLifecycleStatus(
|
||||
@ -2718,6 +2864,57 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'OpenClaw gateway admits five representative E2E tasks without queueing',
|
||||
() async {
|
||||
final fakeGoTaskService = _BlockingGoTaskServiceClient();
|
||||
final controller = _connectedGatewayController(fakeGoTaskService);
|
||||
addTearDown(() {
|
||||
fakeGoTaskService.completeAll();
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
const prompts = <String>[
|
||||
'从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n制作 使用codex 制作连续制作 7张的一些列图片',
|
||||
'参考附件模版制作 ,围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n连续制作 7张的一些列图片',
|
||||
'拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF\n\n右侧 artifact栏 显示的陈旧文件',
|
||||
'围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频',
|
||||
'围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频',
|
||||
];
|
||||
|
||||
for (var index = 0; index < prompts.length; index += 1) {
|
||||
final sessionKey = 'openclaw-e2e-$index';
|
||||
await _selectGatewaySession(controller, sessionKey);
|
||||
await expectLater(
|
||||
controller
|
||||
.sendChatMessage(prompts[index])
|
||||
.timeout(const Duration(seconds: 2)),
|
||||
completes,
|
||||
);
|
||||
}
|
||||
|
||||
await fakeGoTaskService.waitForRequestCount(prompts.length);
|
||||
expect(fakeGoTaskService.requests, hasLength(prompts.length));
|
||||
expect(controller.openClawGatewayActiveTasksInternal, prompts.length);
|
||||
expect(controller.openClawGatewayQueuedTurnsInternal, isEmpty);
|
||||
for (var index = 0; index < prompts.length; index += 1) {
|
||||
final sessionKey = 'openclaw-e2e-$index';
|
||||
expect(
|
||||
controller
|
||||
.requireTaskThreadForSessionInternal(sessionKey)
|
||||
.lifecycleState
|
||||
.status,
|
||||
'running',
|
||||
);
|
||||
expect(fakeGoTaskService.requests[index].sessionId, sessionKey);
|
||||
expect(
|
||||
fakeGoTaskService.requests[index].prompt,
|
||||
contains(prompts[index]),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('OpenClaw gateway task uses the server default model', () async {
|
||||
final fakeGoTaskService = _BlockingGoTaskServiceClient();
|
||||
final controller = _connectedGatewayController(fakeGoTaskService);
|
||||
@ -2735,7 +2932,7 @@ void main() {
|
||||
final taskFuture = controller.sendChatMessage('use OpenClaw default');
|
||||
await fakeGoTaskService.waitForRequestCount(1);
|
||||
await expectLater(
|
||||
taskFuture.timeout(const Duration(milliseconds: 250)),
|
||||
taskFuture.timeout(const Duration(seconds: 2)),
|
||||
completes,
|
||||
);
|
||||
|
||||
@ -3787,7 +3984,7 @@ Future<List<String>> _startOpenClawActiveTasks(
|
||||
await expectLater(
|
||||
controller
|
||||
.sendChatMessage('active task $index')
|
||||
.timeout(const Duration(milliseconds: 250)),
|
||||
.timeout(const Duration(seconds: 2)),
|
||||
completes,
|
||||
);
|
||||
await fakeGoTaskService.waitForRequestCount(index + 1);
|
||||
|
||||
@ -168,6 +168,81 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'manual bridge token authorizes runtime and artifact requests only for manual endpoint',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-manual-bridge-artifact-auth-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
try {
|
||||
await storeRoot.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temp cleanup is best effort here. The controller may still be
|
||||
// releasing files when teardown starts.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
|
||||
.copyWith(
|
||||
selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted
|
||||
.copyWith(
|
||||
serverUrl: 'https://private-bridge.svc.plus',
|
||||
username: 'admin',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await store.saveSecretValueByRef(
|
||||
AcpBridgeServerSelfHostedConfig.defaults().passwordRef,
|
||||
'manual-bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
store: store,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://private-bridge.svc.plus/acp/rpc'),
|
||||
),
|
||||
'manual-bridge-token',
|
||||
);
|
||||
expect(
|
||||
await controller.resolveBridgeArtifactAuthorizationHeaderInternal(
|
||||
Uri.parse('https://private-bridge.svc.plus/artifacts/file.pdf'),
|
||||
),
|
||||
'Bearer manual-bridge-token',
|
||||
);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await controller.resolveBridgeArtifactAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/artifacts/file.pdf'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'runtime coordinator only exposes remote and offline gateway modes',
|
||||
() {
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/assistant_artifacts.dart';
|
||||
import 'package:xworkmate/runtime/desktop_thread_artifact_service.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'loadSnapshot exposes all local workspace files when current run has no artifacts',
|
||||
'loadSnapshot hides historical workspace files when current run has no artifacts',
|
||||
() async {
|
||||
final workspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-artifact-snapshot-',
|
||||
@ -25,10 +26,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(snapshot.resultEntries, isEmpty);
|
||||
expect(
|
||||
snapshot.fileEntries.map((entry) => entry.relativePath),
|
||||
contains('historical.md'),
|
||||
);
|
||||
expect(snapshot.fileEntries, isEmpty);
|
||||
expect(
|
||||
snapshot.resultMessage,
|
||||
'No task artifacts recorded for this run.',
|
||||
@ -80,13 +78,14 @@ void main() {
|
||||
}
|
||||
});
|
||||
await File('${workspace.path}/historical.md').writeAsString('# Old\n');
|
||||
final snapshot = await DesktopThreadArtifactService().loadSnapshot(
|
||||
workspacePath: workspace.path,
|
||||
workspaceKind: WorkspaceRefKind.localPath,
|
||||
artifactRelativePaths: const <String>[],
|
||||
);
|
||||
final historical = snapshot.fileEntries.singleWhere(
|
||||
(entry) => entry.relativePath == 'historical.md',
|
||||
const historical = AssistantArtifactEntry(
|
||||
id: 'historical.md',
|
||||
label: 'historical.md',
|
||||
relativePath: 'historical.md',
|
||||
kind: AssistantArtifactEntryKind.file,
|
||||
mimeType: 'text/markdown',
|
||||
previewable: true,
|
||||
workspacePath: '',
|
||||
);
|
||||
|
||||
final preview = await DesktopThreadArtifactService().loadPreview(
|
||||
|
||||
@ -441,6 +441,144 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'blocked managed bridge sync does not configure runtime authorization',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-managed-bridge-blocked-runtime-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveAccountSessionToken('session-token');
|
||||
await store.saveAccountSessionSummary(
|
||||
const AccountSessionSummary(
|
||||
userId: 'user-1',
|
||||
email: 'review@svc.plus',
|
||||
name: 'Review User',
|
||||
role: 'reviewer',
|
||||
mfaEnabled: true,
|
||||
),
|
||||
);
|
||||
await store.saveAccountSyncState(
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncState: 'ready',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
bridge: true,
|
||||
vault: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await store.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: 'bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{
|
||||
'BRIDGE_AUTH_TOKEN': 'env-token-must-not-recover-blocked-sync',
|
||||
},
|
||||
store: store,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
await controller.settingsControllerInternal
|
||||
.markAccountBridgeRuntimeUnavailable(
|
||||
'Bridge authorization rejected',
|
||||
);
|
||||
|
||||
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await store.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
),
|
||||
'bridge-token',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('manual bridge config becomes the runtime ACP source', () async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-manual-bridge-runtime-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
try {
|
||||
await storeRoot.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temp cleanup is best effort here. The controller may still be
|
||||
// releasing files when teardown starts.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
|
||||
.copyWith(
|
||||
selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted
|
||||
.copyWith(
|
||||
serverUrl: 'https://private-bridge.svc.plus',
|
||||
username: 'admin',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await store.saveSecretValueByRef(
|
||||
AcpBridgeServerSelfHostedConfig.defaults().passwordRef,
|
||||
'manual-bridge-token',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
store: store,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.settingsControllerInternal.initialize();
|
||||
|
||||
expect(
|
||||
controller.resolveGatewayAcpEndpointInternal()?.toString(),
|
||||
'https://private-bridge.svc.plus',
|
||||
);
|
||||
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isTrue);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://private-bridge.svc.plus/acp/rpc'),
|
||||
),
|
||||
'manual-bridge-token',
|
||||
);
|
||||
expect(
|
||||
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'syncAccountSettings succeeds when bridge url metadata is missing',
|
||||
() async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user