Fix gateway routing when provider catalog is empty
This commit is contained in:
parent
78d59292a6
commit
8fa349c483
@ -1,6 +1,6 @@
|
|||||||
# Settings Integration Configuration Model
|
# Settings Integration Configuration Model
|
||||||
|
|
||||||
Last Updated: 2026-04-13
|
Last Updated: 2026-04-14
|
||||||
|
|
||||||
本文件记录当前 `Settings -> Integrations` 在主链中的职责边界。
|
本文件记录当前 `Settings -> Integrations` 在主链中的职责边界。
|
||||||
|
|
||||||
@ -28,36 +28,33 @@ flowchart TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph APPSTATE["App-side derived state"]
|
subgraph APPSTATE["App-side derived state"]
|
||||||
F["refreshSingleAgentCapabilitiesRuntimeInternal()"]
|
F["capability refresh hydration"]
|
||||||
G["bridgeAgentProviderCatalogInternal<br/>bridgeGatewayProviderCatalogInternal<br/>bridgeAvailableExecutionTargetsInternal"]
|
G["bridgeAgentProviderCatalogInternal<br/>bridgeGatewayProviderCatalogInternal<br/>bridgeAvailableExecutionTargetsInternal"]
|
||||||
H["singleAgentCapabilitiesByProviderInternal"]
|
H["GatewayAcpCapabilities"]
|
||||||
I["refreshAcpCapabilitiesRuntimeInternal()"]
|
I["gateway capability -> mount target merge"]
|
||||||
J["GatewayAcpCapabilities"]
|
J["ManagedMountTargetState"]
|
||||||
K["mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal()"]
|
|
||||||
L["ManagedMountTargetState"]
|
|
||||||
C --> F --> G
|
C --> F --> G
|
||||||
F --> H
|
C --> H --> I --> J
|
||||||
C --> I --> J --> K --> L
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph UI["Visible affordances"]
|
subgraph UI["Visible affordances"]
|
||||||
M["assistant provider picker"]
|
M["agent / gateway target switch"]
|
||||||
N["available assistant targets"]
|
N["task dialog provider menu"]
|
||||||
O["settings gateway connection affordances"]
|
O["settings gateway connection affordances"]
|
||||||
G --> M
|
G --> M
|
||||||
H --> N
|
G --> N
|
||||||
L --> O
|
J --> O
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph EXEC["Execution"]
|
subgraph EXEC["Execution"]
|
||||||
P["setSingleAgentProvider(providerId)"]
|
P["providerCatalogForExecutionTarget()"]
|
||||||
Q["singleAgentProviderForSession()"]
|
Q["resolveProviderForExecutionTarget()"]
|
||||||
R["executeTask(...)"]
|
R["setAssistantProvider()"]
|
||||||
S["resolved provider / unavailable message"]
|
S["assistantProviderForSession()"]
|
||||||
T["provider unavailable UX"]
|
T["GoTaskService.executeTask(...)"]
|
||||||
M --> P --> Q --> R
|
U["resolved provider / unavailable UX"]
|
||||||
R --> D --> S
|
N --> P --> Q --> R --> S --> T
|
||||||
S --> T
|
T --> D --> U
|
||||||
O --> E
|
O --> E
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
@ -78,7 +75,15 @@ flowchart TD
|
|||||||
|
|
||||||
## Notes
|
## 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 反向重建
|
- provider picker 的真源只来自 bridge 返回的 target-scoped catalog;不会因为线程里保存过 `providerId` 就被 app 反向重建
|
||||||
- gateway runtime 可见性来自 bridge capability snapshot 与 `xworkmate.gateway.*` 返回,不来自旧设置页枚举
|
- gateway runtime 可见性来自 bridge capability snapshot 与 `xworkmate.gateway.*` 返回,不来自旧设置页枚举
|
||||||
- bridge 若返回额外 capability flag,这些 flag 只属于合同元数据,不会自动生成新的 settings tab 或 module page
|
- bridge 若返回额外 capability flag,这些 flag 只属于合同元数据,不会自动生成新的 settings tab 或 module page
|
||||||
|
- bridge 若未返回 catalog,provider 菜单为空或禁用;app 不伪造 `codex / opencode / gemini / openclaw`
|
||||||
- production provider / gateway 选择继续由 bridge 拥有,app 只保留消费与展示
|
- 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)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Task Control Plane Unification
|
# Task Control Plane Unification
|
||||||
|
|
||||||
Last Updated: 2026-04-13
|
Last Updated: 2026-04-14
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
@ -80,6 +80,12 @@ flowchart TD
|
|||||||
- 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog
|
- 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog
|
||||||
- provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve`
|
- provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve`
|
||||||
- bridge 返回 `availableExecutionTargets` 与 target-scoped provider catalog;app 只做目标切换与展示,不做静态拆分或 canonical 单项硬编码
|
- 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 只负责:
|
- app 只负责:
|
||||||
- 展示 `agent` / `gateway` 目标切换
|
- 展示 `agent` / `gateway` 目标切换
|
||||||
- 请求 bridge contract
|
- 请求 bridge contract
|
||||||
@ -109,6 +115,7 @@ flowchart TD
|
|||||||
|
|
||||||
## See Also
|
## 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)
|
- [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)
|
- [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)
|
- [ACP Forwarding Topology](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md)
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
# TaskThread SessionKey 隔离修正(2026-03-29)
|
# 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 当前“任务线 / 线程 / 工作目录”设计中的一个关键约束:
|
本文补充并修正 XWorkmate 当前“任务线 / 线程 / 工作目录”设计中的一个关键约束:
|
||||||
|
|
||||||
- 左侧任务线不能只是派生 UI 项
|
- 左侧任务线不能只是派生 UI 项
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# XWorkmate Core Module Inventory
|
# XWorkmate Core Module Inventory
|
||||||
|
|
||||||
Last Updated: 2026-04-13
|
Last Updated: 2026-04-14
|
||||||
|
|
||||||
## Repo Context
|
## Repo Context
|
||||||
|
|
||||||
@ -151,6 +151,9 @@ Status: `Active`
|
|||||||
- provider catalog 只来自 bridge capabilities,不再恢复任何 preset / backfill / fallback provider truth
|
- provider catalog 只来自 bridge capabilities,不再恢复任何 preset / backfill / fallback provider truth
|
||||||
- 任务对话模式只保留两类一级目标:`agent` / `gateway`
|
- 任务对话模式只保留两类一级目标:`agent` / `gateway`
|
||||||
- 每个目标下的 provider 菜单都只消费 `xworkmate-bridge` 返回的动态 catalog;app 不维护 `codex / opencode / gemini / openclaw` 这类本地固定列表
|
- 每个目标下的 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`
|
- task state 仍在 assistant 内被消费,但不再拥有独立 `TasksPage`
|
||||||
- skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage`
|
- skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage`
|
||||||
- assistant focus 只保留仍有真实落点的 `settings / language / theme`
|
- assistant focus 只保留仍有真实落点的 `settings / language / theme`
|
||||||
@ -217,3 +220,4 @@ Status: `Removed surface`
|
|||||||
- `xworkmate-app` 不再维护独立模块壳;任何新的 bridge 能力都只能落到 `assistant` 或 `settings`,不能恢复 `tasks/modules/...` 独立 page matrix。
|
- `xworkmate-app` 不再维护独立模块壳;任何新的 bridge 能力都只能落到 `assistant` 或 `settings`,不能恢复 `tasks/modules/...` 独立 page matrix。
|
||||||
- provider、routing、bridge endpoint、managed account sync 的真源继续归 `xworkmate-bridge` 合同与同步链拥有,app 只做消费与最小本地编排。
|
- provider、routing、bridge endpoint、managed account sync 的真源继续归 `xworkmate-bridge` 合同与同步链拥有,app 只做消费与最小本地编排。
|
||||||
- 不再维护兼容 alias、休眠 destination、伪模块矩阵;发现新的 `legacy / fallback / compat` 残留时,默认动作仍然是删除而不是保留占位。
|
- 不再维护兼容 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) 为准。
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# XWorkmate Layered Architecture
|
# XWorkmate Layered Architecture
|
||||||
|
|
||||||
Last Updated: 2026-04-13
|
Last Updated: 2026-04-14
|
||||||
|
|
||||||
## Purpose
|
## 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)
|
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)
|
2. [Task Dialog Provider Selection Mainline](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-dialog-provider-selection-mainline.md)
|
||||||
3. [ACP Forwarding Topology](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md)
|
3. [Task Control Plane Unification](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-control-plane-unification.md)
|
||||||
4. [ADR: Unified Bridge Entry Points](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/adr-unified-bridge-entrypoints.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
|
## Removed From Target
|
||||||
|
|
||||||
|
|||||||
@ -1,45 +1,51 @@
|
|||||||
# APP 侧对齐当前 xworkmate-bridge API
|
# APP 侧对齐当前 xworkmate-bridge API
|
||||||
|
|
||||||
本轮 APP 侧对接以当前 `xworkmate-bridge` 实际返回为准,不再额外定义前端私有 contract。
|
Last Updated: 2026-04-14
|
||||||
|
|
||||||
|
本文件只记录当前 `xworkmate-app` 实际消费的 bridge 合同口径,不再延续旧的 `single-agent provider picker` 叙述。
|
||||||
|
|
||||||
## 当前后端事实
|
## 当前后端事实
|
||||||
|
|
||||||
- `acp.capabilities` 当前继续返回:
|
- `acp.capabilities` 当前 app 主链消费的核心字段是:
|
||||||
- `singleAgent`
|
- `availableExecutionTargets`
|
||||||
- `multiAgent`
|
|
||||||
- `providerCatalog`
|
- `providerCatalog`
|
||||||
- `gatewayProviders`
|
- `gatewayProviders`
|
||||||
- `xworkmate.routing.resolve` 当前继续返回:
|
- 其中:
|
||||||
- `resolvedExecutionTarget`
|
- `providerCatalog` 对应 `agent` 目标下的 ACP server bridges
|
||||||
- `resolvedEndpointTarget`
|
- `gatewayProviders` 对应 `gateway` 目标下的 gateway provider 列表
|
||||||
- `resolvedProviderId`
|
- `singleAgent` / `multiAgent` 仍可能作为兼容元数据被解析,但它们不再定义任务对话框的主术语与主状态
|
||||||
- `resolvedGatewayProviderId`
|
|
||||||
- `session.start` / `session.message` 当前请求仍消费线程级 `workingDirectory`
|
|
||||||
- 当前 bridge 还没有项目列表接口
|
|
||||||
|
|
||||||
## APP 侧执行约定
|
## APP 侧执行约定
|
||||||
|
|
||||||
- APP 模式选择入口只暴露:
|
- APP 任务对话模式只暴露:
|
||||||
- `single-agent`
|
- `agent`
|
||||||
- `gateway`
|
- `gateway`
|
||||||
- `multi-agent` 仍作为 bridge 可返回状态被解析和展示,但不再作为用户主动选择入口
|
- provider picker 按 target-scoped catalog 渲染:
|
||||||
- 线程级“项目选择”当前直接等价于 bridge 请求里的 `workingDirectory`
|
- `agent` catalog 只消费 bridge 返回的 ACP bridge providers
|
||||||
- `workingDirectory` 与本地 `workspaceBinding` 分离:
|
- `gateway` catalog 只消费 bridge 返回的 gateway providers;当前为 `openclaw`,未来可扩展 `hermes`
|
||||||
- `workingDirectory`: 发给 bridge 的执行目录
|
- APP 不再维护静态 provider 列表,也不从线程历史值反向生成 catalog
|
||||||
- `workspaceBinding`: APP 本地 artifact 回写目录
|
|
||||||
|
|
||||||
## 当前实现结果
|
## 当前实现结果
|
||||||
|
|
||||||
- 每个线程持久化 `selectedWorkingDirectory`
|
- 每个线程持久化:
|
||||||
- `single-agent` 与 `gateway` 都复用同一个线程级 `selectedWorkingDirectory`
|
- `executionTarget`
|
||||||
- follow-up 请求继续沿用:
|
- `providerId`
|
||||||
- `sessionId == threadId == sessionKey`
|
- `selectedWorkingDirectory`
|
||||||
- 同一线程绑定的 `workingDirectory`
|
- `agent` 与 `gateway` 都复用同一个线程级 `selectedWorkingDirectory`
|
||||||
- 若线程没有选项目目录,APP 会阻断发送并提示先选择项目
|
- provider 选择主链统一为:
|
||||||
|
- `providerCatalogForExecutionTarget(...)`
|
||||||
|
- `resolveProviderForExecutionTarget(...)`
|
||||||
|
- `setAssistantProvider(...)`
|
||||||
|
- 渲染态读取统一通过:
|
||||||
|
- `assistantProviderForSession(sessionKey)`
|
||||||
|
|
||||||
## 兼容策略
|
## 当前兼容边界
|
||||||
|
|
||||||
- 继续解析 `resolvedEndpointTarget`,但它不再作为前端主状态来源
|
- transport / capability parser 可以继续兼容解析 `single-agent` 旧字段值
|
||||||
- 继续解析 `multiAgent`,但不提供手动切换入口
|
- 这种兼容只存在于低层解析,不再抬升为 UI 文案、架构主术语或设计文档口径
|
||||||
- `providerCatalog` 继续驱动 single-agent provider picker
|
- gateway provider 若 bridge 当前未广告,APP 显示为空或禁用,不再伪造 `openclaw` 默认入口
|
||||||
- `gatewayProviders` 继续按 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)
|
||||||
|
- [Task Control Plane Unification](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/task-control-plane-unification.md)
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
### 2. Assistant 线程体验
|
### 2. Assistant 线程体验
|
||||||
|
|
||||||
- single-agent 线程首次发送时自动绑定完整 `workspaceBinding`。
|
- `agent` 线程首次发送时自动绑定完整 `workspaceBinding`。
|
||||||
- 当前线程的 provider、workspace、artifact 只属于当前线程,不污染其他线程。
|
- 当前线程的 provider、workspace、artifact 只属于当前线程,不污染其他线程。
|
||||||
- 二次追问继续复用当前线程与当前本地 workspace。
|
- 二次追问继续复用当前线程与当前本地 workspace。
|
||||||
- prompt 文本不能覆盖已绑定 workspace。
|
- prompt 文本不能覆盖已绑定 workspace。
|
||||||
@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
- 无 provider 时,UI 给出 ACP-only 的明确提示。
|
- 无 provider 时,UI 给出 ACP-only 的明确提示。
|
||||||
- 已绑定但当前不可用的 provider,UI 给出“不可自动改线”的提示。
|
- 已绑定但当前不可用的 provider,UI 给出“不可自动改线”的提示。
|
||||||
- debug runtime 开启时,UI 可以显示 single-agent runtime/provider 状态。
|
- debug runtime 开启时,UI 可以显示当前 target 的 runtime/provider 状态。
|
||||||
- provider 未就绪、workspace 缺失、执行失败时,提示文案与线程状态一致。
|
- provider 未就绪、workspace 缺失、执行失败时,提示文案与线程状态一致。
|
||||||
|
|
||||||
## Test Scope by Layer
|
## Test Scope by Layer
|
||||||
@ -50,7 +50,7 @@
|
|||||||
重点看用户实际能看到什么:
|
重点看用户实际能看到什么:
|
||||||
|
|
||||||
- provider selector
|
- provider selector
|
||||||
- single-agent mode chip / label
|
- task dialog target chip / label(`agent` / `gateway`)
|
||||||
- thread workspace 与 artifact 可见性
|
- thread workspace 与 artifact 可见性
|
||||||
- 错误提示与状态提示
|
- 错误提示与状态提示
|
||||||
- thread 切换后的 provider / 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
|
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 主链路:
|
验证 thread / provider / workspace / artifact 主链路:
|
||||||
|
|
||||||
@ -145,8 +145,10 @@ flutter test test/features/assistant_page_suite.dart
|
|||||||
### Provider / UI 断言
|
### Provider / UI 断言
|
||||||
|
|
||||||
- provider selector 的选项来自 bridge 当前广告结果。
|
- provider selector 的选项来自 bridge 当前广告结果。
|
||||||
|
- `agent` target 只展示 bridge 当前广告的 ACP bridge providers。
|
||||||
|
- `gateway` target 只展示 bridge 当前广告的 gateway providers。
|
||||||
- UI 不会展示 bridge 未广告的 provider 作为可执行项。
|
- UI 不会展示 bridge 未广告的 provider 作为可执行项。
|
||||||
- `auto` 模式下,UI 显示的是 bridge 当前解析后的状态,而不是硬编码 provider。
|
- bridge 未返回 catalog 时,provider 菜单为空或禁用,而不是硬编码 provider。
|
||||||
- provider 不可用时,线程提示信息正确。
|
- provider 不可用时,线程提示信息正确。
|
||||||
|
|
||||||
### Thread / Workspace 断言
|
### Thread / Workspace 断言
|
||||||
@ -167,7 +169,7 @@ flutter test test/features/assistant_page_suite.dart
|
|||||||
|
|
||||||
- 无 provider 时,错误提示明确指向 bridge/provider 配置问题。
|
- 无 provider 时,错误提示明确指向 bridge/provider 配置问题。
|
||||||
- provider 已绑定但不可用时,UI 不会偷偷改线到其他 provider。
|
- provider 已绑定但不可用时,UI 不会偷偷改线到其他 provider。
|
||||||
- debug runtime 打开时,single-agent provider/runtime 状态对用户可见。
|
- debug runtime 打开时,当前 target 的 provider/runtime 状态对用户可见。
|
||||||
|
|
||||||
## Execution Order
|
## Execution Order
|
||||||
|
|
||||||
@ -201,7 +203,7 @@ flutter test test/features/assistant_page_suite.dart
|
|||||||
额外约定:
|
额外约定:
|
||||||
|
|
||||||
- UI 本轮不改结构,只验证 provider 列表来源、展示结果与 thread 内状态。
|
- UI 本轮不改结构,只验证 provider 列表来源、展示结果与 thread 内状态。
|
||||||
- `openclaw` 作为扩展路由的一部分,若 bridge 当前未广告,可 `skip`,但保留入口。
|
- `gateway` target 若 bridge 当前未广告任何 gateway provider,可 `skip`,但 UI 不得伪造 `openclaw` 默认入口。
|
||||||
- 如果某些长耗时在线任务未在默认时间窗内完成,允许先记录为 `timeout`,再用专项 case 延长超时补验。
|
- 如果某些长耗时在线任务未在默认时间窗内完成,允许先记录为 `timeout`,再用专项 case 延长超时补验。
|
||||||
|
|
||||||
## Deliverable
|
## Deliverable
|
||||||
@ -210,6 +212,6 @@ flutter test test/features/assistant_page_suite.dart
|
|||||||
|
|
||||||
- UI 能证明 provider 列表来自 bridge 动态发现
|
- UI 能证明 provider 列表来自 bridge 动态发现
|
||||||
- thread / workspace / artifact 语义已通过 runtime 回归
|
- thread / workspace / artifact 语义已通过 runtime 回归
|
||||||
- feature 层能看到 single-agent 结果、状态和错误提示
|
- feature 层能看到 `agent / gateway` 结果、状态和错误提示
|
||||||
- 6 个典型 case 都有最小 UI 验收骨架
|
- 6 个典型 case 都有最小 UI 验收骨架
|
||||||
- 所有断言都围绕“用户在 APP 里能否看到正确 provider、正确线程、正确结果”展开
|
- 所有断言都围绕“用户在 APP 里能否看到正确 provider、正确线程、正确结果”展开
|
||||||
|
|||||||
@ -68,10 +68,9 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
|
|||||||
normalizedSessionKey,
|
normalizedSessionKey,
|
||||||
);
|
);
|
||||||
final resolvedProvider = assistantProviderForSession(normalizedSessionKey);
|
final resolvedProvider = assistantProviderForSession(normalizedSessionKey);
|
||||||
final resolvedExplicitProviderId = currentTarget.isGateway
|
final resolvedExplicitProviderId =
|
||||||
? kCanonicalGatewayProviderId
|
thread?.hasExplicitProviderSelection == true &&
|
||||||
: thread?.hasExplicitProviderSelection == true &&
|
!resolvedProvider.isUnspecified
|
||||||
!resolvedProvider.isUnspecified
|
|
||||||
? resolvedProvider.providerId
|
? resolvedProvider.providerId
|
||||||
: '';
|
: '';
|
||||||
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
|
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
|
||||||
@ -81,7 +80,6 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
|
|||||||
? selectedSkills
|
? selectedSkills
|
||||||
: const <String>[];
|
: const <String>[];
|
||||||
final hasAnyExplicitSelection =
|
final hasAnyExplicitSelection =
|
||||||
(thread?.hasExplicitExecutionTargetSelection ?? false) ||
|
|
||||||
resolvedExplicitProviderId.isNotEmpty ||
|
resolvedExplicitProviderId.isNotEmpty ||
|
||||||
resolvedExplicitModel.trim().isNotEmpty ||
|
resolvedExplicitModel.trim().isNotEmpty ||
|
||||||
resolvedExplicitSkills.isNotEmpty;
|
resolvedExplicitSkills.isNotEmpty;
|
||||||
|
|||||||
@ -310,6 +310,9 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
|||||||
selectedProviderSource ??
|
selectedProviderSource ??
|
||||||
existing?.executionBinding.providerSource ??
|
existing?.executionBinding.providerSource ??
|
||||||
ThreadSelectionSource.inherited;
|
ThreadSelectionSource.inherited;
|
||||||
|
final normalizedProviderSource = nextProvider.isUnspecified
|
||||||
|
? ThreadSelectionSource.inherited
|
||||||
|
: nextProviderSource;
|
||||||
final nextExecutionBinding =
|
final nextExecutionBinding =
|
||||||
(executionBinding ??
|
(executionBinding ??
|
||||||
existing?.executionBinding ??
|
existing?.executionBinding ??
|
||||||
@ -331,7 +334,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
|||||||
executionModeSource:
|
executionModeSource:
|
||||||
executionTargetSource ??
|
executionTargetSource ??
|
||||||
existing?.executionBinding.executionModeSource,
|
existing?.executionBinding.executionModeSource,
|
||||||
providerSource: nextProviderSource,
|
providerSource: normalizedProviderSource,
|
||||||
);
|
);
|
||||||
final nextContextState =
|
final nextContextState =
|
||||||
(contextState ??
|
(contextState ??
|
||||||
|
|||||||
@ -257,6 +257,40 @@ extension AppControllerDesktopThreadActions on AppController {
|
|||||||
recomputeTasksInternal();
|
recomputeTasksInternal();
|
||||||
throw error;
|
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<void>(
|
await enqueueThreadTurnInternal<void>(
|
||||||
normalizedAssistantSessionKeyInternal(currentSessionKey),
|
normalizedAssistantSessionKeyInternal(currentSessionKey),
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@ -1073,7 +1073,8 @@ class TaskThread {
|
|||||||
bool get hasExplicitExecutionTargetSelection =>
|
bool get hasExplicitExecutionTargetSelection =>
|
||||||
executionBinding.executionModeSource == ThreadSelectionSource.explicit;
|
executionBinding.executionModeSource == ThreadSelectionSource.explicit;
|
||||||
bool get hasExplicitProviderSelection =>
|
bool get hasExplicitProviderSelection =>
|
||||||
executionBinding.providerSource == ThreadSelectionSource.explicit;
|
executionBinding.providerSource == ThreadSelectionSource.explicit &&
|
||||||
|
executionBinding.providerId.trim().isNotEmpty;
|
||||||
bool get hasExplicitModelSelection =>
|
bool get hasExplicitModelSelection =>
|
||||||
contextState.selectedModelSource == ThreadSelectionSource.explicit;
|
contextState.selectedModelSource == ThreadSelectionSource.explicit;
|
||||||
bool get hasExplicitSkillSelection =>
|
bool get hasExplicitSkillSelection =>
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:xworkmate/app/app_controller.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/runtime_models.dart';
|
||||||
import 'package:xworkmate/runtime/secure_config_store.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>[
|
||||||
|
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>[
|
||||||
|
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(
|
test(
|
||||||
'locks the gateway provider catalog to the canonical openclaw contract',
|
'locks the gateway provider catalog to the canonical openclaw contract',
|
||||||
() {
|
() {
|
||||||
@ -240,6 +302,81 @@ void main() {
|
|||||||
expect(capture.lastAuthorizationHeader, 'Bearer bridge-token');
|
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: <String, String>{
|
||||||
|
'BRIDGE_SERVER_URL': capture.baseEndpoint.toString(),
|
||||||
|
'BRIDGE_AUTH_TOKEN': 'bridge-token',
|
||||||
|
},
|
||||||
|
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
|
||||||
|
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<StateError>().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;
|
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(<String, dynamic>{
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': 'capabilities',
|
||||||
|
'result': <String, dynamic>{
|
||||||
|
'singleAgent': false,
|
||||||
|
'multiAgent': true,
|
||||||
|
'availableExecutionTargets': const <String>[],
|
||||||
|
'providerCatalog': const <Map<String, dynamic>>[],
|
||||||
|
'gatewayProviders': const <Map<String, dynamic>>[],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await request.response.close();
|
||||||
|
});
|
||||||
|
return capture;
|
||||||
|
}
|
||||||
|
|
||||||
class _CapabilityServerCapture {
|
class _CapabilityServerCapture {
|
||||||
_CapabilityServerCapture._(this._server, this.baseEndpoint);
|
_CapabilityServerCapture._(this._server, this.baseEndpoint);
|
||||||
|
|
||||||
@ -310,3 +477,57 @@ class _CapabilityServerCapture {
|
|||||||
|
|
||||||
Future<void> close() => _server.close(force: true);
|
Future<void> close() => _server.close(force: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RecordingGoTaskServiceClient implements GoTaskServiceClient {
|
||||||
|
int executeCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
|
||||||
|
required AssistantExecutionTarget target,
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) async => const ExternalCodeAgentAcpCapabilities.empty();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
|
||||||
|
required String taskPrompt,
|
||||||
|
required String workingDirectory,
|
||||||
|
required ExternalCodeAgentAcpRoutingConfig routing,
|
||||||
|
}) async =>
|
||||||
|
const ExternalCodeAgentAcpRoutingResolution(raw: <String, dynamic>{});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GoTaskServiceResult> executeTask(
|
||||||
|
GoTaskServiceRequest request, {
|
||||||
|
required void Function(GoTaskServiceUpdate update) onUpdate,
|
||||||
|
}) async {
|
||||||
|
executeCount += 1;
|
||||||
|
return const GoTaskServiceResult(
|
||||||
|
success: true,
|
||||||
|
message: 'unexpected executeTask call',
|
||||||
|
turnId: 'turn',
|
||||||
|
raw: <String, dynamic>{},
|
||||||
|
errorMessage: '',
|
||||||
|
resolvedModel: '',
|
||||||
|
route: GoTaskServiceRoute.externalAcpSingle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelTask({
|
||||||
|
required GoTaskServiceRoute route,
|
||||||
|
required AssistantExecutionTarget target,
|
||||||
|
required String sessionId,
|
||||||
|
required String threadId,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> closeTask({
|
||||||
|
required GoTaskServiceRoute route,
|
||||||
|
required AssistantExecutionTarget target,
|
||||||
|
required String sessionId,
|
||||||
|
required String threadId,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user