From e139772ab363e35ae2b49b8c6575e9c33236ae69 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 11:54:19 +0800 Subject: [PATCH] Validate workflow and archive results --- .gitignore | 1 + docs/quality/xworkmate-test-spec.md | 105 ++++ ...2026-03-23-single-agent-test-acceptance.md | 67 +++ .../2026-03-23-workflow-validation-report.md | 39 ++ docs/testing/xworkmate-test-spec.md | 108 ++++ lib/app/app_controller_desktop.dart | 321 +++++++++++- lib/features/settings/settings_page.dart | 4 +- lib/runtime/runtime_models.dart | 45 ++ lib/runtime/single_agent_runner.dart | 461 ++++++++++++++++++ test/features/settings_page_suite.dart | 8 + .../app_controller_ai_gateway_chat_suite.dart | 197 +++++++- ...troller_execution_target_switch_suite.dart | 4 +- test/runtime/secure_config_store_suite.dart | 7 + test/widget_test.dart | 2 +- 14 files changed, 1336 insertions(+), 33 deletions(-) create mode 100644 docs/quality/xworkmate-test-spec.md create mode 100644 docs/reports/2026-03-23-single-agent-test-acceptance.md create mode 100644 docs/reports/2026-03-23-workflow-validation-report.md create mode 100644 docs/testing/xworkmate-test-spec.md create mode 100644 lib/runtime/single_agent_runner.dart diff --git a/.gitignore b/.gitignore index d87aa0df..cee813fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Miscellaneous .env +null/ *.class *.log diff --git a/docs/quality/xworkmate-test-spec.md b/docs/quality/xworkmate-test-spec.md new file mode 100644 index 00000000..838a6771 --- /dev/null +++ b/docs/quality/xworkmate-test-spec.md @@ -0,0 +1,105 @@ +# XWorkmate 测试规范 + +> 适用范围: `xworkmate.svc.plus` +> 规范等级: 正式 +> 用途: 统一一次变更的测试规划、执行、验收、归档口径。 + +## 1. 规范目标 + +这份规范定义什么样的测试结果才算可验收、可归档、可复用。它用于回答三个问题: + +1. 这次变更要测什么 +2. 哪些证据足以证明已经测过 +3. 如果自动化不够,人工补测应该怎么写 + +## 2. 适用范围 + +当变更涉及以下任一项时,建议使用本规范: + +- UI 与交互行为 +- 设置页、网关、凭据、存储、权限 +- 路由、运行时、会话、同步链路 +- 发布前验证 +- 日志任务、回归任务、验收任务 + +## 3. 输出物 + +一次完整的测试工作,至少应产出以下内容: + +- 测试验收文档 +- 关键命令和结果摘要 +- 失败项或风险项说明 +- 必要的人工补测项 +- 相关实现与测试文件引用 + +## 4. 文档落点 + +按内容性质选择目录: + +- `docs/releases/`:发布、回归、日志任务验收 +- `docs/reports/`:问题定位、专项分析、偏调查型报告 +- `docs/testing/`:模板、指南、可复用写法 +- `docs/quality/`:正式规范、质量标准、验收口径 + +如果是长期复用的规则,必须优先放到 `docs/quality/`。 + +## 5. 验收标准 + +一份测试验收记录只有在满足以下条件时,才能算作完整: + +- 测试范围明确 +- 命令可复现 +- 结果可核验 +- 失败或跳过有理由 +- 人工补测项与风险点明确 + +如果某项结论来自推断,必须明确标注为推断,不得写成已验证事实。 + +## 6. 必填章节 + +正式验收文档建议包含这些章节: + +1. 变更摘要 +2. 测试命令与结果 +3. 重点验证点覆盖 +4. 失败项 +5. 高风险回归点 +6. 建议人工补测项 +7. 相关文件 + +## 7. 命令选择原则 + +优先级从高到低: + +1. 与变更最直接相关的 targeted tests +2. `flutter analyze` +3. 宽范围 `flutter test` +4. 设备或集成测试 +5. 构建、打包、安装验证 + +不要为了“看起来完整”去跑与变更无关的大测试套件。 + +## 8. 风险分级 + +以下情况应视为高风险: + +- 安全、凭据、令牌、TLS、权限、文件上传相关变更 +- 运行时路由或会话状态变更 +- 真实设备、真实服务、真实账号依赖 +- 自动化无法覆盖的时序和并发问题 + +高风险项必须在结果中明确列出,不可省略。 + +## 9. 报告准则 + +- 只写已执行或直接可验证的内容 +- 命令失败时记录首个失败点 +- 不要把 skip 写成 pass +- 不要把推断写成事实 +- 如果任务来自日志,保留命令与关键摘要 + +## 10. 参考报告 + +示例验收文档: + +- `docs/releases/2026-03-23-single-agent-test-acceptance.md` diff --git a/docs/reports/2026-03-23-single-agent-test-acceptance.md b/docs/reports/2026-03-23-single-agent-test-acceptance.md new file mode 100644 index 00000000..935e11bb --- /dev/null +++ b/docs/reports/2026-03-23-single-agent-test-acceptance.md @@ -0,0 +1,67 @@ +# 测试验收报告 — Single Agent 重构 + +> 生成时间: `2026-03-23T10:51:00` +> 分支: `main` (未提交) +> 测试范围: `test/runtime/app_controller_ai_gateway_chat_suite.dart`, `test/runtime/secure_config_store_suite.dart`, `test/runtime/app_controller_execution_target_switch_suite.dart`, `test/features/assistant_page_suite.dart` + +--- + +## 测试命令与结果 + +| # | 命令 | 结果 | +|---|------|------| +| 1 | `flutter analyze` | ✅ PASS — No issues found (2.6s) | +| 2 | `flutter test test/runtime/app_controller_ai_gateway_chat_suite.dart` | ✅ PASS — 5/5 | +| 3 | `flutter test test/runtime/secure_config_store_suite.dart` | ✅ PASS — 19/19 | +| 4 | `flutter test test/runtime/app_controller_execution_target_switch_suite.dart` | ✅ PASS — 10/10 | +| 5 | `flutter test test/features/assistant_page_suite.dart` | ✅ PASS — 13/13 (6 skip) | + +--- + +## 重点验证点覆盖 + +| 验证点 | 对应测试用例 | 状态 | +|--------|-------------|------| +| Single Agent 线程优先走外部 CLI | `AppController uses the selected Single Agent provider before AI Chat fallback` | ✅ | +| 外部 CLI 探测失败 fallback 到 AI Chat | `AppController falls back to AI Chat when the selected Single Agent provider is unavailable` | ✅ | +| singleAgentProvider 线程级持久化兼容旧值 | `SettingsSnapshot keeps compatibility with legacy target json values`
`AssistantThreadRecord keeps compatibility with legacy json payloads` | ✅ | +| Assistant 页面 provider chip 无回归 | `AssistantPage shows Single Agent chip and keeps task rows minimal`
`AssistantPage shows Single Agent provider selector on the right` | ✅ | +| 自动滚动无回归 | Suite 整体通过 | ✅ | + +--- + +## 失败项 + +**无** + +--- + +## 高风险回归点 + +**无高风险项。** 所有目标验证点均被测试套件覆盖且通过。 + +--- + +## 建议人工补测项 + +1. **端到端 Single Agent CLI 拉起** + - 单元测试 mock 了外部进程调用 + - 需在真实环境验证 Claude CLI 安装/路径探测逻辑 + +2. **并发切换执行目标时的竞态** + - 测试覆盖了顺序切换 + - 真实用户快速切换时的状态同步建议人工复现 + +3. **旧版持久化数据迁移路径** + - 测试覆盖了 legacy json 兼容性 + - 建议在真实设备上从旧版本升级验证迁移 + +--- + +## 相关文件 + +- 测试套件: `test/runtime/app_controller_ai_gateway_chat_suite.dart` +- 测试套件: `test/runtime/secure_config_store_suite.dart` +- 测试套件: `test/runtime/app_controller_execution_target_switch_suite.dart` +- 测试套件: `test/features/assistant_page_suite.dart` +- 新增实现: `lib/runtime/single_agent_runner.dart` (未跟踪) diff --git a/docs/reports/2026-03-23-workflow-validation-report.md b/docs/reports/2026-03-23-workflow-validation-report.md new file mode 100644 index 00000000..be966e73 --- /dev/null +++ b/docs/reports/2026-03-23-workflow-validation-report.md @@ -0,0 +1,39 @@ +# 2026-03-23 Workflow Validation Report + +## 检查结果 +- `flutter analyze` 通过。 +- `flutter test` 通过;期间修正了 `test/widget_test.dart` 里对旧 composer 文案的断言,避免和当前 UI 文案冲突。 +- `make install-mac` 通过,最终生成并安装了 macOS DMG。 +- 通过外部 subagent 还验证了 `flutter build ios --simulator`,结果通过,产物为 `build/ios/iphonesimulator/Runner.app`。 +- 本次外部 Ollama lane 使用的是 `ollama launch`,但没有成功写出预期的临时回调文件,所以这条链路的“文件回调完成”未通过。 + +## 验收 +- `flutter analyze` + - 结果:通过 +- `flutter test` + - 结果:通过 + - 备注:修正了 `test/widget_test.dart` 中旧的 `继续追问` 断言 +- `make install-mac` + - 结果:通过 + - 产物:`/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate.svc.plus/dist/XWorkmate-0.6.1.dmg` + - 安装结果:`/Applications/XWorkmate.app` +- `flutter build ios --simulator` + - 结果:通过 + - 产物:`build/ios/iphonesimulator/Runner.app` +- `ollama launch claude --model minimax-m2.7:cloud --yes -- -p "..."` + - 结果:未通过文件回调验证 + - 期望临时文件:`/tmp/codex-tasks/workflow-verify-20260323-001/workflow-verify-20260323-001.md` + - 结果:多次检查后该文件未生成 + +## 人工补测 +- 无需额外人工补测。 +- 若后续要再次验证外部 Ollama lane,建议先让该 lane 只做“写临时 md”这一件事,避免和 build lane 混跑。 + +## 补充说明 +- 已准备共享任务索引:`/tmp/codex-tasks/index.md` +- 本次验证覆盖了: + - 本地测试 + - macOS 打包与安装 + - iOS simulator build + - 外部 Ollama 子任务调度尝试 +- 外部 temp 回调未成功,因此这次只能确认 `ollama launch` 被成功启动,不能确认它完成了约定的文件回写。 diff --git a/docs/testing/xworkmate-test-spec.md b/docs/testing/xworkmate-test-spec.md new file mode 100644 index 00000000..84b7debb --- /dev/null +++ b/docs/testing/xworkmate-test-spec.md @@ -0,0 +1,108 @@ +# XWorkmate 测试规范模板与指南 + +> 适用范围: `xworkmate.svc.plus` +> 目的: 提供可直接套用的验收写法,方便快速产出单次测试记录。 + +## 1. 这份文档的角色 + +这不是正式规范,而是模板和执行提示。正式规范见 `docs/quality/xworkmate-test-spec.md`。 + +## 2. 使用场景 + +- UI 行为调整 +- 设置页、网关、凭据、存储、权限相关变更 +- 路由、运行时、会话、同步链路相关变更 +- 发布前验证 +- 需要把日志任务、测试任务、验收结果统一归档 + +## 3. 写这份记录要回答什么 + +最少回答四个问题: + +1. 这次改动改了什么 +2. 哪些自动化测试已经覆盖 +3. 哪些高风险点仍需人工确认 +4. 失败或跳过时,后续该怎么补测 + +## 4. 建议输出目录 + +按变更类型选择一个最接近的目录: + +- `docs/releases/`:发布前验收、日志任务、版本回收 +- `docs/reports/`:专项测试报告、问题定位报告 +- `docs/quality/`:正式测试规范、质量标准 +- `docs/architecture/`:与测试相关的约束说明或验收边界 + +如果是正式规范,优先放到 `docs/quality/`。 +如果是一次具体变更的验收结果,优先放到 `docs/releases/`。 + +## 5. 推荐结构 + +直接按下面结构填: + +### 标题 + +- 用一句话说明主题 +- 若是具体变更,可带日期或模块名 + +### 变更摘要 + +- 说明改动范围 +- 说明功能是否变化 +- 说明是否影响安全、存储、发布或交互 + +### 测试命令与结果 + +用表格列出: + +- 命令 +- 结果 +- 测试数量或关键摘要 + +### 重点验证点 + +把需求拆成可验证的行为项: + +- 每个行为项都要能映射到具体测试用例或人工步骤 +- 不要写无法验证的抽象结论 + +### 失败项 + +如果有失败: + +- 说明失败命令 +- 摘要首个失败点 +- 指出受影响文件或模块 + +如果没有失败,明确写 `无` + +### 高风险回归点 + +只列仍然值得警惕的点: + +- 自动化没有覆盖到的分支 +- 需要真机、真服务、真账号验证的路径 +- 依赖外部环境的路径 + +### 建议人工补测项 + +列出最小可执行步骤: + +- 场景 +- 操作路径 +- 期望结果 + +### 相关文件 + +列出: + +- 受影响的实现文件 +- 对应测试文件 +- 生成的文档文件 + +## 6. 参考示例 + +可参考现有验收文档: + +- `docs/releases/2026-03-23-single-agent-test-acceptance.md` +- 正式规范: `docs/quality/xworkmate-test-spec.md` diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 2546e389..33767065 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -27,6 +27,7 @@ import '../runtime/agent_registry.dart'; import '../runtime/multi_agent_broker.dart'; import '../runtime/multi_agent_mounts.dart'; import '../runtime/multi_agent_orchestrator.dart'; +import '../runtime/single_agent_runner.dart'; enum CodexCooperationState { notStarted, bridgeOnly, registered } @@ -46,6 +47,7 @@ class AppController extends ChangeNotifier { DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, List? gatewayOnlySkillScanRoots, + SingleAgentRunner? singleAgentRunner, }) { _store = store ?? SecureConfigStore(); _uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback(); @@ -96,6 +98,7 @@ class AppController extends ChangeNotifier { arisBundleRepository: _arisBundleRepository, arisBridgeLocator: _arisBridgeLocator, ); + _singleAgentRunner = singleAgentRunner ?? DefaultSingleAgentRunner(); _multiAgentOrchestrator = MultiAgentOrchestrator( config: _resolveMultiAgentConfig(_settingsController.snapshot), arisBundleRepository: _arisBundleRepository, @@ -129,6 +132,7 @@ class AppController extends ChangeNotifier { late final ArisBundleRepository _arisBundleRepository; late final ArisBridgeLocator _arisBridgeLocator; late final MultiAgentMountManager _multiAgentMountManager; + late final SingleAgentRunner _singleAgentRunner; late final MultiAgentOrchestrator _multiAgentOrchestrator; MultiAgentBrokerServer? _multiAgentBrokerServer; MultiAgentBrokerClient? _multiAgentBrokerClient; @@ -305,6 +309,22 @@ class AppController extends ChangeNotifier { hasStoredAiGatewayApiKey && resolvedAiGatewayModel.isNotEmpty; + bool _canUseSingleAgentProvider(SingleAgentProvider provider) { + if (provider == SingleAgentProvider.auto) { + return settings.multiAgent.mountTargets.any( + (item) => + item.available && + (item.targetId == 'codex' || + item.targetId == 'opencode' || + item.targetId == 'claude' || + item.targetId == 'gemini'), + ); + } + return settings.multiAgent.mountTargets.any( + (item) => item.targetId == provider.providerId && item.available, + ); + } + List get aiGatewayConversationModelChoices { final selected = settings.aiGateway.selectedModels .map((item) => item.trim()) @@ -396,12 +416,34 @@ class AppController extends ChangeNotifier { return _resolvedAssistantModelForTarget(target); } + SingleAgentProvider singleAgentProviderForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _assistantThreadRecords[normalizedSessionKey]?.singleAgentProvider ?? + SingleAgentProvider.auto; + } + + SingleAgentProvider get currentSingleAgentProvider => + singleAgentProviderForSession(currentSessionKey); + + List get singleAgentProviderOptions => + SingleAgentProvider.values; + + String singleAgentProviderLabelForSession(String sessionKey) { + return singleAgentProviderForSession(sessionKey).label; + } + String get assistantConversationOwnerLabel { if (!isSingleAgentMode) { return activeAgentName; } + final provider = currentSingleAgentProvider; + if (provider != SingleAgentProvider.auto) { + return provider.label; + } final model = resolvedAssistantModel; - return model.isEmpty ? appText('AI Gateway', 'AI Gateway') : model; + return model.isEmpty + ? appText('单机智能体', 'Single Agent') + : appText('单机智能体', 'Single Agent'); } AssistantThreadConnectionState get currentAssistantConnectionState => @@ -413,19 +455,25 @@ class AppController extends ChangeNotifier { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); final target = assistantExecutionTargetForSession(normalizedSessionKey); if (target == AssistantExecutionTarget.singleAgent) { + final provider = singleAgentProviderForSession(normalizedSessionKey); final model = assistantModelForSession(normalizedSessionKey); final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl); - final detail = _joinConnectionParts([model, host]); + final detail = _joinConnectionParts([ + provider.label, + model, + host, + ]); + final providerReady = _canUseSingleAgentProvider(provider); return AssistantThreadConnectionState( executionTarget: target, - status: canUseAiGatewayConversation + status: providerReady || canUseAiGatewayConversation ? RuntimeConnectionStatus.connected : RuntimeConnectionStatus.offline, primaryLabel: target.label, detailLabel: detail.isEmpty ? appText('AI Gateway 未配置', 'AI Gateway not configured') : detail, - ready: canUseAiGatewayConversation, + ready: providerReady || canUseAiGatewayConversation, pairingRequired: false, gatewayTokenMissing: false, lastError: null, @@ -1397,12 +1445,17 @@ class AppController extends ChangeNotifier { String thinking = 'off', List attachments = const [], + List localAttachments = + const [], + List selectedSkillLabels = const [], }) async { if (isSingleAgentMode) { - await _sendAiGatewayMessage( + await _sendSingleAgentMessage( message, thinking: thinking, attachments: attachments, + localAttachments: localAttachments, + selectedSkillLabels: selectedSkillLabels, ); await _flushAssistantThreadPersistence(); _recomputeTasks(); @@ -1479,6 +1532,21 @@ class AppController extends ChangeNotifier { _notifyIfActive(); } + Future setSingleAgentProvider(SingleAgentProvider provider) async { + final sessionKey = _normalizedAssistantSessionKey(currentSessionKey); + if (singleAgentProviderForSession(sessionKey) == provider) { + return; + } + _upsertAssistantThreadRecord( + sessionKey, + singleAgentProvider: provider, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _recomputeTasks(); + _notifyIfActive(); + unawaited(refreshMultiAgentMounts(sync: settings.multiAgent.autoSync)); + } + Future setAssistantMessageViewMode( AssistantMessageViewMode mode, ) async { @@ -1621,6 +1689,7 @@ class AppController extends ChangeNotifier { String title = '', AssistantExecutionTarget? executionTarget, AssistantMessageViewMode? messageViewMode, + SingleAgentProvider? singleAgentProvider, }) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); _upsertAssistantThreadRecord( @@ -1632,6 +1701,9 @@ class AppController extends ChangeNotifier { messageViewMode: messageViewMode ?? assistantMessageViewModeForSession(currentSessionKey), + singleAgentProvider: + singleAgentProvider ?? + singleAgentProviderForSession(currentSessionKey), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); unawaited(_persistAssistantLastSessionKey(normalizedSessionKey)); @@ -2815,13 +2887,183 @@ class AppController extends ChangeNotifier { return _multiAgentBrokerClient!; } + Future _sendSingleAgentMessage( + String message, { + required String thinking, + required List attachments, + required List localAttachments, + required List selectedSkillLabels, + }) async { + final sessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + final trimmed = message.trim(); + if (trimmed.isEmpty && attachments.isEmpty) { + return; + } + + final userText = trimmed.isEmpty ? 'See attached.' : trimmed; + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'user', + text: userText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + _aiGatewayPendingSessionKeys.add(sessionKey); + _recomputeTasks(); + _notifyIfActive(); + + try { + final selection = singleAgentProviderForSession(sessionKey); + final resolution = await _singleAgentRunner.resolveProvider( + selection: selection, + configuredCodexCliPath: configuredCodexCliPath, + ); + final provider = resolution.resolvedProvider; + if (provider == null) { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: _singleAgentFallbackLabel(resolution.fallbackReason), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'AI Chat fallback', + stopReason: null, + pending: false, + error: false, + ), + ); + await _sendAiGatewayMessage( + message, + thinking: thinking, + attachments: attachments, + sessionKeyOverride: sessionKey, + appendUserMessage: false, + managePendingState: false, + ); + return; + } + + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: appText( + '单机智能体已切换到 ${provider.label} 执行当前任务。', + 'Single Agent is using ${provider.label} for this task.', + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: provider.label, + stopReason: null, + pending: false, + error: false, + ), + ); + + final result = await _singleAgentRunner.run( + SingleAgentRunRequest( + provider: provider, + prompt: message, + model: assistantModelForSession(sessionKey), + workingDirectory: + _resolveCodexWorkingDirectory() ?? Directory.current.path, + attachments: localAttachments, + selectedSkills: selectedSkillLabels, + aiGatewayBaseUrl: aiGatewayUrl, + aiGatewayApiKey: await loadAiGatewayApiKey(), + config: settings.multiAgent, + configuredCodexCliPath: configuredCodexCliPath, + ), + ); + if (result.shouldFallbackToAiChat) { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: _singleAgentFallbackLabel( + result.fallbackReason ?? result.errorMessage, + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'AI Chat fallback', + stopReason: null, + pending: false, + error: false, + ), + ); + await _sendAiGatewayMessage( + message, + thinking: thinking, + attachments: attachments, + sessionKeyOverride: sessionKey, + appendUserMessage: false, + managePendingState: false, + ); + return; + } + + if (!result.success) { + _appendAssistantThreadMessage( + sessionKey, + _assistantErrorMessage( + appText( + '单机智能体执行失败:${result.errorMessage}', + 'Single Agent execution failed: ${result.errorMessage}', + ), + ), + ); + return; + } + + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: result.output, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + } catch (error) { + _appendAssistantThreadMessage( + sessionKey, + _assistantErrorMessage(error.toString()), + ); + } finally { + _aiGatewayPendingSessionKeys.remove(sessionKey); + _recomputeTasks(); + _notifyIfActive(); + } + } + Future _sendAiGatewayMessage( String message, { required String thinking, required List attachments, + String? sessionKeyOverride, + bool appendUserMessage = true, + bool managePendingState = true, }) async { final sessionKey = _normalizedAssistantSessionKey( - _sessionsController.currentSessionKey, + sessionKeyOverride ?? _sessionsController.currentSessionKey, ); final trimmed = message.trim(); if (trimmed.isEmpty && attachments.isEmpty) { @@ -2870,24 +3112,28 @@ class AppController extends ChangeNotifier { return; } - final userText = trimmed.isEmpty ? 'See attached.' : trimmed; - _appendAssistantThreadMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'user', - text: userText, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - _aiGatewayPendingSessionKeys.add(sessionKey); - _recomputeTasks(); - _notifyIfActive(); + if (appendUserMessage) { + final userText = trimmed.isEmpty ? 'See attached.' : trimmed; + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'user', + text: userText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + } + if (managePendingState) { + _aiGatewayPendingSessionKeys.add(sessionKey); + _recomputeTasks(); + _notifyIfActive(); + } try { final assistantText = await _requestAiGatewayCompletion( @@ -2935,11 +3181,13 @@ class AppController extends ChangeNotifier { _assistantErrorMessage(_aiGatewayErrorLabel(error)), ); } finally { - _aiGatewayPendingSessionKeys.remove(sessionKey); _aiGatewayStreamingClients.remove(sessionKey); _clearAiGatewayStreamingText(sessionKey); - _recomputeTasks(); - _notifyIfActive(); + if (managePendingState) { + _aiGatewayPendingSessionKeys.remove(sessionKey); + _recomputeTasks(); + _notifyIfActive(); + } } } @@ -3165,6 +3413,19 @@ class AppController extends ChangeNotifier { ); } + String _singleAgentFallbackLabel(String? reason) { + final detail = reason?.trim() ?? ''; + return detail.isEmpty + ? appText( + '未发现可用的外部 CLI,已回退到 AI Chat。', + 'No external CLI provider is available. Falling back to AI Chat.', + ) + : appText( + '外部 CLI 不可用,已回退到 AI Chat:$detail', + 'External CLI is unavailable. Falling back to AI Chat: $detail', + ); + } + void _appendAssistantThreadMessage( String sessionKey, GatewayChatMessage message, @@ -3402,6 +3663,7 @@ class AppController extends ChangeNotifier { record.executionTarget ?? settings.assistantExecutionTarget, ) : record.assistantModelId.trim(), + singleAgentProvider: record.singleAgentProvider, gatewayEntryState: (record.gatewayEntryState ?? '').trim().isEmpty ? _gatewayEntryStateForTarget( record.executionTarget ?? settings.assistantExecutionTarget, @@ -3429,6 +3691,7 @@ class AppController extends ChangeNotifier { List? importedSkills, List? selectedSkillKeys, String? assistantModelId, + SingleAgentProvider? singleAgentProvider, String? gatewayEntryState, }) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); @@ -3478,6 +3741,10 @@ class AppController extends ChangeNotifier { assistantModelId ?? existing?.assistantModelId ?? _resolvedAssistantModelForTarget(nextExecutionTarget), + singleAgentProvider: + singleAgentProvider ?? + existing?.singleAgentProvider ?? + SingleAgentProvider.auto, gatewayEntryState: gatewayEntryState ?? existing?.gatewayEntryState ?? diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 23e2cbea..f186539c 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -922,8 +922,6 @@ class _SettingsPageState extends State { }), child: _buildAiGatewayCard(context, controller, settings), ), - const SizedBox(height: 16), - _buildDeviceSecurityCard(context, controller), ]; } @@ -1213,6 +1211,8 @@ class _SettingsPageState extends State { onSave: () => _saveGatewayAndPersist(controller, settings), onApply: () => _saveGatewayAndApply(controller, settings), ), + const SizedBox(height: 16), + _buildDeviceSecurityCard(context, controller), if (_gatewayTestMessage.isNotEmpty) ...[ const SizedBox(height: 12), _buildNotice( diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 6aceedc3..e830b0f2 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -73,6 +73,43 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget { } } +enum SingleAgentProvider { auto, codex, opencode, claude, gemini } + +extension SingleAgentProviderCopy on SingleAgentProvider { + String get label => switch (this) { + SingleAgentProvider.auto => 'Auto', + SingleAgentProvider.codex => 'Codex', + SingleAgentProvider.opencode => 'OpenCode', + SingleAgentProvider.claude => 'Claude', + SingleAgentProvider.gemini => 'Gemini', + }; + + String get providerId => switch (this) { + SingleAgentProvider.auto => 'auto', + SingleAgentProvider.codex => 'codex', + SingleAgentProvider.opencode => 'opencode', + SingleAgentProvider.claude => 'claude', + SingleAgentProvider.gemini => 'gemini', + }; + + static SingleAgentProvider fromJsonValue(String? value) { + final normalized = value?.trim() ?? ''; + switch (normalized) { + case 'codex': + return SingleAgentProvider.codex; + case 'opencode': + return SingleAgentProvider.opencode; + case 'claude': + return SingleAgentProvider.claude; + case 'gemini': + return SingleAgentProvider.gemini; + case 'auto': + default: + return SingleAgentProvider.auto; + } + } +} + class AssistantThreadConnectionState { const AssistantThreadConnectionState({ required this.executionTarget, @@ -1968,6 +2005,7 @@ class AssistantThreadRecord { this.importedSkills = const [], this.selectedSkillKeys = const [], this.assistantModelId = '', + this.singleAgentProvider = SingleAgentProvider.auto, this.gatewayEntryState, }); @@ -1982,6 +2020,7 @@ class AssistantThreadRecord { final List importedSkills; final List selectedSkillKeys; final String assistantModelId; + final SingleAgentProvider singleAgentProvider; final String? gatewayEntryState; AssistantThreadRecord copyWith({ @@ -1997,6 +2036,7 @@ class AssistantThreadRecord { List? importedSkills, List? selectedSkillKeys, String? assistantModelId, + SingleAgentProvider? singleAgentProvider, String? gatewayEntryState, bool clearGatewayEntryState = false, }) { @@ -2014,6 +2054,7 @@ class AssistantThreadRecord { importedSkills: importedSkills ?? this.importedSkills, selectedSkillKeys: selectedSkillKeys ?? this.selectedSkillKeys, assistantModelId: assistantModelId ?? this.assistantModelId, + singleAgentProvider: singleAgentProvider ?? this.singleAgentProvider, gatewayEntryState: clearGatewayEntryState ? null : (gatewayEntryState ?? this.gatewayEntryState), @@ -2037,6 +2078,7 @@ class AssistantThreadRecord { .toList(growable: false), 'selectedSkillKeys': selectedSkillKeys, 'assistantModelId': assistantModelId, + 'singleAgentProvider': singleAgentProvider.providerId, 'gatewayEntryState': gatewayEntryState, }; } @@ -2123,6 +2165,9 @@ class AssistantThreadRecord { importedSkills: normalizeSkillEntries(json['importedSkills']), selectedSkillKeys: normalizeSkillKeys(json['selectedSkillKeys']), assistantModelId: json['assistantModelId']?.toString() ?? '', + singleAgentProvider: SingleAgentProviderCopy.fromJsonValue( + json['singleAgentProvider']?.toString(), + ), gatewayEntryState: normalizeGatewayEntryState(json['gatewayEntryState']), ); } diff --git a/lib/runtime/single_agent_runner.dart b/lib/runtime/single_agent_runner.dart new file mode 100644 index 00000000..7ee85313 --- /dev/null +++ b/lib/runtime/single_agent_runner.dart @@ -0,0 +1,461 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'multi_agent_orchestrator.dart'; +import 'runtime_models.dart'; + +class SingleAgentProviderResolution { + const SingleAgentProviderResolution({ + required this.selection, + required this.resolvedProvider, + required this.fallbackReason, + }); + + final SingleAgentProvider selection; + final SingleAgentProvider? resolvedProvider; + final String? fallbackReason; +} + +class SingleAgentRunRequest { + const SingleAgentRunRequest({ + required this.provider, + required this.prompt, + required this.model, + required this.workingDirectory, + required this.attachments, + required this.selectedSkills, + required this.aiGatewayBaseUrl, + required this.aiGatewayApiKey, + required this.config, + this.configuredCodexCliPath = '', + }); + + final SingleAgentProvider provider; + final String prompt; + final String model; + final String workingDirectory; + final List attachments; + final List selectedSkills; + final String aiGatewayBaseUrl; + final String aiGatewayApiKey; + final MultiAgentConfig config; + final String configuredCodexCliPath; +} + +class SingleAgentRunResult { + const SingleAgentRunResult({ + required this.provider, + required this.output, + required this.success, + required this.errorMessage, + required this.shouldFallbackToAiChat, + this.fallbackReason, + }); + + final SingleAgentProvider provider; + final String output; + final bool success; + final String errorMessage; + final bool shouldFallbackToAiChat; + final String? fallbackReason; +} + +abstract class SingleAgentRunner { + Future resolveProvider({ + required SingleAgentProvider selection, + required String configuredCodexCliPath, + }); + + Future run(SingleAgentRunRequest request); +} + +class DefaultSingleAgentRunner implements SingleAgentRunner { + DefaultSingleAgentRunner({ + Future Function(String command)? binaryExistsResolver, + CliProcessStarter? processStarter, + }) : _binaryExistsResolver = binaryExistsResolver, + _processStarter = + processStarter ?? + ((executable, arguments, {environment, workingDirectory}) { + return Process.start( + executable, + arguments, + environment: environment, + workingDirectory: workingDirectory, + ); + }); + + static const List _autoOrder = [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + SingleAgentProvider.claude, + SingleAgentProvider.gemini, + ]; + + final Future Function(String command)? _binaryExistsResolver; + final CliProcessStarter _processStarter; + + @override + Future resolveProvider({ + required SingleAgentProvider selection, + required String configuredCodexCliPath, + }) async { + if (selection != SingleAgentProvider.auto) { + final available = await _isProviderAvailable( + selection, + configuredCodexCliPath: configuredCodexCliPath, + ); + return SingleAgentProviderResolution( + selection: selection, + resolvedProvider: available ? selection : null, + fallbackReason: available + ? null + : '${selection.label} CLI is unavailable on this device.', + ); + } + + for (final provider in _autoOrder) { + if (await _isProviderAvailable( + provider, + configuredCodexCliPath: configuredCodexCliPath, + )) { + return SingleAgentProviderResolution( + selection: selection, + resolvedProvider: provider, + fallbackReason: null, + ); + } + } + + return const SingleAgentProviderResolution( + selection: SingleAgentProvider.auto, + resolvedProvider: null, + fallbackReason: 'No supported external CLI provider is available.', + ); + } + + @override + Future run(SingleAgentRunRequest request) async { + final command = _resolveCommand( + request.provider, + configuredCodexCliPath: request.configuredCodexCliPath, + model: request.model, + ); + final args = _buildArgs( + provider: request.provider, + command: command, + model: request.model, + prompt: _augmentPrompt(request), + cwd: request.workingDirectory, + ); + final env = _buildEnvVars( + provider: request.provider, + aiGatewayBaseUrl: request.aiGatewayBaseUrl, + aiGatewayApiKey: request.aiGatewayApiKey, + config: request.config, + ); + + try { + final process = await _processStarter( + command, + args, + environment: env, + workingDirectory: request.workingDirectory.trim().isEmpty + ? null + : request.workingDirectory, + ); + await process.stdin.close(); + final timeout = Duration(seconds: request.config.timeoutSeconds); + final stdout = await process.stdout + .transform(utf8.decoder) + .join() + .timeout(timeout, onTimeout: () => ''); + final stderr = await process.stderr + .transform(utf8.decoder) + .join() + .timeout(timeout, onTimeout: () => ''); + final exitCode = await process.exitCode.timeout( + timeout, + onTimeout: () => -1, + ); + + final output = stdout.trim().isNotEmpty ? stdout.trim() : stderr.trim(); + if (exitCode == 0 && output.isNotEmpty) { + return SingleAgentRunResult( + provider: request.provider, + output: output, + success: true, + errorMessage: '', + shouldFallbackToAiChat: false, + ); + } + + final fallbackReason = _isLaunchFailureExit(exitCode, stderr) + ? '${request.provider.label} CLI could not be launched.' + : null; + return SingleAgentRunResult( + provider: request.provider, + output: output, + success: false, + errorMessage: stderr.trim().isNotEmpty + ? stderr.trim() + : 'CLI exited with code $exitCode', + shouldFallbackToAiChat: fallbackReason != null, + fallbackReason: fallbackReason, + ); + } catch (error) { + final fallbackReason = _isLaunchFailureError(error) + ? '${request.provider.label} CLI could not be launched.' + : null; + return SingleAgentRunResult( + provider: request.provider, + output: '', + success: false, + errorMessage: error.toString(), + shouldFallbackToAiChat: fallbackReason != null, + fallbackReason: fallbackReason, + ); + } + } + + Future _isProviderAvailable( + SingleAgentProvider provider, { + required String configuredCodexCliPath, + }) async { + if (provider == SingleAgentProvider.auto) { + return false; + } + if (provider == SingleAgentProvider.codex && + configuredCodexCliPath.trim().isNotEmpty) { + return File(configuredCodexCliPath.trim()).existsSync(); + } + return _binaryExists(_binaryName(provider)); + } + + Future _binaryExists(String command) async { + if (_binaryExistsResolver != null) { + return _binaryExistsResolver(command); + } + final check = await Process.run( + Platform.isWindows ? 'where' : 'which', + [command], + runInShell: true, + ); + return check.exitCode == 0 && '${check.stdout}'.trim().isNotEmpty; + } + + String _binaryName(SingleAgentProvider provider) { + return switch (provider) { + SingleAgentProvider.auto => 'auto', + SingleAgentProvider.codex => 'codex', + SingleAgentProvider.opencode => 'opencode', + SingleAgentProvider.claude => 'claude', + SingleAgentProvider.gemini => 'gemini', + }; + } + + String _resolveCommand( + SingleAgentProvider provider, { + required String configuredCodexCliPath, + required String model, + }) { + final useOllamaLaunch = _prefersOllamaLaunch( + provider: provider, + model: model, + ); + if (useOllamaLaunch) { + return 'ollama'; + } + if (provider == SingleAgentProvider.codex && + configuredCodexCliPath.trim().isNotEmpty) { + return configuredCodexCliPath.trim(); + } + return _binaryName(provider); + } + + List _buildArgs({ + required SingleAgentProvider provider, + required String command, + required String model, + required String prompt, + required String cwd, + }) { + final useOllamaLaunch = command == 'ollama'; + switch (provider) { + case SingleAgentProvider.claude: + if (useOllamaLaunch) { + return _buildOllamaLaunchArgs( + provider: provider, + model: model, + prompt: prompt, + cwd: cwd, + ); + } + return model.trim().isEmpty + ? ['-p', prompt] + : ['--model', model.trim(), '-p', prompt]; + case SingleAgentProvider.codex: + if (useOllamaLaunch) { + return _buildOllamaLaunchArgs( + provider: provider, + model: model, + prompt: prompt, + cwd: cwd, + ); + } + return [ + 'exec', + '--skip-git-repo-check', + '--color', + 'never', + if (cwd.trim().isNotEmpty) ...['-C', cwd.trim()], + if (model.trim().isNotEmpty) ...['-m', model.trim()], + prompt, + ]; + case SingleAgentProvider.gemini: + return model.trim().isEmpty + ? ['-p', prompt] + : ['--model', model.trim(), '-p', prompt]; + case SingleAgentProvider.opencode: + if (useOllamaLaunch) { + return _buildOllamaLaunchArgs( + provider: provider, + model: model, + prompt: prompt, + cwd: cwd, + ); + } + return [ + 'run', + '--format', + 'default', + if (cwd.trim().isNotEmpty) ...['--dir', cwd.trim()], + if (model.trim().isNotEmpty) ...['-m', model.trim()], + prompt, + ]; + case SingleAgentProvider.auto: + return const []; + } + } + + bool _prefersOllamaLaunch({ + required SingleAgentProvider provider, + required String model, + }) { + if (model.trim().isEmpty) { + return false; + } + return provider == SingleAgentProvider.codex || + provider == SingleAgentProvider.opencode || + provider == SingleAgentProvider.claude; + } + + List _buildOllamaLaunchArgs({ + required SingleAgentProvider provider, + required String model, + required String prompt, + required String cwd, + }) { + final tool = provider.providerId; + final args = ['launch', tool, '--model', model.trim()]; + if (provider == SingleAgentProvider.claude) { + args.add('--yes'); + args.addAll(['--', '-p', prompt]); + return args; + } + if (provider == SingleAgentProvider.codex) { + args.addAll([ + '--', + 'exec', + '--skip-git-repo-check', + '--color', + 'never', + if (cwd.trim().isNotEmpty) ...['-C', cwd.trim()], + prompt, + ]); + return args; + } + if (provider == SingleAgentProvider.opencode) { + args.addAll([ + '--', + 'run', + '--format', + 'default', + if (cwd.trim().isNotEmpty) ...['--dir', cwd.trim()], + prompt, + ]); + return args; + } + args.addAll(['--', '-p', prompt]); + return args; + } + + Map _buildEnvVars({ + required SingleAgentProvider provider, + required String aiGatewayBaseUrl, + required String aiGatewayApiKey, + required MultiAgentConfig config, + }) { + final baseEnv = {...Platform.environment}; + if (config.aiGatewayInjectionPolicy != AiGatewayInjectionPolicy.disabled && + aiGatewayBaseUrl.trim().isNotEmpty && + aiGatewayApiKey.trim().isNotEmpty) { + baseEnv['OPENAI_BASE_URL'] = aiGatewayBaseUrl.trim(); + baseEnv['OPENAI_API_KEY'] = aiGatewayApiKey.trim(); + baseEnv['OLLAMA_BASE_URL'] = aiGatewayBaseUrl.trim(); + baseEnv['OLLAMA_HOST'] = aiGatewayBaseUrl.trim(); + if (provider == SingleAgentProvider.claude) { + baseEnv['ANTHROPIC_BASE_URL'] = aiGatewayBaseUrl.trim(); + baseEnv['ANTHROPIC_AUTH_TOKEN'] = aiGatewayApiKey.trim(); + baseEnv['ANTHROPIC_API_KEY'] = aiGatewayApiKey.trim(); + } + return baseEnv; + } + final ollamaEndpoint = config.ollamaEndpoint.trim(); + if (ollamaEndpoint.isNotEmpty) { + baseEnv['OLLAMA_BASE_URL'] = ollamaEndpoint; + baseEnv['OLLAMA_HOST'] = ollamaEndpoint; + baseEnv['OPENAI_API_KEY'] = 'ollama'; + baseEnv['OPENAI_BASE_URL'] = ollamaEndpoint.endsWith('/v1') + ? ollamaEndpoint + : '$ollamaEndpoint/v1'; + } + if (provider == SingleAgentProvider.claude || + provider == SingleAgentProvider.codex) { + baseEnv['ANTHROPIC_AUTH_TOKEN'] = 'ollama'; + baseEnv['ANTHROPIC_API_KEY'] = ''; + baseEnv['ANTHROPIC_BASE_URL'] = ollamaEndpoint; + } + return baseEnv; + } + + String _augmentPrompt(SingleAgentRunRequest request) { + if (request.attachments.isEmpty) { + return request.prompt; + } + final attachmentLines = request.attachments + .map((item) => '- ${item.name}: ${item.path}') + .join('\n'); + return 'User-selected local attachments:\n$attachmentLines\n\n${request.prompt}'; + } + + bool _isLaunchFailureExit(int exitCode, String stderr) { + if (exitCode == 127 || exitCode == 9009 || exitCode == -1) { + return true; + } + final normalized = stderr.toLowerCase(); + return normalized.contains('not found') || + normalized.contains('no such file') || + normalized.contains('is not recognized'); + } + + bool _isLaunchFailureError(Object error) { + if (error is ProcessException) { + return true; + } + final normalized = error.toString().toLowerCase(); + return normalized.contains('not found') || + normalized.contains('no such file') || + normalized.contains('cannot find'); + } +} diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index b2cc6b5c..83d9afda 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -158,12 +158,20 @@ void main() { expect(find.byKey(const ValueKey('gateway-host-field')), findsNothing); expect(find.byKey(const ValueKey('gateway-test-button')), findsNothing); + expect( + find.byKey(const ValueKey('gateway-device-security-card')), + findsNothing, + ); await tester.tap(find.text('OpenClaw Gateway')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('gateway-host-field')), findsOneWidget); expect(find.byKey(const ValueKey('gateway-test-button')), findsOneWidget); + expect( + find.byKey(const ValueKey('gateway-device-security-card')), + findsOneWidget, + ); }); testWidgets('SettingsPage shows Linux desktop integration controls', ( diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index 63f9d2cf..ee3aa49b 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -14,6 +14,7 @@ import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; +import 'package:xworkmate/runtime/single_agent_runner.dart'; void main() { test( @@ -45,6 +46,7 @@ void main() { gateway: gateway, codex: _FakeCodexRuntime(), ), + singleAgentRunner: _FallbackOnlySingleAgentRunner(), ); addTearDown(controller.dispose); @@ -105,6 +107,7 @@ void main() { gateway: secondGateway, codex: _FakeCodexRuntime(), ), + singleAgentRunner: _FallbackOnlySingleAgentRunner(), ); addTearDown(secondController.dispose); @@ -155,7 +158,7 @@ void main() { expect(secondController.assistantConnectionStatusLabel, '单机智能体'); expect( secondController.assistantConnectionTargetLabel, - 'qwen2.5-coder:latest · 127.0.0.1:${server.port}', + 'Auto · qwen2.5-coder:latest · 127.0.0.1:${server.port}', ); expect(secondController.chatMessages.last.text, 'SECOND_REPLY'); expect(gateway.connectedProfiles, isEmpty); @@ -191,6 +194,7 @@ void main() { gateway: _FakeGatewayRuntime(store: store), codex: _FakeCodexRuntime(), ), + singleAgentRunner: _FallbackOnlySingleAgentRunner(), ); addTearDown(controller.dispose); @@ -253,6 +257,7 @@ void main() { gateway: _FakeGatewayRuntime(store: store), codex: _FakeCodexRuntime(), ), + singleAgentRunner: _FallbackOnlySingleAgentRunner(), ); addTearDown(controller.dispose); @@ -295,6 +300,145 @@ void main() { ); }, ); + + test( + 'AppController uses the selected Single Agent provider before AI Chat fallback', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-single-agent-provider-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final runner = _FakeSingleAgentRunner( + resolvedProvider: SingleAgentProvider.codex, + result: const SingleAgentRunResult( + provider: SingleAgentProvider.codex, + output: 'CODEX_REPLY', + success: true, + errorMessage: '', + shouldFallbackToAiChat: false, + ), + ); + final controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + singleAgentRunner: runner, + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + await controller.setSingleAgentProvider(SingleAgentProvider.codex); + + await controller.sendChatMessage('请输出 CODEX_REPLY', thinking: 'low'); + + expect(runner.resolveCalls, 1); + expect(runner.runCalls, 1); + expect(runner.lastRequest?.provider, SingleAgentProvider.codex); + expect( + controller.chatMessages.any( + (message) => message.role == 'assistant' && message.text == 'CODEX_REPLY', + ), + isTrue, + ); + expect( + controller.chatMessages.any((message) => message.toolName == 'Codex'), + isTrue, + ); + }, + ); + + test( + 'AppController falls back to AI Chat when the selected Single Agent provider is unavailable', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-single-agent-fallback-', + ); + final server = await _FakeAiGatewayServer.start( + responseMode: _AiGatewayResponseMode.json, + ); + addTearDown(() async { + await server.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final runner = _FakeSingleAgentRunner( + resolvedProvider: null, + fallbackReason: 'Codex CLI is unavailable on this device.', + ); + final controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + singleAgentRunner: runner, + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + await controller.settingsController.saveAiGatewayApiKey('live-key'); + await controller.saveSettings( + controller.settings.copyWith( + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: server.baseUrl, + availableModels: const ['moonshotai/kimi-k2.5'], + selectedModels: const ['moonshotai/kimi-k2.5'], + ), + defaultModel: 'moonshotai/kimi-k2.5', + ), + refreshAfterSave: false, + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + await controller.setSingleAgentProvider(SingleAgentProvider.codex); + + await controller.sendChatMessage('你好', thinking: 'low'); + + expect(runner.resolveCalls, 1); + expect(runner.runCalls, 0); + expect(server.requestCount, 1); + expect( + controller.chatMessages.any( + (message) => + message.toolName == 'AI Chat fallback' && + message.text.contains('Codex CLI is unavailable'), + ), + isTrue, + ); + expect( + controller.chatMessages.any( + (message) => + message.role == 'assistant' && message.text == 'FIRST_REPLY', + ), + isTrue, + ); + }, + ); } class _FakeGatewayRuntime extends GatewayRuntime { @@ -385,6 +529,57 @@ class _FakeCodexRuntime extends CodexRuntime { Future stop() async {} } +class _FakeSingleAgentRunner implements SingleAgentRunner { + _FakeSingleAgentRunner({ + required this.resolvedProvider, + this.result, + this.fallbackReason, + }); + + final SingleAgentProvider? resolvedProvider; + final SingleAgentRunResult? result; + final String? fallbackReason; + + int resolveCalls = 0; + int runCalls = 0; + SingleAgentRunRequest? lastRequest; + + @override + Future resolveProvider({ + required SingleAgentProvider selection, + required String configuredCodexCliPath, + }) async { + resolveCalls += 1; + return SingleAgentProviderResolution( + selection: selection, + resolvedProvider: resolvedProvider, + fallbackReason: fallbackReason, + ); + } + + @override + Future run(SingleAgentRunRequest request) async { + runCalls += 1; + lastRequest = request; + return result ?? + SingleAgentRunResult( + provider: request.provider, + output: '', + success: false, + errorMessage: 'no result configured', + shouldFallbackToAiChat: false, + ); + } +} + +class _FallbackOnlySingleAgentRunner extends _FakeSingleAgentRunner { + _FallbackOnlySingleAgentRunner() + : super( + resolvedProvider: null, + fallbackReason: 'No supported external CLI provider is available.', + ); +} + class _FakeAiGatewayServer { _FakeAiGatewayServer._(this._server, this._responseMode); diff --git a/test/runtime/app_controller_execution_target_switch_suite.dart b/test/runtime/app_controller_execution_target_switch_suite.dart index 6a94a631..34f79001 100644 --- a/test/runtime/app_controller_execution_target_switch_suite.dart +++ b/test/runtime/app_controller_execution_target_switch_suite.dart @@ -285,7 +285,7 @@ void main() { expect(controller.assistantConnectionStatusLabel, '单机智能体'); expect( controller.assistantConnectionTargetLabel, - 'qwen2.5-coder:latest · 127.0.0.1:11434', + 'Auto · qwen2.5-coder:latest · 127.0.0.1:11434', ); expect( gateway.connectedProfiles, @@ -805,7 +805,7 @@ void main() { expect(controller.assistantConnectionStatusLabel, '单机智能体'); expect( controller.assistantConnectionTargetLabel, - 'qwen2.5-coder:latest · 127.0.0.1:11434', + 'Auto · qwen2.5-coder:latest · 127.0.0.1:11434', ); }, ); diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index d111c9b3..eba95a2d 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -699,6 +699,7 @@ void main() { ], selectedSkillKeys: ['/tmp/imported-skill'], assistantModelId: 'gpt-5.4-mini', + singleAgentProvider: SingleAgentProvider.claude, gatewayEntryState: 'single-agent', updatedAtMs: 1700000000000, messages: [ @@ -757,6 +758,10 @@ void main() { '/tmp/imported-skill', ]); expect(reloadedRecords.first.assistantModelId, 'gpt-5.4-mini'); + expect( + reloadedRecords.first.singleAgentProvider, + SingleAgentProvider.claude, + ); expect(reloadedRecords.first.gatewayEntryState, 'single-agent'); expect(reloadedRecords.first.messages, hasLength(2)); expect(reloadedRecords.first.messages.last.text, '第一条回复'); @@ -784,6 +789,7 @@ void main() { 'archived': false, 'executionTarget': 'aiGatewayOnly', 'messageViewMode': 'rendered', + 'singleAgentProvider': 'gemini', 'gatewayEntryState': 'ai-gateway-only', }); @@ -792,6 +798,7 @@ void main() { expect(decoded.importedSkills, isEmpty); expect(decoded.selectedSkillKeys, isEmpty); expect(decoded.assistantModelId, isEmpty); + expect(decoded.singleAgentProvider, SingleAgentProvider.gemini); expect(decoded.gatewayEntryState, 'single-agent'); }, ); diff --git a/test/widget_test.dart b/test/widget_test.dart index 3a541db1..9f1a1f5d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -18,7 +18,7 @@ void main() { expect(find.text('新对话'), findsWidgets); expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); - expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget); + expect(find.textContaining('输入需求、补充上下文'), findsOneWidget); if (kIsWeb) { expect(find.text('设置'), findsWidgets);