Add bridge-to-app remote provider test coverage
This commit is contained in:
parent
b77a486d2c
commit
56e80fef60
@ -258,6 +258,184 @@
|
||||
- 标题、来源、摘要等字段完整
|
||||
- 结果留在当前线程内
|
||||
- 建议记录项
|
||||
- 当前 provider / endpoint
|
||||
- 输入提示词
|
||||
- 结果中的标题 / 来源 / 摘要
|
||||
- 是否回到当前线程
|
||||
|
||||
## 5. XWorkmate App -> XWorkmate Bridge 远端单 Agent / Gateway 验收
|
||||
|
||||
这些 case 用于验证 `xworkmate-app` 通过本地 `GoAcpStdioBridge` 调用
|
||||
`xworkmate-bridge`,再转发到远端 `codex / opencode / gemini / openclaw`
|
||||
时的真实线程行为,重点关注:
|
||||
|
||||
- provider 选择是否正确
|
||||
- follow-up 是否保持同一 thread
|
||||
- artifact 是否写回当前线程本地 workspace
|
||||
- `lastRemoteWorkingDirectory` / `remoteWorkspaceRefKind` 是否只作为 metadata
|
||||
|
||||
统一新增记录项:
|
||||
|
||||
- 当前模式
|
||||
- 当前 provider / endpoint
|
||||
- 输入提示词
|
||||
- 线程 ID
|
||||
- 本地线程 workspace 路径
|
||||
- 产物路径列表
|
||||
- `lastRemoteWorkingDirectory`
|
||||
- `remoteWorkspaceRefKind`
|
||||
- 是否需要外部服务人工确认
|
||||
|
||||
### `MANUAL-REMOTE-001` Codex 对话 + `pptx`
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `codex`
|
||||
- bridge/provider 连通
|
||||
- 操作步骤
|
||||
1. 输入“生成一个两页产品介绍演示稿,输出为 `deck.pptx`”
|
||||
2. 等待任务完成并确认 artifact 区出现 `.pptx`
|
||||
3. 在同一线程继续追问“把第二页改成总结页”
|
||||
- 期望结果
|
||||
- 对话可用
|
||||
- `.pptx` 写回当前线程本地 workspace
|
||||
- follow-up 复用同一线程,不漂移到其他 provider
|
||||
- `lastRemoteWorkingDirectory` 更新,但 `workspaceBinding` 仍是本地目录
|
||||
|
||||
### `MANUAL-REMOTE-002` Codex `docx/xlsx/pdf`
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `codex`
|
||||
- 操作步骤
|
||||
1. 执行 `docx`:生成一份周报文档
|
||||
2. 执行 `xlsx`:生成一个带汇总公式的销售表
|
||||
3. 执行 `pdf`:生成或转换出一个 PDF 摘要文件
|
||||
- 期望结果
|
||||
- 三类任务均可执行
|
||||
- 产物分别出现在 artifact 区
|
||||
- 文件落回当前线程本地 workspace
|
||||
|
||||
### `MANUAL-REMOTE-003` Codex `image-resizer`
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `codex`
|
||||
- 线程目录内有一张待处理图片
|
||||
- 操作步骤
|
||||
1. 输入“将 `input.png` 缩放到 1200x800 并输出 `resized.png`”
|
||||
2. 等待结果完成
|
||||
- 期望结果
|
||||
- 图片处理成功
|
||||
- 输出图片写回当前线程本地 workspace
|
||||
- 结果摘要含尺寸或压缩信息
|
||||
|
||||
### `MANUAL-REMOTE-004` OpenCode 对话 + `pptx`
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `opencode`
|
||||
- 操作步骤
|
||||
1. 输入“生成一个两页演示稿 `deck.pptx`”
|
||||
2. 等待完成
|
||||
3. 同线程继续追问修改第二页内容
|
||||
- 期望结果
|
||||
- 对话可用
|
||||
- `.pptx` 落回当前线程本地 workspace
|
||||
- follow-up 继续复用同一线程上下文
|
||||
|
||||
### `MANUAL-REMOTE-005` OpenCode `docx/xlsx/pdf`
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `opencode`
|
||||
- 操作步骤
|
||||
1. 执行 `docx`
|
||||
2. 执行 `xlsx`
|
||||
3. 执行 `pdf`
|
||||
- 期望结果
|
||||
- 三类任务可用
|
||||
- 产物可见且落回当前线程本地 workspace
|
||||
|
||||
### `MANUAL-REMOTE-006` OpenCode `image-resizer`
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `opencode`
|
||||
- 已准备本地输入图片
|
||||
- 操作步骤
|
||||
1. 输入图片缩放任务
|
||||
2. 等待结果
|
||||
- 期望结果
|
||||
- 输出图片可见
|
||||
- 线程 artifact 和本地 workspace 均可确认结果
|
||||
|
||||
### `MANUAL-REMOTE-007` Gemini 基础对话
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `gemini`
|
||||
- 操作步骤
|
||||
1. 输入“回复 exactly pong”
|
||||
2. 在同一线程继续追问“回复 exactly round2”
|
||||
- 期望结果
|
||||
- 基础对话可用
|
||||
- 两轮消息都停留在同一线程
|
||||
- provider 显示仍为 `gemini`
|
||||
|
||||
### `MANUAL-REMOTE-008` Gemini 文档 / 图片任务能力边界确认
|
||||
|
||||
- 前置条件
|
||||
- 已选择任务对话模式 `gemini`
|
||||
- 操作步骤
|
||||
1. 分别尝试 `docx / pptx / xlsx / pdf / image-resizer`
|
||||
2. 记录每项成功或失败
|
||||
- 期望结果
|
||||
- 若成功:artifact 落回当前线程本地 workspace
|
||||
- 若失败:错误摘要明确,可区分是 provider 能力限制还是 bridge/app 落盘问题
|
||||
|
||||
### `MANUAL-GATEWAY-001` OpenClaw Gateway 基础对话
|
||||
|
||||
- 前置条件
|
||||
- 任务线程使用 remote gateway / `openclaw gateway`
|
||||
- `openclaw.svc.plus` 可连通
|
||||
- 操作步骤
|
||||
1. 输入普通对话任务
|
||||
2. 等待 gateway 返回结果
|
||||
- 期望结果
|
||||
- 可建立对话
|
||||
- 线程消息返回成功或明确失败摘要
|
||||
- provider / mode 显示为 gateway 路径
|
||||
|
||||
### `MANUAL-GATEWAY-002` OpenClaw Gateway 文档类任务
|
||||
|
||||
- 前置条件
|
||||
- Gateway 路径可用
|
||||
- 操作步骤
|
||||
1. 执行 `docx` 或 `pptx`
|
||||
2. 执行 `xlsx` 或 `pdf`
|
||||
- 期望结果
|
||||
- 至少 1-2 类文档任务成功
|
||||
- 若返回 artifact,应回写当前线程本地 workspace
|
||||
- 若只返回文本摘要,应记录为“对话成功但无 artifact”
|
||||
|
||||
### `MANUAL-GATEWAY-003` OpenClaw Gateway 浏览器自动化
|
||||
|
||||
- 前置条件
|
||||
- Gateway 浏览器能力可用
|
||||
- 有可访问网页
|
||||
- 操作步骤
|
||||
1. 输入“打开示例页面并提取标题”
|
||||
2. 如支持截图,再追问“截图并保存结果”
|
||||
- 期望结果
|
||||
- 可执行浏览器任务
|
||||
- 返回网页摘要
|
||||
- 若有截图 / 日志产物,应进入当前线程 artifact
|
||||
|
||||
### `MANUAL-GATEWAY-004` OpenClaw Gateway 在线资讯汇总
|
||||
|
||||
- 前置条件
|
||||
- Gateway 联网能力可用
|
||||
- 操作步骤
|
||||
1. 输入“汇总今天关于 AI Agent 的 5 条资讯”
|
||||
2. 查看结构化结果
|
||||
- 期望结果
|
||||
- 能返回标题、来源、摘要
|
||||
- 结果留在当前线程
|
||||
- 若生成文档或截图,写回当前线程本地 workspace
|
||||
- 查询词
|
||||
- 结果条数
|
||||
- 结果摘要截图
|
||||
|
||||
@ -6,6 +6,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/desktop_thread_artifact_sync.dart';
|
||||
import '../runtime/go_task_service_client.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
import 'app_controller_desktop_core.dart';
|
||||
@ -364,81 +365,17 @@ Future<void> _persistSingleAgentArtifactsDesktopInternal(
|
||||
return;
|
||||
}
|
||||
final root = Directory(existingThread.workspaceBinding.workspacePath);
|
||||
await root.create(recursive: true);
|
||||
|
||||
var wroteArtifact = false;
|
||||
for (final artifact in artifacts) {
|
||||
if (!artifact.hasInlineContent) {
|
||||
continue;
|
||||
}
|
||||
final relativePath = _sanitizeArtifactRelativePathInternal(
|
||||
artifact.relativePath,
|
||||
);
|
||||
if (relativePath.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final target = await _nextArtifactTargetFileInternal(root, relativePath);
|
||||
await target.parent.create(recursive: true);
|
||||
await target.writeAsBytes(
|
||||
_decodeArtifactContentInternal(artifact),
|
||||
flush: true,
|
||||
);
|
||||
wroteArtifact = true;
|
||||
}
|
||||
final syncResult = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: root,
|
||||
artifacts: artifacts,
|
||||
);
|
||||
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content',
|
||||
lastArtifactSyncStatus: syncResult.wroteArtifact
|
||||
? 'synced'
|
||||
: 'no-inline-content',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
String _sanitizeArtifactRelativePathInternal(String raw) {
|
||||
final trimmed = raw.trim().replaceAll('\\', '/');
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final cleaned = trimmed
|
||||
.split('/')
|
||||
.where(
|
||||
(segment) => segment.isNotEmpty && segment != '.' && segment != '..',
|
||||
)
|
||||
.join('/');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
List<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
|
||||
final encoding = artifact.encoding.trim().toLowerCase();
|
||||
if (encoding == 'base64') {
|
||||
return base64Decode(artifact.content);
|
||||
}
|
||||
return utf8.encode(artifact.content);
|
||||
}
|
||||
|
||||
Future<File> _nextArtifactTargetFileInternal(
|
||||
Directory root,
|
||||
String relativePath,
|
||||
) async {
|
||||
final segments = relativePath.split('/');
|
||||
final fileName = segments.removeLast();
|
||||
final parent = segments.isEmpty
|
||||
? root
|
||||
: Directory('${root.path}/${segments.join('/')}');
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
|
||||
final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex);
|
||||
var candidate = File('${parent.path}/$fileName');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
for (var version = 2; version < 1000; version += 1) {
|
||||
candidate = File('${parent.path}/$baseName.v$version$extension');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return File(
|
||||
'${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension',
|
||||
);
|
||||
}
|
||||
|
||||
84
lib/runtime/desktop_thread_artifact_sync.dart
Normal file
84
lib/runtime/desktop_thread_artifact_sync.dart
Normal file
@ -0,0 +1,84 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'go_task_service_client.dart';
|
||||
|
||||
class DesktopThreadArtifactSyncResult {
|
||||
const DesktopThreadArtifactSyncResult({
|
||||
required this.wroteArtifact,
|
||||
required this.writtenFiles,
|
||||
});
|
||||
|
||||
final bool wroteArtifact;
|
||||
final List<String> writtenFiles;
|
||||
}
|
||||
|
||||
Future<DesktopThreadArtifactSyncResult> syncInlineArtifactsToLocalWorkspace({
|
||||
required Directory root,
|
||||
required List<GoTaskServiceArtifact> artifacts,
|
||||
}) async {
|
||||
await root.create(recursive: true);
|
||||
final writtenFiles = <String>[];
|
||||
for (final artifact in artifacts) {
|
||||
if (!artifact.hasInlineContent) {
|
||||
continue;
|
||||
}
|
||||
final relativePath = sanitizeArtifactRelativePath(artifact.relativePath);
|
||||
if (relativePath.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final target = await nextArtifactTargetFile(root, relativePath);
|
||||
await target.parent.create(recursive: true);
|
||||
await target.writeAsBytes(decodeArtifactContent(artifact), flush: true);
|
||||
writtenFiles.add(target.path);
|
||||
}
|
||||
return DesktopThreadArtifactSyncResult(
|
||||
wroteArtifact: writtenFiles.isNotEmpty,
|
||||
writtenFiles: List<String>.unmodifiable(writtenFiles),
|
||||
);
|
||||
}
|
||||
|
||||
String sanitizeArtifactRelativePath(String raw) {
|
||||
final trimmed = raw.trim().replaceAll('\\', '/');
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
return trimmed
|
||||
.split('/')
|
||||
.where(
|
||||
(segment) => segment.isNotEmpty && segment != '.' && segment != '..',
|
||||
)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
List<int> decodeArtifactContent(GoTaskServiceArtifact artifact) {
|
||||
final encoding = artifact.encoding.trim().toLowerCase();
|
||||
if (encoding == 'base64') {
|
||||
return base64Decode(artifact.content);
|
||||
}
|
||||
return utf8.encode(artifact.content);
|
||||
}
|
||||
|
||||
Future<File> nextArtifactTargetFile(Directory root, String relativePath) async {
|
||||
final segments = relativePath.split('/');
|
||||
final fileName = segments.removeLast();
|
||||
final parent = segments.isEmpty
|
||||
? root
|
||||
: Directory('${root.path}/${segments.join('/')}');
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
|
||||
final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex);
|
||||
var candidate = File('${parent.path}/$fileName');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
for (var version = 2; version < 1000; version += 1) {
|
||||
candidate = File('${parent.path}/$baseName.v$version$extension');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return File(
|
||||
'${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension',
|
||||
);
|
||||
}
|
||||
352
test/runtime/bridge_real_e2e_test.dart
Normal file
352
test/runtime/bridge_real_e2e_test.dart
Normal file
@ -0,0 +1,352 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/desktop_thread_artifact_sync.dart';
|
||||
import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart';
|
||||
import 'package:xworkmate/runtime/go_task_service_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
const _providerEndpoints = <String, String>{
|
||||
'codex': 'https://acp-server.svc.plus/codex/acp/rpc',
|
||||
'opencode': 'https://acp-server.svc.plus/opencode/acp/rpc',
|
||||
'gemini': 'https://acp-server.svc.plus/gemini/acp/rpc',
|
||||
};
|
||||
|
||||
const _tinyPngBase64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0x8AAAAASUVORK5CYII=';
|
||||
|
||||
void main() {
|
||||
final runRealE2E =
|
||||
Platform.environment['RUN_REAL_BRIDGE_E2E'] == '1' ||
|
||||
Platform.environment['RUN_REAL_BRIDGE_E2E'] == 'true';
|
||||
final bridgeAuthToken =
|
||||
Platform.environment['BRIDGE_AUTH_TOKEN']?.trim() ?? '';
|
||||
final openclawGatewayToken =
|
||||
Platform.environment['OPENCLAW_GATEWAY_TOKEN']?.trim() ?? '';
|
||||
|
||||
group('real bridge provider matrix', () {
|
||||
late ExternalCodeAgentAcpDesktopTransport transport;
|
||||
|
||||
setUpAll(() async {
|
||||
if (!runRealE2E || bridgeAuthToken.isEmpty) {
|
||||
return;
|
||||
}
|
||||
transport = ExternalCodeAgentAcpDesktopTransport();
|
||||
await transport.syncExternalProviders(
|
||||
_providerEndpoints.entries
|
||||
.map(
|
||||
(entry) => ExternalCodeAgentAcpSyncedProvider(
|
||||
providerId: entry.key,
|
||||
label: entry.key,
|
||||
endpoint: entry.value,
|
||||
authorizationHeader: 'Bearer $bridgeAuthToken',
|
||||
enabled: true,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (runRealE2E && bridgeAuthToken.isNotEmpty) {
|
||||
await transport.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('loads external ACP capabilities and provider catalog', () async {
|
||||
if (!runRealE2E || bridgeAuthToken.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final capabilities = await transport.loadExternalAcpCapabilities(
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(capabilities.singleAgent, isTrue);
|
||||
expect(
|
||||
capabilities.providerCatalog.map((item) => item.providerId),
|
||||
containsAll(<String>['codex', 'opencode', 'gemini']),
|
||||
);
|
||||
});
|
||||
|
||||
for (final providerId in _providerEndpoints.keys) {
|
||||
test('$providerId supports a two-turn conversation', () async {
|
||||
if (!runRealE2E || bridgeAuthToken.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final workdir = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-$providerId-conversation-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await workdir.exists()) {
|
||||
await workdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final startResult = await transport.executeTask(
|
||||
_buildRequest(
|
||||
providerId: providerId,
|
||||
sessionId: 'conversation-$providerId',
|
||||
threadId: 'conversation-$providerId',
|
||||
workingDirectory: workdir.path,
|
||||
prompt: 'Reply with exactly pong.',
|
||||
),
|
||||
onUpdate: (_) {},
|
||||
);
|
||||
expect(startResult.success, isTrue);
|
||||
expect(startResult.resolvedProviderId, providerId);
|
||||
|
||||
final messageResult = await transport.executeTask(
|
||||
_buildRequest(
|
||||
providerId: providerId,
|
||||
sessionId: 'conversation-$providerId',
|
||||
threadId: 'conversation-$providerId',
|
||||
workingDirectory: workdir.path,
|
||||
prompt: 'Reply with exactly round2.',
|
||||
resumeSession: true,
|
||||
),
|
||||
onUpdate: (_) {},
|
||||
);
|
||||
expect(messageResult.success, isTrue);
|
||||
expect(messageResult.resolvedProviderId, providerId);
|
||||
expect(
|
||||
messageResult.message.toLowerCase(),
|
||||
contains('round2'),
|
||||
reason: 'follow-up should stay on the same provider/thread',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (final providerId in <String>['codex', 'opencode']) {
|
||||
for (final scenario in _artifactScenarios) {
|
||||
test(
|
||||
'$providerId can return ${scenario.skill} artifacts to local workspace',
|
||||
() async {
|
||||
if (!runRealE2E || bridgeAuthToken.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final workdir = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-$providerId-${scenario.skill}-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await workdir.exists()) {
|
||||
await workdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
await scenario.prepare?.call(workdir);
|
||||
|
||||
final result = await transport.executeTask(
|
||||
_buildRequest(
|
||||
providerId: providerId,
|
||||
sessionId: '${providerId}-${scenario.skill}',
|
||||
threadId: '${providerId}-${scenario.skill}',
|
||||
workingDirectory: workdir.path,
|
||||
prompt: scenario.prompt,
|
||||
selectedSkills: <String>[scenario.skill],
|
||||
),
|
||||
onUpdate: (_) {},
|
||||
);
|
||||
|
||||
expect(result.success, isTrue, reason: result.errorMessage);
|
||||
expect(result.resolvedProviderId, providerId);
|
||||
expect(result.remoteWorkingDirectory.trim(), isNotEmpty);
|
||||
expect(result.remoteWorkspaceRefKind, WorkspaceRefKind.remotePath);
|
||||
expect(result.resultSummary.trim(), isNotEmpty);
|
||||
expect(result.artifacts, isNotEmpty);
|
||||
|
||||
final syncResult = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: workdir,
|
||||
artifacts: result.artifacts,
|
||||
);
|
||||
expect(syncResult.wroteArtifact, isTrue);
|
||||
expect(
|
||||
syncResult.writtenFiles.any(
|
||||
(path) => path.endsWith(scenario.expectedSuffix),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final scenario in _artifactScenarios) {
|
||||
test(
|
||||
'gemini reports either success or a provider limitation for ${scenario.skill}',
|
||||
() async {
|
||||
if (!runRealE2E || bridgeAuthToken.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final workdir = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-gemini-${scenario.skill}-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await workdir.exists()) {
|
||||
await workdir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
await scenario.prepare?.call(workdir);
|
||||
|
||||
final result = await transport.executeTask(
|
||||
_buildRequest(
|
||||
providerId: 'gemini',
|
||||
sessionId: 'gemini-${scenario.skill}',
|
||||
threadId: 'gemini-${scenario.skill}',
|
||||
workingDirectory: workdir.path,
|
||||
prompt: scenario.prompt,
|
||||
selectedSkills: <String>[scenario.skill],
|
||||
),
|
||||
onUpdate: (_) {},
|
||||
);
|
||||
|
||||
expect(result.resolvedProviderId, 'gemini');
|
||||
if (result.success) {
|
||||
final syncResult = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: workdir,
|
||||
artifacts: result.artifacts,
|
||||
);
|
||||
expect(syncResult.wroteArtifact, isTrue);
|
||||
} else {
|
||||
expect(
|
||||
result.errorMessage.trim().isNotEmpty ||
|
||||
result.message.trim().isNotEmpty,
|
||||
isTrue,
|
||||
reason:
|
||||
'provider limitation should still surface a clear summary',
|
||||
);
|
||||
}
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
group('openclaw gateway smoke', () {
|
||||
test('defaultsRemote still targets openclaw.svc.plus:443', () {
|
||||
final profile = GatewayConnectionProfile.defaultsRemote();
|
||||
expect(profile.host, 'openclaw.svc.plus');
|
||||
expect(profile.port, 443);
|
||||
expect(profile.tls, isTrue);
|
||||
});
|
||||
|
||||
test('wss endpoint is reachable', () async {
|
||||
if (!runRealE2E) {
|
||||
return;
|
||||
}
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final request = await client.getUrl(
|
||||
Uri.parse('https://openclaw.svc.plus'),
|
||||
);
|
||||
final response = await request.close();
|
||||
expect(response.statusCode, anyOf(200, 400, 401, 403, 404, 426));
|
||||
});
|
||||
|
||||
test(
|
||||
'gateway token is wired for future remote runtime coverage',
|
||||
() {
|
||||
if (!runRealE2E) {
|
||||
return;
|
||||
}
|
||||
expect(
|
||||
openclawGatewayToken.isNotEmpty,
|
||||
isTrue,
|
||||
reason:
|
||||
'Set OPENCLAW_GATEWAY_TOKEN to run remote gateway-chat coverage against openclaw.svc.plus.',
|
||||
);
|
||||
},
|
||||
skip: !runRealE2E || openclawGatewayToken.isNotEmpty,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _ArtifactScenario {
|
||||
const _ArtifactScenario({
|
||||
required this.skill,
|
||||
required this.prompt,
|
||||
required this.expectedSuffix,
|
||||
this.prepare,
|
||||
});
|
||||
|
||||
final String skill;
|
||||
final String prompt;
|
||||
final String expectedSuffix;
|
||||
final Future<void> Function(Directory root)? prepare;
|
||||
}
|
||||
|
||||
final _artifactScenarios = <_ArtifactScenario>[
|
||||
const _ArtifactScenario(
|
||||
skill: 'docx',
|
||||
prompt:
|
||||
'Use the docx skill to create report.docx in the working directory. Include a title and a 2-column table with two rows.',
|
||||
expectedSuffix: '/report.docx',
|
||||
),
|
||||
const _ArtifactScenario(
|
||||
skill: 'pptx',
|
||||
prompt:
|
||||
'Use the pptx skill to create deck.pptx in the working directory with two slides titled Intro and Summary.',
|
||||
expectedSuffix: '/deck.pptx',
|
||||
),
|
||||
const _ArtifactScenario(
|
||||
skill: 'xlsx',
|
||||
prompt:
|
||||
'Use the xlsx skill to create sales.xlsx in the working directory with a totals formula column.',
|
||||
expectedSuffix: '/sales.xlsx',
|
||||
),
|
||||
const _ArtifactScenario(
|
||||
skill: 'pdf',
|
||||
prompt:
|
||||
'Use the pdf skill to create summary.pdf in the working directory with a one-page summary of bridge validation.',
|
||||
expectedSuffix: '/summary.pdf',
|
||||
),
|
||||
_ArtifactScenario(
|
||||
skill: 'image-resizer',
|
||||
prompt:
|
||||
'Use the image-resizer skill to resize input.png to 1200x800 and save the output as resized.png in the working directory.',
|
||||
expectedSuffix: '/resized.png',
|
||||
prepare: (root) async {
|
||||
final bytes = base64Decode(_tinyPngBase64);
|
||||
await File('${root.path}/input.png').writeAsBytes(bytes, flush: true);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
GoTaskServiceRequest _buildRequest({
|
||||
required String providerId,
|
||||
required String sessionId,
|
||||
required String threadId,
|
||||
required String workingDirectory,
|
||||
required String prompt,
|
||||
List<String> selectedSkills = const <String>[],
|
||||
bool resumeSession = false,
|
||||
}) {
|
||||
return GoTaskServiceRequest(
|
||||
sessionId: sessionId,
|
||||
threadId: threadId,
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
prompt: prompt,
|
||||
workingDirectory: workingDirectory,
|
||||
model: '',
|
||||
thinking: '',
|
||||
selectedSkills: selectedSkills,
|
||||
inlineAttachments: const <GatewayChatAttachmentPayload>[],
|
||||
localAttachments: const <CollaborationAttachment>[],
|
||||
aiGatewayBaseUrl: '',
|
||||
aiGatewayApiKey: '',
|
||||
agentId: '',
|
||||
metadata: const <String, dynamic>{},
|
||||
routing: ExternalCodeAgentAcpRoutingConfig(
|
||||
mode: ExternalCodeAgentAcpRoutingMode.explicit,
|
||||
preferredGatewayTarget: 'local',
|
||||
explicitExecutionTarget: 'singleAgent',
|
||||
explicitProviderId: providerId,
|
||||
explicitModel: '',
|
||||
explicitSkills: selectedSkills,
|
||||
allowSkillInstall: false,
|
||||
availableSkills: const <ExternalCodeAgentAcpAvailableSkill>[],
|
||||
),
|
||||
provider: SingleAgentProviderCopy.fromJsonValue(providerId),
|
||||
remoteWorkingDirectoryHint: '',
|
||||
resumeSession: resumeSession,
|
||||
);
|
||||
}
|
||||
169
test/runtime/desktop_thread_artifact_sync_test.dart
Normal file
169
test/runtime/desktop_thread_artifact_sync_test.dart
Normal file
@ -0,0 +1,169 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/desktop_thread_artifact_sync.dart';
|
||||
import 'package:xworkmate/runtime/go_task_service_client.dart';
|
||||
|
||||
void main() {
|
||||
group('syncInlineArtifactsToLocalWorkspace', () {
|
||||
test('writes inline artifacts into the local workspace', () async {
|
||||
final root = Directory.systemTemp.createTempSync(
|
||||
'xworkmate-artifact-sync-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await root.exists()) {
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final result = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: root,
|
||||
artifacts: const <GoTaskServiceArtifact>[
|
||||
GoTaskServiceArtifact(
|
||||
relativePath: 'reports/weekly.docx',
|
||||
label: 'weekly.docx',
|
||||
contentType:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
encoding: 'utf8',
|
||||
content: 'docx-bytes-placeholder',
|
||||
downloadUrl: '',
|
||||
sizeBytes: null,
|
||||
sha256: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.wroteArtifact, isTrue);
|
||||
expect(result.writtenFiles, hasLength(1));
|
||||
final file = File(result.writtenFiles.single);
|
||||
expect(await file.exists(), isTrue);
|
||||
expect(await file.readAsString(), 'docx-bytes-placeholder');
|
||||
});
|
||||
|
||||
test(
|
||||
'sanitizes parent traversal and preserves nested relative paths',
|
||||
() async {
|
||||
final root = Directory.systemTemp.createTempSync(
|
||||
'xworkmate-artifact-sanitize-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await root.exists()) {
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final result = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: root,
|
||||
artifacts: const <GoTaskServiceArtifact>[
|
||||
GoTaskServiceArtifact(
|
||||
relativePath: '../unsafe/../../slides/demo.pptx',
|
||||
label: 'demo.pptx',
|
||||
contentType:
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
encoding: 'utf8',
|
||||
content: 'pptx-bytes-placeholder',
|
||||
downloadUrl: '',
|
||||
sizeBytes: null,
|
||||
sha256: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
result.writtenFiles.single,
|
||||
endsWith('/unsafe/slides/demo.pptx'),
|
||||
);
|
||||
expect(File('${root.path}/demo.pptx').existsSync(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('creates versioned files when the target path already exists', () async {
|
||||
final root = Directory.systemTemp.createTempSync(
|
||||
'xworkmate-artifact-version-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await root.exists()) {
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final original = File('${root.path}/table.xlsx');
|
||||
await original.writeAsString('v1');
|
||||
|
||||
final first = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: root,
|
||||
artifacts: const <GoTaskServiceArtifact>[
|
||||
GoTaskServiceArtifact(
|
||||
relativePath: 'table.xlsx',
|
||||
label: 'table.xlsx',
|
||||
contentType:
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
encoding: 'utf8',
|
||||
content: 'v2',
|
||||
downloadUrl: '',
|
||||
sizeBytes: null,
|
||||
sha256: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
final second = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: root,
|
||||
artifacts: const <GoTaskServiceArtifact>[
|
||||
GoTaskServiceArtifact(
|
||||
relativePath: 'table.xlsx',
|
||||
label: 'table.xlsx',
|
||||
contentType:
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
encoding: 'utf8',
|
||||
content: 'v3',
|
||||
downloadUrl: '',
|
||||
sizeBytes: null,
|
||||
sha256: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(first.writtenFiles.single, endsWith('/table.v2.xlsx'));
|
||||
expect(second.writtenFiles.single, endsWith('/table.v3.xlsx'));
|
||||
expect(await File(first.writtenFiles.single).readAsString(), 'v2');
|
||||
expect(await File(second.writtenFiles.single).readAsString(), 'v3');
|
||||
});
|
||||
|
||||
test('decodes base64 inline content for binary-like artifacts', () async {
|
||||
final root = Directory.systemTemp.createTempSync(
|
||||
'xworkmate-artifact-base64-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await root.exists()) {
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final payload = base64Encode(<int>[1, 2, 3, 4, 5]);
|
||||
final result = await syncInlineArtifactsToLocalWorkspace(
|
||||
root: root,
|
||||
artifacts: <GoTaskServiceArtifact>[
|
||||
GoTaskServiceArtifact(
|
||||
relativePath: 'images/resized.png',
|
||||
label: 'resized.png',
|
||||
contentType: 'image/png',
|
||||
encoding: 'base64',
|
||||
content: payload,
|
||||
downloadUrl: '',
|
||||
sizeBytes: 5,
|
||||
sha256: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(await File(result.writtenFiles.single).readAsBytes(), <int>[
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user