Fix gateway routing when provider catalog is empty

This commit is contained in:
Haitao Pan 2026-04-14 20:15:38 +08:00
parent 78d59292a6
commit 8fa349c483
12 changed files with 358 additions and 71 deletions

View File

@ -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<br/>bridgeGatewayProviderCatalogInternal<br/>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 若未返回 catalogprovider 菜单为空或禁用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)

View File

@ -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 catalogapp 只做目标切换与展示,不做静态拆分或 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)

View File

@ -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 项

View File

@ -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` 返回的动态 catalogapp 不维护 `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) 为准。

View File

@ -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

View File

@ -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)

View File

@ -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 的明确提示。
- 已绑定但当前不可用的 providerUI 给出“不可自动改线”的提示。
- 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、正确线程、正确结果”展开

View File

@ -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 <String>[];
final hasAnyExplicitSelection =
(thread?.hasExplicitExecutionTargetSelection ?? false) ||
resolvedExplicitProviderId.isNotEmpty ||
resolvedExplicitModel.trim().isNotEmpty ||
resolvedExplicitSkills.isNotEmpty;

View File

@ -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 ??

View File

@ -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<void>(
normalizedAssistantSessionKeyInternal(currentSessionKey),
() async {

View File

@ -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 =>

View File

@ -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>[
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(
'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: <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;
}
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 {
_CapabilityServerCapture._(this._server, this.baseEndpoint);
@ -310,3 +477,57 @@ class _CapabilityServerCapture {
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 {}
}