From 8fa349c483b2d59f302ce39ccfc778ad0798d2bd Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 14 Apr 2026 20:15:38 +0800 Subject: [PATCH] Fix gateway routing when provider catalog is empty --- ...ettings-integration-configuration-model.md | 47 ++-- .../task-control-plane-unification.md | 9 +- ...k-thread-session-key-isolation-20260329.md | 5 + ...rkmate-core-module-inventory-2026-04-13.md | 6 +- .../xworkmate-layered-architecture.md | 9 +- .../2026-04-11-app-bridge-api-alignment.md | 64 ++--- ...rkmate-app-core-functional-test-plan-v1.md | 18 +- ...ntroller_desktop_external_acp_routing.dart | 8 +- ..._controller_desktop_skill_permissions.dart | 5 +- ...app_controller_desktop_thread_actions.dart | 34 +++ .../runtime_models_runtime_payloads.dart | 3 +- .../assistant_execution_target_test.dart | 221 ++++++++++++++++++ 12 files changed, 358 insertions(+), 71 deletions(-) diff --git a/docs/architecture/settings-integration-configuration-model.md b/docs/architecture/settings-integration-configuration-model.md index 13201ac5..5d391884 100644 --- a/docs/architecture/settings-integration-configuration-model.md +++ b/docs/architecture/settings-integration-configuration-model.md @@ -1,6 +1,6 @@ # Settings Integration Configuration Model -Last Updated: 2026-04-13 +Last Updated: 2026-04-14 本文件记录当前 `Settings -> Integrations` 在主链中的职责边界。 @@ -28,36 +28,33 @@ flowchart TD end subgraph APPSTATE["App-side derived state"] - F["refreshSingleAgentCapabilitiesRuntimeInternal()"] + F["capability refresh hydration"] G["bridgeAgentProviderCatalogInternal
bridgeGatewayProviderCatalogInternal
bridgeAvailableExecutionTargetsInternal"] - H["singleAgentCapabilitiesByProviderInternal"] - I["refreshAcpCapabilitiesRuntimeInternal()"] - J["GatewayAcpCapabilities"] - K["mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal()"] - L["ManagedMountTargetState"] + H["GatewayAcpCapabilities"] + I["gateway capability -> mount target merge"] + J["ManagedMountTargetState"] C --> F --> G - F --> H - C --> I --> J --> K --> L + C --> H --> I --> J end subgraph UI["Visible affordances"] - M["assistant provider picker"] - N["available assistant targets"] + M["agent / gateway target switch"] + N["task dialog provider menu"] O["settings gateway connection affordances"] G --> M - H --> N - L --> O + G --> N + J --> O end subgraph EXEC["Execution"] - P["setSingleAgentProvider(providerId)"] - Q["singleAgentProviderForSession()"] - R["executeTask(...)"] - S["resolved provider / unavailable message"] - T["provider unavailable UX"] - M --> P --> Q --> R - R --> D --> S - S --> T + P["providerCatalogForExecutionTarget()"] + Q["resolveProviderForExecutionTarget()"] + R["setAssistantProvider()"] + S["assistantProviderForSession()"] + T["GoTaskService.executeTask(...)"] + U["resolved provider / unavailable UX"] + N --> P --> Q --> R --> S --> T + T --> D --> U O --> E end ``` @@ -78,7 +75,15 @@ flowchart TD ## Notes +- 当前任务对话框 provider 选择主链固定为 `providerCatalogForExecutionTarget() -> resolveProviderForExecutionTarget() -> setAssistantProvider()` +- `agent` catalog 只对应 bridge 广告的 ACP server bridges +- `gateway` catalog 只对应 bridge 返回的 gateway provider 列表;当前为 `openclaw`,未来可扩展 `hermes` 等项 - provider picker 的真源只来自 bridge 返回的 target-scoped catalog;不会因为线程里保存过 `providerId` 就被 app 反向重建 - gateway runtime 可见性来自 bridge capability snapshot 与 `xworkmate.gateway.*` 返回,不来自旧设置页枚举 - bridge 若返回额外 capability flag,这些 flag 只属于合同元数据,不会自动生成新的 settings tab 或 module page +- bridge 若未返回 catalog,provider 菜单为空或禁用;app 不伪造 `codex / opencode / gemini / openclaw` - production provider / gateway 选择继续由 bridge 拥有,app 只保留消费与展示 + +## See Also + +- [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md) diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md index 74619ddd..d8622417 100644 --- a/docs/architecture/task-control-plane-unification.md +++ b/docs/architecture/task-control-plane-unification.md @@ -1,6 +1,6 @@ # Task Control Plane Unification -Last Updated: 2026-04-13 +Last Updated: 2026-04-14 ## Background @@ -80,6 +80,12 @@ flowchart TD - 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog - provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve` - bridge 返回 `availableExecutionTargets` 与 target-scoped provider catalog;app 只做目标切换与展示,不做静态拆分或 canonical 单项硬编码 +- app 侧任务对话框 provider 选择主链固定为: + - `providerCatalogForExecutionTarget(...)` + - `resolveProviderForExecutionTarget(...)` + - `setAssistantProvider(...)` +- `agent` catalog 只消费 bridge 广告的 ACP server bridges +- `gateway` catalog 只消费 bridge 返回的 gateway provider 列表;当前为 `openclaw`,未来可扩展 `hermes` 等项 - app 只负责: - 展示 `agent` / `gateway` 目标切换 - 请求 bridge contract @@ -109,6 +115,7 @@ flowchart TD ## See Also +- [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md) - [XWorkmate Core Module Inventory](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md) - [Settings Integration Configuration Model](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/settings-integration-configuration-model.md) - [ACP Forwarding Topology](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md) diff --git a/docs/architecture/task-thread-session-key-isolation-20260329.md b/docs/architecture/task-thread-session-key-isolation-20260329.md index 631ece45..e0e33885 100644 --- a/docs/architecture/task-thread-session-key-isolation-20260329.md +++ b/docs/architecture/task-thread-session-key-isolation-20260329.md @@ -1,5 +1,10 @@ # TaskThread SessionKey 隔离修正(2026-03-29) +术语说明: + +- 本文写于 `single-agent` 仍是主术语的阶段;凡正文出现 `single-agent`,当前都应读作任务对话模式下的 `agent` 一级目标 +- 当前任务对话框 provider 选择与 target/catalog 真源口径,以 [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md) 为准 + 本文补充并修正 XWorkmate 当前“任务线 / 线程 / 工作目录”设计中的一个关键约束: - 左侧任务线不能只是派生 UI 项 diff --git a/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md b/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md index 39f86e93..1995f610 100644 --- a/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md +++ b/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md @@ -1,6 +1,6 @@ # XWorkmate Core Module Inventory -Last Updated: 2026-04-13 +Last Updated: 2026-04-14 ## Repo Context @@ -151,6 +151,9 @@ Status: `Active` - provider catalog 只来自 bridge capabilities,不再恢复任何 preset / backfill / fallback provider truth - 任务对话模式只保留两类一级目标:`agent` / `gateway` - 每个目标下的 provider 菜单都只消费 `xworkmate-bridge` 返回的动态 catalog;app 不维护 `codex / opencode / gemini / openclaw` 这类本地固定列表 +- app 侧 provider 选择主链统一为 `providerCatalogForExecutionTarget(...) -> resolveProviderForExecutionTarget(...) -> setAssistantProvider(...)` +- `agent` catalog 只对应 ACP server bridges;`gateway` catalog 只对应 bridge 返回的 gateway provider 列表,当前为 `openclaw`,未来可扩展 `hermes` 等项 +- provider fallback 文档口径统一使用通用 provider 语义,不再保留 “single-agent provider” 术语 - task state 仍在 assistant 内被消费,但不再拥有独立 `TasksPage` - skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage` - assistant focus 只保留仍有真实落点的 `settings / language / theme` @@ -217,3 +220,4 @@ Status: `Removed surface` - `xworkmate-app` 不再维护独立模块壳;任何新的 bridge 能力都只能落到 `assistant` 或 `settings`,不能恢复 `tasks/modules/...` 独立 page matrix。 - provider、routing、bridge endpoint、managed account sync 的真源继续归 `xworkmate-bridge` 合同与同步链拥有,app 只做消费与最小本地编排。 - 不再维护兼容 alias、休眠 destination、伪模块矩阵;发现新的 `legacy / fallback / compat` 残留时,默认动作仍然是删除而不是保留占位。 +- 任务对话框 provider 选择细则以 [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md) 为准。 diff --git a/docs/architecture/xworkmate-layered-architecture.md b/docs/architecture/xworkmate-layered-architecture.md index 0d79d6c8..44af4de3 100644 --- a/docs/architecture/xworkmate-layered-architecture.md +++ b/docs/architecture/xworkmate-layered-architecture.md @@ -1,6 +1,6 @@ # XWorkmate Layered Architecture -Last Updated: 2026-04-13 +Last Updated: 2026-04-14 ## Purpose @@ -101,9 +101,10 @@ flowchart TD 建议按下面顺序理解当前主链: 1. [XWorkmate Core Module Inventory](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md) -2. [Task Control Plane Unification](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-control-plane-unification.md) -3. [ACP Forwarding Topology](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md) -4. [ADR: Unified Bridge Entry Points](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/adr-unified-bridge-entrypoints.md) +2. [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md) +3. [Task Control Plane Unification](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-control-plane-unification.md) +4. [ACP Forwarding Topology](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md) +5. [ADR: Unified Bridge Entry Points](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/adr-unified-bridge-entrypoints.md) ## Removed From Target diff --git a/docs/feature/2026-04-11-app-bridge-api-alignment.md b/docs/feature/2026-04-11-app-bridge-api-alignment.md index f226729f..2d97a0f6 100644 --- a/docs/feature/2026-04-11-app-bridge-api-alignment.md +++ b/docs/feature/2026-04-11-app-bridge-api-alignment.md @@ -1,45 +1,51 @@ # APP 侧对齐当前 xworkmate-bridge API -本轮 APP 侧对接以当前 `xworkmate-bridge` 实际返回为准,不再额外定义前端私有 contract。 +Last Updated: 2026-04-14 + +本文件只记录当前 `xworkmate-app` 实际消费的 bridge 合同口径,不再延续旧的 `single-agent provider picker` 叙述。 ## 当前后端事实 -- `acp.capabilities` 当前继续返回: - - `singleAgent` - - `multiAgent` +- `acp.capabilities` 当前 app 主链消费的核心字段是: + - `availableExecutionTargets` - `providerCatalog` - `gatewayProviders` -- `xworkmate.routing.resolve` 当前继续返回: - - `resolvedExecutionTarget` - - `resolvedEndpointTarget` - - `resolvedProviderId` - - `resolvedGatewayProviderId` -- `session.start` / `session.message` 当前请求仍消费线程级 `workingDirectory` -- 当前 bridge 还没有项目列表接口 +- 其中: + - `providerCatalog` 对应 `agent` 目标下的 ACP server bridges + - `gatewayProviders` 对应 `gateway` 目标下的 gateway provider 列表 +- `singleAgent` / `multiAgent` 仍可能作为兼容元数据被解析,但它们不再定义任务对话框的主术语与主状态 ## APP 侧执行约定 -- APP 模式选择入口只暴露: - - `single-agent` +- APP 任务对话模式只暴露: + - `agent` - `gateway` -- `multi-agent` 仍作为 bridge 可返回状态被解析和展示,但不再作为用户主动选择入口 -- 线程级“项目选择”当前直接等价于 bridge 请求里的 `workingDirectory` -- `workingDirectory` 与本地 `workspaceBinding` 分离: - - `workingDirectory`: 发给 bridge 的执行目录 - - `workspaceBinding`: APP 本地 artifact 回写目录 +- provider picker 按 target-scoped catalog 渲染: + - `agent` catalog 只消费 bridge 返回的 ACP bridge providers + - `gateway` catalog 只消费 bridge 返回的 gateway providers;当前为 `openclaw`,未来可扩展 `hermes` +- APP 不再维护静态 provider 列表,也不从线程历史值反向生成 catalog ## 当前实现结果 -- 每个线程持久化 `selectedWorkingDirectory` -- `single-agent` 与 `gateway` 都复用同一个线程级 `selectedWorkingDirectory` -- follow-up 请求继续沿用: - - `sessionId == threadId == sessionKey` - - 同一线程绑定的 `workingDirectory` -- 若线程没有选项目目录,APP 会阻断发送并提示先选择项目 +- 每个线程持久化: + - `executionTarget` + - `providerId` + - `selectedWorkingDirectory` +- `agent` 与 `gateway` 都复用同一个线程级 `selectedWorkingDirectory` +- provider 选择主链统一为: + - `providerCatalogForExecutionTarget(...)` + - `resolveProviderForExecutionTarget(...)` + - `setAssistantProvider(...)` +- 渲染态读取统一通过: + - `assistantProviderForSession(sessionKey)` -## 兼容策略 +## 当前兼容边界 -- 继续解析 `resolvedEndpointTarget`,但它不再作为前端主状态来源 -- 继续解析 `multiAgent`,但不提供手动切换入口 -- `providerCatalog` 继续驱动 single-agent provider picker -- `gatewayProviders` 继续按 bridge 返回结构保存和消费,不在 APP 侧硬编码扩展 +- transport / capability parser 可以继续兼容解析 `single-agent` 旧字段值 +- 这种兼容只存在于低层解析,不再抬升为 UI 文案、架构主术语或设计文档口径 +- gateway provider 若 bridge 当前未广告,APP 显示为空或禁用,不再伪造 `openclaw` 默认入口 + +## See Also + +- [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md) +- [Task Control Plane Unification](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-control-plane-unification.md) diff --git a/docs/xworkmate-app-core-functional-test-plan-v1.md b/docs/xworkmate-app-core-functional-test-plan-v1.md index 54558914..25a7637d 100644 --- a/docs/xworkmate-app-core-functional-test-plan-v1.md +++ b/docs/xworkmate-app-core-functional-test-plan-v1.md @@ -24,7 +24,7 @@ ### 2. Assistant 线程体验 -- single-agent 线程首次发送时自动绑定完整 `workspaceBinding`。 +- `agent` 线程首次发送时自动绑定完整 `workspaceBinding`。 - 当前线程的 provider、workspace、artifact 只属于当前线程,不污染其他线程。 - 二次追问继续复用当前线程与当前本地 workspace。 - prompt 文本不能覆盖已绑定 workspace。 @@ -40,7 +40,7 @@ - 无 provider 时,UI 给出 ACP-only 的明确提示。 - 已绑定但当前不可用的 provider,UI 给出“不可自动改线”的提示。 -- debug runtime 开启时,UI 可以显示 single-agent runtime/provider 状态。 +- debug runtime 开启时,UI 可以显示当前 target 的 runtime/provider 状态。 - provider 未就绪、workspace 缺失、执行失败时,提示文案与线程状态一致。 ## Test Scope by Layer @@ -50,7 +50,7 @@ 重点看用户实际能看到什么: - provider selector -- single-agent mode chip / label +- task dialog target chip / label(`agent` / `gateway`) - thread workspace 与 artifact 可见性 - 错误提示与状态提示 - thread 切换后的 provider / artifact 隔离 @@ -94,7 +94,7 @@ flutter test test/runtime/account_bridge_smoke_suite.dart flutter test test/features/settings_page_external_acp_end_to_end_suite.dart ``` -### Phase 2: Single-Agent Runtime 回归 +### Phase 2: Agent Runtime 回归 验证 thread / provider / workspace / artifact 主链路: @@ -145,8 +145,10 @@ flutter test test/features/assistant_page_suite.dart ### Provider / UI 断言 - provider selector 的选项来自 bridge 当前广告结果。 +- `agent` target 只展示 bridge 当前广告的 ACP bridge providers。 +- `gateway` target 只展示 bridge 当前广告的 gateway providers。 - UI 不会展示 bridge 未广告的 provider 作为可执行项。 -- `auto` 模式下,UI 显示的是 bridge 当前解析后的状态,而不是硬编码 provider。 +- bridge 未返回 catalog 时,provider 菜单为空或禁用,而不是硬编码 provider。 - provider 不可用时,线程提示信息正确。 ### Thread / Workspace 断言 @@ -167,7 +169,7 @@ flutter test test/features/assistant_page_suite.dart - 无 provider 时,错误提示明确指向 bridge/provider 配置问题。 - provider 已绑定但不可用时,UI 不会偷偷改线到其他 provider。 -- debug runtime 打开时,single-agent provider/runtime 状态对用户可见。 +- debug runtime 打开时,当前 target 的 provider/runtime 状态对用户可见。 ## Execution Order @@ -201,7 +203,7 @@ flutter test test/features/assistant_page_suite.dart 额外约定: - UI 本轮不改结构,只验证 provider 列表来源、展示结果与 thread 内状态。 -- `openclaw` 作为扩展路由的一部分,若 bridge 当前未广告,可 `skip`,但保留入口。 +- `gateway` target 若 bridge 当前未广告任何 gateway provider,可 `skip`,但 UI 不得伪造 `openclaw` 默认入口。 - 如果某些长耗时在线任务未在默认时间窗内完成,允许先记录为 `timeout`,再用专项 case 延长超时补验。 ## Deliverable @@ -210,6 +212,6 @@ flutter test test/features/assistant_page_suite.dart - UI 能证明 provider 列表来自 bridge 动态发现 - thread / workspace / artifact 语义已通过 runtime 回归 -- feature 层能看到 single-agent 结果、状态和错误提示 +- feature 层能看到 `agent / gateway` 结果、状态和错误提示 - 6 个典型 case 都有最小 UI 验收骨架 - 所有断言都围绕“用户在 APP 里能否看到正确 provider、正确线程、正确结果”展开 diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index 7f7095ca..a0a34423 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -68,10 +68,9 @@ extension AppControllerDesktopExternalAcpRouting on AppController { normalizedSessionKey, ); final resolvedProvider = assistantProviderForSession(normalizedSessionKey); - final resolvedExplicitProviderId = currentTarget.isGateway - ? kCanonicalGatewayProviderId - : thread?.hasExplicitProviderSelection == true && - !resolvedProvider.isUnspecified + final resolvedExplicitProviderId = + thread?.hasExplicitProviderSelection == true && + !resolvedProvider.isUnspecified ? resolvedProvider.providerId : ''; final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false @@ -81,7 +80,6 @@ extension AppControllerDesktopExternalAcpRouting on AppController { ? selectedSkills : const []; final hasAnyExplicitSelection = - (thread?.hasExplicitExecutionTargetSelection ?? false) || resolvedExplicitProviderId.isNotEmpty || resolvedExplicitModel.trim().isNotEmpty || resolvedExplicitSkills.isNotEmpty; diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index 6b18ed58..56e0b29f 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -310,6 +310,9 @@ extension AppControllerDesktopSkillPermissions on AppController { selectedProviderSource ?? existing?.executionBinding.providerSource ?? ThreadSelectionSource.inherited; + final normalizedProviderSource = nextProvider.isUnspecified + ? ThreadSelectionSource.inherited + : nextProviderSource; final nextExecutionBinding = (executionBinding ?? existing?.executionBinding ?? @@ -331,7 +334,7 @@ extension AppControllerDesktopSkillPermissions on AppController { executionModeSource: executionTargetSource ?? existing?.executionBinding.executionModeSource, - providerSource: nextProviderSource, + providerSource: normalizedProviderSource, ); final nextContextState = (contextState ?? diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index b4103d01..09bfc8a8 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -257,6 +257,40 @@ extension AppControllerDesktopThreadActions on AppController { recomputeTasksInternal(); throw error; } + if (currentTarget.isGateway && + providerCatalogForExecutionTarget(currentTarget).isEmpty) { + try { + await refreshSingleAgentCapabilitiesInternal(forceRefresh: true); + } catch (_) { + // Keep the local guard focused on the post-refresh catalog state. + } + if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + currentSessionKey, + ); + upsertTaskThreadInternal( + normalizedSessionKey, + selectedProvider: SingleAgentProvider.unspecified, + selectedProviderSource: ThreadSelectionSource.inherited, + latestResolvedProviderId: '', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + final error = StateError( + appText( + 'Gateway ACP 未报告可用的 gateway provider,当前无法发送。', + 'Gateway ACP did not report a usable gateway provider, so this Gateway task cannot run yet.', + ), + ); + appendAssistantThreadMessageInternal( + normalizedSessionKey, + assistantErrorMessageInternal(error.message), + ); + await flushAssistantThreadPersistenceInternal(); + recomputeTasksInternal(); + notifyIfActiveInternal(); + throw error; + } + } await enqueueThreadTurnInternal( normalizedAssistantSessionKeyInternal(currentSessionKey), () async { diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 2b233e94..6cc21176 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -1073,7 +1073,8 @@ class TaskThread { bool get hasExplicitExecutionTargetSelection => executionBinding.executionModeSource == ThreadSelectionSource.explicit; bool get hasExplicitProviderSelection => - executionBinding.providerSource == ThreadSelectionSource.explicit; + executionBinding.providerSource == ThreadSelectionSource.explicit && + executionBinding.providerId.trim().isNotEmpty; bool get hasExplicitModelSelection => contextState.selectedModelSource == ThreadSelectionSource.explicit; bool get hasExplicitSkillSelection => diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index f5100dab..ec00eb93 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -144,6 +146,66 @@ void main() { }, ); + test( + 'switching a session to gateway with an empty gateway catalog keeps provider selection inherited', + () async { + final controller = AppController( + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + SingleAgentProvider.gemini, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + final record = controller.requireTaskThreadForSessionInternal( + 'session-1', + ); + + expect( + controller.assistantExecutionTargetForSession('session-1'), + AssistantExecutionTarget.gateway, + ); + expect(record.executionBinding.providerId, isEmpty); + expect( + record.executionBinding.providerSource, + ThreadSelectionSource.inherited, + ); + expect(record.hasExplicitProviderSelection, isFalse); + }, + ); + + test( + 'gateway target without a live gateway provider falls back to auto routing', + () async { + final controller = AppController( + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + final routing = controller.buildExternalAcpRoutingForSessionInternal( + 'session-1', + ); + + expect(routing.isAuto, isTrue); + expect(routing.explicitExecutionTarget, isEmpty); + expect(routing.explicitProviderId, isEmpty); + }, + ); + test( 'locks the gateway provider catalog to the canonical openclaw contract', () { @@ -240,6 +302,81 @@ void main() { expect(capture.lastAuthorizationHeader, 'Bearer bridge-token'); }, ); + + test( + 'sendChatMessage refreshes gateway capabilities and fails locally when gateway provider catalog stays empty', + () async { + final capture = await _startEmptyCapabilityServer(); + addTearDown(capture.close); + + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-empty-gateway-provider-send-', + ); + 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.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + bridgeServerUrl: capture.baseEndpoint.toString(), + ), + syncState: 'ready', + ), + ); + + final controller = AppController( + store: store, + goTaskServiceClient: fakeGoTaskService, + environmentOverride: { + 'BRIDGE_SERVER_URL': capture.baseEndpoint.toString(), + 'BRIDGE_AUTH_TOKEN': 'bridge-token', + }, + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await _waitForRequest(capture, minimumCount: 1); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + await _waitForRequest(capture, minimumCount: 2); + + await expectLater( + controller.sendChatMessage('hi'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('gateway provider'), + ), + ), + ); + + expect(fakeGoTaskService.executeCount, 0); + expect(capture.requestCount, greaterThanOrEqualTo(3)); + expect(controller.chatMessages.last.text, contains('gateway provider')); + }, + ); }); } @@ -300,6 +437,36 @@ Future<_CapabilityServerCapture> _startCapabilityServer() async { return capture; } +Future<_CapabilityServerCapture> _startEmptyCapabilityServer() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final capture = _CapabilityServerCapture._( + server, + Uri.parse('http://127.0.0.1:${server.port}'), + ); + server.listen((request) async { + capture.requestCount += 1; + capture.lastAuthorizationHeader = + request.headers.value(HttpHeaders.authorizationHeader) ?? ''; + await utf8.decoder.bind(request).join(); + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': 'capabilities', + 'result': { + 'singleAgent': false, + 'multiAgent': true, + 'availableExecutionTargets': const [], + 'providerCatalog': const >[], + 'gatewayProviders': const >[], + }, + }), + ); + await request.response.close(); + }); + return capture; +} + class _CapabilityServerCapture { _CapabilityServerCapture._(this._server, this.baseEndpoint); @@ -310,3 +477,57 @@ class _CapabilityServerCapture { Future close() => _server.close(force: true); } + +class _RecordingGoTaskServiceClient implements GoTaskServiceClient { + int executeCount = 0; + + @override + Future loadExternalAcpCapabilities({ + required AssistantExecutionTarget target, + bool forceRefresh = false, + }) async => const ExternalCodeAgentAcpCapabilities.empty(); + + @override + Future resolveExternalAcpRouting({ + required String taskPrompt, + required String workingDirectory, + required ExternalCodeAgentAcpRoutingConfig routing, + }) async => + const ExternalCodeAgentAcpRoutingResolution(raw: {}); + + @override + Future executeTask( + GoTaskServiceRequest request, { + required void Function(GoTaskServiceUpdate update) onUpdate, + }) async { + executeCount += 1; + return const GoTaskServiceResult( + success: true, + message: 'unexpected executeTask call', + turnId: 'turn', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + } + + @override + Future cancelTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future closeTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future dispose() async {} +}