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