Drive task dialog providers from bridge catalog

This commit is contained in:
Haitao Pan 2026-04-14 10:05:10 +08:00
parent 69f0d451d9
commit d4e09d7113
18 changed files with 511 additions and 147 deletions

View File

@ -29,7 +29,7 @@ flowchart TD
subgraph APPSTATE["App-side derived state"]
F["refreshSingleAgentCapabilitiesRuntimeInternal()"]
G["bridgeProviderCatalogInternal"]
G["bridgeAgentProviderCatalogInternal<br/>bridgeGatewayProviderCatalogInternal<br/>bridgeAvailableExecutionTargetsInternal"]
H["singleAgentCapabilitiesByProviderInternal"]
I["refreshAcpCapabilitiesRuntimeInternal()"]
J["GatewayAcpCapabilities"]
@ -78,7 +78,7 @@ flowchart TD
## Notes
- `providerCatalog` 只负责 assistant provider picker;不会因为线程里保存过 `providerId` 就被 app 反向重建
- provider picker 的真源只来自 bridge 返回的 target-scoped catalog;不会因为线程里保存过 `providerId` 就被 app 反向重建
- gateway runtime 可见性来自 bridge capability snapshot 与 `xworkmate.gateway.*` 返回,不来自旧设置页枚举
- bridge 若返回额外 capability flag这些 flag 只属于合同元数据,不会自动生成新的 settings tab 或 module page
- production provider / gateway 选择继续由 bridge 拥有app 只保留消费与展示

View File

@ -76,12 +76,14 @@ flowchart TD
### Provider Truth
- `acp.capabilities.providerCatalog` 是 assistant provider picker 的唯一上游真源
- `acp.capabilities` 是任务对话模式与 provider picker 的唯一上游真源
- 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog
- provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve`
- 任务对话模式的 provider 菜单按 execution target 分流:
- `agent` 只展示 bridge-owned provider catalog`codex / opencode / gemini`
- `gateway` 只展示 canonical gateway provider`OpenClaw`
- bridge 返回 `availableExecutionTargets` 与 target-scoped provider catalogapp 只做目标切换与展示,不做静态拆分或 canonical 单项硬编码
- app 只负责:
- 展示 `agent` / `gateway` 目标切换
- 请求 bridge contract
- 按 bridge 返回结果渲染 provider 菜单与默认项
- 这里不保留旧的 provider matrix、preset fallback 或双真源选择路径
### Gateway Truth

View File

@ -149,7 +149,8 @@ Status: `Active`
当前 Assistant 事实:
- provider catalog 只来自 bridge capabilities不再恢复任何 preset / backfill / fallback provider truth
- 任务对话模式按 execution target 分流:`智能体` 只提供 `codex / opencode / gemini``Gateway` 只提供 `OpenClaw`
- 任务对话模式只保留两类一级目标:`agent` / `gateway`
- 每个目标下的 provider 菜单都只消费 `xworkmate-bridge` 返回的动态 catalogapp 不维护 `codex / opencode / gemini / openclaw` 这类本地固定列表
- task state 仍在 assistant 内被消费,但不再拥有独立 `TasksPage`
- skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage`
- assistant focus 只保留仍有真实落点的 `settings / language / theme`

View File

@ -120,6 +120,8 @@ class AppController extends ChangeNotifier {
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
List<SingleAgentProvider>? initialBridgeProviderCatalog,
List<SingleAgentProvider>? initialGatewayProviderCatalog,
List<AssistantExecutionTarget>? initialAvailableExecutionTargets,
SkillDirectoryAccessService? skillDirectoryAccessService,
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
Map<String, String>? environmentOverride,
@ -225,9 +227,15 @@ class AppController extends ChangeNotifier {
endpointResolver: resolveGatewayAcpEndpointInternal,
),
);
bridgeProviderCatalogInternal = normalizeBridgeOwnedSingleAgentProviderList(
bridgeAgentProviderCatalogInternal = normalizeSingleAgentProviderList(
initialBridgeProviderCatalog ?? const <SingleAgentProvider>[],
);
bridgeGatewayProviderCatalogInternal = normalizeSingleAgentProviderList(
initialGatewayProviderCatalog ?? const <SingleAgentProvider>[],
);
bridgeAvailableExecutionTargetsInternal = compactAssistantExecutionTargets(
initialAvailableExecutionTargets ?? const <AssistantExecutionTarget>[],
);
attachChildListenersInternal();
unawaited(initializeInternal());
@ -290,8 +298,12 @@ class AppController extends ChangeNotifier {
GatewayAcpClient get gatewayAcpClientForTest => gatewayAcpClientInternal;
List<SingleAgentProvider> bridgeProviderCatalogInternal =
List<SingleAgentProvider> bridgeAgentProviderCatalogInternal =
const <SingleAgentProvider>[];
List<SingleAgentProvider> bridgeGatewayProviderCatalogInternal =
const <SingleAgentProvider>[];
List<AssistantExecutionTarget> bridgeAvailableExecutionTargetsInternal =
const <AssistantExecutionTarget>[];
final Map<String, List<GatewayChatMessage>> assistantThreadMessagesInternal =
<String, List<GatewayChatMessage>>{};
late final DesktopTaskThreadRepository taskThreadRepositoryInternal =
@ -546,19 +558,30 @@ class AppController extends ChangeNotifier {
);
List<SingleAgentProvider> get bridgeProviderCatalog =>
normalizeSingleAgentProviderList(bridgeProviderCatalogInternal);
normalizeSingleAgentProviderList(<SingleAgentProvider>[
...bridgeAgentProviderCatalogInternal,
...bridgeGatewayProviderCatalogInternal,
]);
List<SingleAgentProvider> get assistantProviderCatalog =>
normalizeBridgeOwnedSingleAgentProviderList(
bridgeProviderCatalogInternal,
);
normalizeSingleAgentProviderList(bridgeAgentProviderCatalogInternal);
List<SingleAgentProvider> get gatewayProviderCatalog =>
normalizeSingleAgentProviderList(bridgeGatewayProviderCatalogInternal);
List<AssistantExecutionTarget> get bridgeAvailableExecutionTargets =>
compactAssistantExecutionTargets(bridgeAvailableExecutionTargetsInternal);
List<SingleAgentProvider> get assistantProviderCatalogForDisplay {
final liveCatalog = assistantProviderCatalog;
if (liveCatalog.isNotEmpty) {
return liveCatalog;
}
return kBridgeOwnedSingleAgentProviders;
return assistantProviderCatalog;
}
List<SingleAgentProvider> providerCatalogForExecutionTarget(
AssistantExecutionTarget executionTarget,
) {
return executionTarget.isGateway
? gatewayProviderCatalog
: assistantProviderCatalogForDisplay;
}
SingleAgentProvider? bridgeProviderForId(String providerId) {
@ -574,11 +597,14 @@ class AppController extends ChangeNotifier {
return null;
}
SingleAgentProvider resolveAssistantProvider(String? providerId) {
SingleAgentProvider resolveProviderForExecutionTarget(
String? providerId, {
required AssistantExecutionTarget executionTarget,
}) {
final normalizedProviderId = normalizeSingleAgentProviderId(
providerId ?? '',
);
final catalog = assistantProviderCatalogForDisplay;
final catalog = providerCatalogForExecutionTarget(executionTarget);
if (normalizedProviderId.isNotEmpty) {
for (final provider in catalog) {
if (provider.providerId == normalizedProviderId) {
@ -589,9 +615,23 @@ class AppController extends ChangeNotifier {
if (catalog.isNotEmpty) {
return catalog.first;
}
if (normalizedProviderId.isNotEmpty) {
return SingleAgentProvider.fromJsonValue(
normalizedProviderId,
supportedTargets: <AssistantExecutionTarget>[executionTarget],
enabled: false,
);
}
return SingleAgentProvider.unspecified;
}
SingleAgentProvider resolveAssistantProvider(String? providerId) {
return resolveProviderForExecutionTarget(
providerId,
executionTarget: AssistantExecutionTarget.agent,
);
}
SingleAgentProvider assistantProviderForSession(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@ -600,10 +640,10 @@ class AppController extends ChangeNotifier {
final executionTarget = assistantExecutionTargetForSession(
normalizedSessionKey,
);
if (executionTarget.isGateway) {
return SingleAgentProvider.openclaw;
}
return resolveAssistantProvider(thread?.executionBinding.providerId);
return resolveProviderForExecutionTarget(
thread?.executionBinding.providerId,
executionTarget: executionTarget,
);
}
UiFeatureManifest loadRepoUiFeatureManifestSyncInternal() {
@ -618,7 +658,16 @@ class AppController extends ChangeNotifier {
List<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
) => compactAssistantExecutionTargets(supportedTargets);
) {
final visible = compactAssistantExecutionTargets(supportedTargets);
final bridgeVisible = bridgeAvailableExecutionTargets;
if (bridgeVisible.isEmpty) {
return visible;
}
return visible
.where((item) => bridgeVisible.contains(item))
.toList(growable: false);
}
List<String> get aiGatewayConversationModelChoices {
final availableModels =

View File

@ -59,9 +59,13 @@ Future<void> refreshAcpCapabilitiesRuntimeInternal(
// Keep mount refresh resilient when ACP is temporarily unavailable.
}
if (capabilities != null) {
controller.bridgeProviderCatalogInternal =
normalizeBridgeOwnedSingleAgentProviderList(
capabilities.providerCatalog,
controller.bridgeAgentProviderCatalogInternal =
normalizeSingleAgentProviderList(capabilities.providerCatalog);
controller.bridgeGatewayProviderCatalogInternal =
normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog);
controller.bridgeAvailableExecutionTargetsInternal =
compactAssistantExecutionTargets(
capabilities.availableExecutionTargets,
);
}
if (persistMountTargets && !controller.disposedInternal) {
@ -90,12 +94,21 @@ Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
try {
final capabilities = await controller.gatewayAcpClientInternal
.loadCapabilities(forceRefresh: forceRefresh);
controller.bridgeProviderCatalogInternal =
normalizeBridgeOwnedSingleAgentProviderList(
capabilities.providerCatalog,
controller.bridgeAgentProviderCatalogInternal =
normalizeSingleAgentProviderList(capabilities.providerCatalog);
controller.bridgeGatewayProviderCatalogInternal =
normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog);
controller.bridgeAvailableExecutionTargetsInternal =
compactAssistantExecutionTargets(
capabilities.availableExecutionTargets,
);
} catch (_) {
controller.bridgeProviderCatalogInternal = const <SingleAgentProvider>[];
controller.bridgeAgentProviderCatalogInternal =
const <SingleAgentProvider>[];
controller.bridgeGatewayProviderCatalogInternal =
const <SingleAgentProvider>[];
controller.bridgeAvailableExecutionTargetsInternal =
const <AssistantExecutionTarget>[];
}
if (!controller.disposedInternal) {
controller.notifyListeners();
@ -109,16 +122,19 @@ mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
GatewayAcpCapabilities capabilities,
) {
final source = current.isEmpty ? ManagedMountTargetState.defaults() : current;
final providers = capabilities.providerCatalog
final agentProviders = capabilities.providerCatalog
.map((item) => item.providerId)
.toSet();
final gatewayProviders = capabilities.gatewayProviderCatalog
.map((item) => item.providerId)
.toSet();
return source
.map((item) {
final available = switch (item.targetId) {
'codex' => providers.contains('codex'),
'opencode' => providers.contains('opencode'),
'gemini' => providers.contains('gemini'),
'openclaw' => capabilities.multiAgent || capabilities.singleAgent,
'codex' => agentProviders.contains('codex'),
'opencode' => agentProviders.contains('opencode'),
'gemini' => agentProviders.contains('gemini'),
'openclaw' => gatewayProviders.contains('openclaw'),
_ => false,
};
return item.copyWith(

View File

@ -300,9 +300,10 @@ extension AppControllerDesktopSkillPermissions on AppController {
existing?.contextState.latestResolvedProviderId ??
'',
);
final nextProvider = nextProviderId.isEmpty
? SingleAgentProvider.unspecified
: resolveAssistantProvider(nextProviderId);
final nextProvider = resolveProviderForExecutionTarget(
nextProviderId,
executionTarget: nextExecutionTarget,
);
final nextProviderSource =
singleAgentProviderSource ??
existing?.executionBinding.providerSource ??

View File

@ -222,9 +222,10 @@ extension AppControllerDesktopThreadBinding on AppController {
final persistedProviderId = normalizeSingleAgentProviderId(
existingBinding?.providerId ?? '',
);
final selectedProvider = persistedProviderId.isEmpty
? SingleAgentProvider.unspecified
: resolveAssistantProvider(persistedProviderId);
final selectedProvider = resolveProviderForExecutionTarget(
persistedProviderId,
executionTarget: executionTarget,
);
return (existingBinding ??
ExecutionBinding(
executionMode: threadExecutionModeFromAssistantExecutionTarget(

View File

@ -458,16 +458,7 @@ AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordsForTest(
final record = primaryRecord ?? fallbackRecord;
return record == null
? AssistantExecutionTarget.agent
: (() {
final resolved = assistantExecutionTargetFromExecutionMode(
record.executionBinding.executionMode,
);
if (resolved.isGateway &&
isBridgeOwnedSingleAgentProviderId(
record.executionBinding.providerId,
)) {
return AssistantExecutionTarget.agent;
}
return resolved;
})();
: assistantExecutionTargetFromExecutionMode(
record.executionBinding.executionMode,
);
}

View File

@ -688,15 +688,11 @@ extension AppControllerDesktopThreadStorage on AppController {
final recordProviderId = normalizeSingleAgentProviderId(
record.executionBinding.providerId,
);
final recordProvider = recordProviderId.isEmpty
? SingleAgentProvider.unspecified
: resolveAssistantProvider(recordProviderId);
final normalizedExecutionTarget =
recordExecutionTarget.isGateway &&
recordProviderId.isNotEmpty &&
isBridgeOwnedSingleAgentProviderId(recordProviderId)
? AssistantExecutionTarget.agent
: recordExecutionTarget;
final normalizedExecutionTarget = recordExecutionTarget;
final recordProvider = resolveProviderForExecutionTarget(
recordProviderId,
executionTarget: normalizedExecutionTarget,
);
final workspaceBinding = record.workspaceBinding.copyWith(
workspaceId: sessionKey,
displayPath: record.workspaceKind == WorkspaceKind.localFs

View File

@ -54,14 +54,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
sessionsControllerInternal.currentSessionKey,
);
final shouldRefreshAgentProviders =
resolvedTarget.isAgent && assistantProviderCatalog.isEmpty;
providerCatalogForExecutionTarget(resolvedTarget).isEmpty;
if (shouldRefreshAgentProviders) {
try {
await refreshSingleAgentCapabilitiesInternal(forceRefresh: true);
} catch (_) {
// Keep target selection interactive even when a just-in-time
// capabilities refresh fails. The dialog still shows the canonical
// single-agent providers while the live catalog catches up.
// capabilities refresh fails. The dialog stays interactive while the
// live catalog catches up from bridge capabilities.
}
if (currentTarget == resolvedTarget &&
settings.assistantExecutionTarget == resolvedTarget) {
@ -99,6 +99,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
sessionsControllerInternal.currentSessionKey,
executionTarget: resolvedTarget,
executionTargetSource: ThreadSelectionSource.explicit,
singleAgentProvider: resolveProviderForExecutionTarget(
taskThreadForSessionInternal(
sessionsControllerInternal.currentSessionKey,
)?.executionBinding.providerId,
executionTarget: resolvedTarget,
),
singleAgentProviderSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget),
latestResolvedRuntimeModel: '',
latestResolvedProviderId: '',
@ -215,6 +222,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
);
upsertTaskThreadInternal(
normalizedSessionKey,
singleAgentProvider: resolveProviderForExecutionTarget(
taskThreadForSessionInternal(normalizedSessionKey)
?.executionBinding
.providerId,
executionTarget: resolvedTarget,
),
singleAgentProviderSource: ThreadSelectionSource.explicit,
latestResolvedRuntimeModel: '',
latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),

View File

@ -217,6 +217,7 @@ class SingleAgentProviderBadgeInternal extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = context.palette;
final logoEmoji = provider.logoEmoji.trim();
final candidate = provider.badge.trim().isEmpty
? provider.label
: provider.badge;
@ -235,13 +236,13 @@ class SingleAgentProviderBadgeInternal extends StatelessWidget {
border: Border.all(color: palette.strokeSoft),
),
child: Text(
display,
logoEmoji.isEmpty ? display : logoEmoji,
maxLines: 1,
overflow: TextOverflow.clip,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w700,
fontSize: 9,
fontSize: logoEmoji.isEmpty ? 9 : 11,
height: 1,
),
),

View File

@ -23,16 +23,22 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
final supportedExecutionTargets = compactAssistantExecutionTargets(
uiFeatures.availableExecutionTargets,
);
if (visibleExecutionTargets.isEmpty) {
if (supportedExecutionTargets.isEmpty) {
return const SizedBox.shrink();
}
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
supportedExecutionTargets,
);
final resolutionTargets = visibleExecutionTargets.isNotEmpty
? visibleExecutionTargets
: supportedExecutionTargets;
final currentExecutionTarget =
resolveAssistantExecutionTargetFromVisibleTargets(
visibleExecutionTargets,
resolutionTargets,
currentTarget: controller.assistantExecutionTarget,
);
final executionTarget = collapseAssistantExecutionTargetForDisplay(
@ -51,17 +57,17 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
_TaskDialogExecutionTargetMenuButtonInternal(
controller: controller,
executionTarget: executionTarget,
supportedExecutionTargets: supportedExecutionTargets,
visibleExecutionTargets: visibleExecutionTargets,
),
if (providerMenuProviders.isNotEmpty)
_TaskDialogProviderMenuButtonInternal(
controller: controller,
executionTarget: executionTarget,
selectedProvider: controller.assistantProviderForSession(
controller.currentSessionKey,
),
providers: providerMenuProviders,
_TaskDialogProviderMenuButtonInternal(
controller: controller,
executionTarget: executionTarget,
selectedProvider: controller.assistantProviderForSession(
controller.currentSessionKey,
),
providers: providerMenuProviders,
),
],
);
}
@ -71,30 +77,24 @@ List<SingleAgentProvider> _taskDialogProviderCatalogForTarget({
required AppController controller,
required AssistantExecutionTarget executionTarget,
}) {
if (executionTarget.isGateway) {
return <SingleAgentProvider>[
controller.assistantProviderForSession(controller.currentSessionKey),
];
}
return controller.assistantProviderCatalogForDisplay;
return controller.providerCatalogForExecutionTarget(executionTarget);
}
class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
const _TaskDialogExecutionTargetMenuButtonInternal({
required this.controller,
required this.executionTarget,
required this.supportedExecutionTargets,
required this.visibleExecutionTargets,
});
final AppController controller;
final AssistantExecutionTarget executionTarget;
final List<AssistantExecutionTarget> supportedExecutionTargets;
final List<AssistantExecutionTarget> visibleExecutionTargets;
@override
Widget build(BuildContext context) {
final compactExecutionTargets = compactAssistantExecutionTargets(
visibleExecutionTargets,
);
final palette = context.palette;
final selectedLabel = executionTarget.label;
@ -104,21 +104,25 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
onSelected: (value) {
unawaited(_handleExecutionTargetSelected(value));
},
itemBuilder: (context) => compactExecutionTargets
itemBuilder: (context) => supportedExecutionTargets
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
value: value,
key: Key('assistant-execution-target-menu-item-${value.name}'),
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == executionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
),
(value) {
final enabled = visibleExecutionTargets.contains(value);
return PopupMenuItem<AssistantExecutionTarget>(
value: value,
enabled: enabled,
key: Key('assistant-execution-target-menu-item-${value.name}'),
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == executionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
);
},
)
.toList(growable: false),
child: _TaskDialogSelectorChipInternal(
@ -156,11 +160,13 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
@override
Widget build(BuildContext context) {
final displayProvider = selectedProvider.isUnspecified
? _fallbackDisplayProvider()
? _fallbackDisplayProvider(context)
: selectedProvider;
final isEnabled = providers.isNotEmpty;
return PopupMenuButton<SingleAgentProvider>(
key: const Key('assistant-provider-button'),
enabled: isEnabled,
tooltip: appText('智能体 Provider', 'Agent Provider'),
onSelected: (provider) {
unawaited(_handleProviderSelected(provider));
@ -198,18 +204,21 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
);
}
SingleAgentProvider _fallbackDisplayProvider() {
if (executionTarget.isGateway) {
return SingleAgentProvider.openclaw;
}
SingleAgentProvider _fallbackDisplayProvider(BuildContext context) {
if (providers.isNotEmpty) {
return providers.first;
}
return SingleAgentProvider.codex;
return SingleAgentProvider(
providerId: '',
label: appText('未提供', 'Unavailable'),
badge: '?',
supportedTargets: <AssistantExecutionTarget>[executionTarget],
enabled: false,
);
}
Future<void> _handleProviderSelected(SingleAgentProvider provider) async {
if (executionTarget.isGateway) {
if (executionTarget.isGateway || providers.isEmpty) {
return;
}
await controller.setAssistantSingleAgentProvider(provider);

View File

@ -34,9 +34,11 @@ class ExternalCodeAgentAcpDesktopTransport
final caps = _castMap(result['capabilities']);
final providerCatalog = _parseProviderCatalog(
result['providerCatalog'] ?? caps['providerCatalog'],
defaultTarget: AssistantExecutionTarget.agent,
);
final gatewayProviders = _castMapList(
final gatewayProviders = _parseProviderCatalog(
result['gatewayProviders'] ?? caps['gatewayProviders'],
defaultTarget: AssistantExecutionTarget.gateway,
);
return ExternalCodeAgentAcpCapabilities(
singleAgent:
@ -47,6 +49,14 @@ class ExternalCodeAgentAcpDesktopTransport
_boolValue(result['multiAgent']) ??
_boolValue(caps['multi_agent']) ??
true,
availableExecutionTargets: _parseAvailableExecutionTargets(
result['availableExecutionTargets'] ?? caps['availableExecutionTargets'],
singleAgent:
_boolValue(result['singleAgent']) ??
_boolValue(caps['single_agent']) ??
providerCatalog.isNotEmpty,
gatewayProviders: gatewayProviders,
),
providerCatalog: providerCatalog,
gatewayProviders: gatewayProviders,
raw: result,
@ -166,10 +176,6 @@ class ExternalCodeAgentAcpDesktopTransport
return const <Object?>[];
}
List<Map<String, dynamic>> _castMapList(Object? raw) {
return _asList(raw).map(_castMap).toList(growable: false);
}
bool? _boolValue(Object? raw) {
if (raw is bool) {
return raw;
@ -190,7 +196,10 @@ class ExternalCodeAgentAcpDesktopTransport
return null;
}
List<SingleAgentProvider> _parseProviderCatalog(Object? raw) {
List<SingleAgentProvider> _parseProviderCatalog(
Object? raw, {
required AssistantExecutionTarget defaultTarget,
}) {
final providers = <SingleAgentProvider>[];
for (final item in _asList(raw)) {
final entry = _castMap(item);
@ -199,14 +208,85 @@ class ExternalCodeAgentAcpDesktopTransport
continue;
}
final label = entry['label']?.toString().trim();
final providerDisplay = _castMap(entry['providerDisplay']);
final targets = _parseProviderTargets(
entry['targets'] ?? entry['executionTarget'],
defaultTarget: defaultTarget,
);
final provider = SingleAgentProviderCopy.fromJsonValue(
providerId,
label: label?.isNotEmpty == true ? label : null,
badge: entry['badge']?.toString().trim().isNotEmpty == true
? entry['badge']?.toString().trim()
: providerDisplay['badge']?.toString().trim(),
logoEmoji: entry['logoEmoji']?.toString().trim().isNotEmpty == true
? entry['logoEmoji']?.toString().trim()
: providerDisplay['logoEmoji']?.toString().trim(),
supportedTargets: targets,
enabled: _boolValue(entry['enabled']) ?? true,
unavailableReason:
entry['unavailableReason']?.toString().trim().isNotEmpty == true
? entry['unavailableReason']?.toString().trim()
: '',
);
if (!provider.isUnspecified) {
providers.add(provider);
}
}
return normalizeBridgeOwnedSingleAgentProviderList(providers);
return normalizeSingleAgentProviderList(providers);
}
List<AssistantExecutionTarget> _parseAvailableExecutionTargets(
Object? raw, {
required bool singleAgent,
required List<SingleAgentProvider> gatewayProviders,
}) {
final parsed = <AssistantExecutionTarget>[];
for (final item in _asList(raw)) {
final normalized = item?.toString().trim().toLowerCase() ?? '';
if (normalized == 'agent' || normalized == 'single-agent') {
if (!parsed.contains(AssistantExecutionTarget.agent)) {
parsed.add(AssistantExecutionTarget.agent);
}
} else if (normalized == 'gateway') {
if (!parsed.contains(AssistantExecutionTarget.gateway)) {
parsed.add(AssistantExecutionTarget.gateway);
}
}
}
if (parsed.isNotEmpty) {
return parsed;
}
if (singleAgent) {
parsed.add(AssistantExecutionTarget.agent);
}
if (gatewayProviders.isNotEmpty) {
parsed.add(AssistantExecutionTarget.gateway);
}
return parsed;
}
List<AssistantExecutionTarget> _parseProviderTargets(
Object? raw, {
required AssistantExecutionTarget defaultTarget,
}) {
final parsed = <AssistantExecutionTarget>[];
final items = raw is List ? raw : <Object?>[raw];
for (final item in items) {
final normalized = item?.toString().trim().toLowerCase() ?? '';
if (normalized == 'agent' || normalized == 'single-agent') {
if (!parsed.contains(AssistantExecutionTarget.agent)) {
parsed.add(AssistantExecutionTarget.agent);
}
} else if (normalized == 'gateway') {
if (!parsed.contains(AssistantExecutionTarget.gateway)) {
parsed.add(AssistantExecutionTarget.gateway);
}
}
}
if (parsed.isNotEmpty) {
return parsed;
}
return <AssistantExecutionTarget>[defaultTarget];
}
}

View File

@ -20,7 +20,9 @@ class GatewayAcpCapabilities {
const GatewayAcpCapabilities({
required this.singleAgent,
required this.multiAgent,
required this.availableExecutionTargets,
required this.providerCatalog,
required this.gatewayProviderCatalog,
required this.raw,
this.diagnostics = const <String, dynamic>{},
});
@ -28,13 +30,17 @@ class GatewayAcpCapabilities {
const GatewayAcpCapabilities.empty()
: singleAgent = false,
multiAgent = false,
availableExecutionTargets = const <AssistantExecutionTarget>[],
providerCatalog = const <SingleAgentProvider>[],
gatewayProviderCatalog = const <SingleAgentProvider>[],
raw = const <String, dynamic>{},
diagnostics = const <String, dynamic>{};
final bool singleAgent;
final bool multiAgent;
final List<AssistantExecutionTarget> availableExecutionTargets;
final List<SingleAgentProvider> providerCatalog;
final List<SingleAgentProvider> gatewayProviderCatalog;
final Map<String, dynamic> raw;
final Map<String, dynamic> diagnostics;
}
@ -121,6 +127,11 @@ class GatewayAcpClient {
final caps = asMap(result['capabilities']);
final providerCatalog = _parseProviderCatalog(
result['providerCatalog'] ?? caps['providerCatalog'],
defaultTarget: AssistantExecutionTarget.agent,
);
final gatewayProviderCatalog = _parseProviderCatalog(
result['gatewayProviders'] ?? caps['gatewayProviders'],
defaultTarget: AssistantExecutionTarget.gateway,
);
final singleAgent =
boolValue(result['singleAgent']) ??
@ -133,7 +144,13 @@ class GatewayAcpClient {
_cachedCapabilities = GatewayAcpCapabilities(
singleAgent: singleAgent,
multiAgent: multiAgent,
availableExecutionTargets: _parseAvailableExecutionTargets(
result['availableExecutionTargets'] ?? caps['availableExecutionTargets'],
singleAgent: singleAgent,
gatewayProviderCatalog: gatewayProviderCatalog,
),
providerCatalog: providerCatalog,
gatewayProviderCatalog: gatewayProviderCatalog,
raw: result,
diagnostics: asMap(response['_xworkmateDiagnostics']),
);
@ -141,7 +158,10 @@ class GatewayAcpClient {
return _cachedCapabilities;
}
List<SingleAgentProvider> _parseProviderCatalog(Object? raw) {
List<SingleAgentProvider> _parseProviderCatalog(
Object? raw, {
required AssistantExecutionTarget defaultTarget,
}) {
final providers = <SingleAgentProvider>[];
for (final item in asList(raw)) {
final entry = asMap(item);
@ -150,15 +170,86 @@ class GatewayAcpClient {
continue;
}
final label = entry['label']?.toString().trim();
final providerDisplay = asMap(entry['providerDisplay']);
final targets = _parseProviderTargets(
entry['targets'] ?? entry['executionTarget'],
defaultTarget: defaultTarget,
);
final provider = SingleAgentProviderCopy.fromJsonValue(
providerId,
label: label?.isNotEmpty == true ? label : null,
badge: entry['badge']?.toString().trim().isNotEmpty == true
? entry['badge']?.toString().trim()
: providerDisplay['badge']?.toString().trim(),
logoEmoji: entry['logoEmoji']?.toString().trim().isNotEmpty == true
? entry['logoEmoji']?.toString().trim()
: providerDisplay['logoEmoji']?.toString().trim(),
supportedTargets: targets,
enabled: boolValue(entry['enabled']) ?? true,
unavailableReason:
entry['unavailableReason']?.toString().trim().isNotEmpty == true
? entry['unavailableReason']?.toString().trim()
: '',
);
if (!provider.isUnspecified) {
providers.add(provider);
}
}
return normalizeBridgeOwnedSingleAgentProviderList(providers);
return normalizeSingleAgentProviderList(providers);
}
List<AssistantExecutionTarget> _parseAvailableExecutionTargets(
Object? raw, {
required bool singleAgent,
required List<SingleAgentProvider> gatewayProviderCatalog,
}) {
final parsed = <AssistantExecutionTarget>[];
for (final item in asList(raw)) {
final normalized = item?.toString().trim().toLowerCase() ?? '';
if (normalized == 'agent' || normalized == 'single-agent') {
if (!parsed.contains(AssistantExecutionTarget.agent)) {
parsed.add(AssistantExecutionTarget.agent);
}
} else if (normalized == 'gateway') {
if (!parsed.contains(AssistantExecutionTarget.gateway)) {
parsed.add(AssistantExecutionTarget.gateway);
}
}
}
if (parsed.isNotEmpty) {
return parsed;
}
if (singleAgent) {
parsed.add(AssistantExecutionTarget.agent);
}
if (gatewayProviderCatalog.isNotEmpty) {
parsed.add(AssistantExecutionTarget.gateway);
}
return parsed;
}
List<AssistantExecutionTarget> _parseProviderTargets(
Object? raw, {
required AssistantExecutionTarget defaultTarget,
}) {
final parsed = <AssistantExecutionTarget>[];
final items = raw is List ? raw : <Object?>[raw];
for (final item in items) {
final normalized = item?.toString().trim().toLowerCase() ?? '';
if (normalized == 'agent' || normalized == 'single-agent') {
if (!parsed.contains(AssistantExecutionTarget.agent)) {
parsed.add(AssistantExecutionTarget.agent);
}
} else if (normalized == 'gateway') {
if (!parsed.contains(AssistantExecutionTarget.gateway)) {
parsed.add(AssistantExecutionTarget.gateway);
}
}
}
if (parsed.isNotEmpty) {
return parsed;
}
return <AssistantExecutionTarget>[defaultTarget];
}
Stream<MultiAgentRunEvent> runMultiAgent(

View File

@ -8,6 +8,7 @@ class ExternalCodeAgentAcpCapabilities {
const ExternalCodeAgentAcpCapabilities({
required this.singleAgent,
required this.multiAgent,
required this.availableExecutionTargets,
required this.providerCatalog,
required this.gatewayProviders,
required this.raw,
@ -16,14 +17,16 @@ class ExternalCodeAgentAcpCapabilities {
const ExternalCodeAgentAcpCapabilities.empty()
: singleAgent = false,
multiAgent = false,
availableExecutionTargets = const <AssistantExecutionTarget>[],
providerCatalog = const <SingleAgentProvider>[],
gatewayProviders = const <Map<String, dynamic>>[],
gatewayProviders = const <SingleAgentProvider>[],
raw = const <String, dynamic>{};
final bool singleAgent;
final bool multiAgent;
final List<AssistantExecutionTarget> availableExecutionTargets;
final List<SingleAgentProvider> providerCatalog;
final List<Map<String, dynamic>> gatewayProviders;
final List<SingleAgentProvider> gatewayProviders;
final Map<String, dynamic> raw;
}

View File

@ -185,6 +185,10 @@ class SingleAgentProvider {
required this.providerId,
required this.label,
required this.badge,
this.logoEmoji = '',
this.supportedTargets = const <AssistantExecutionTarget>[],
this.enabled = true,
this.unavailableReason = '',
this.source = SingleAgentProviderSource.externalExtension,
});
@ -227,6 +231,10 @@ class SingleAgentProvider {
final String providerId;
final String label;
final String badge;
final String logoEmoji;
final List<AssistantExecutionTarget> supportedTargets;
final bool enabled;
final String unavailableReason;
final SingleAgentProviderSource source;
bool get isUnspecified => providerId.trim().isEmpty;
@ -237,6 +245,10 @@ class SingleAgentProvider {
String? providerId,
String? label,
String? badge,
String? logoEmoji,
List<AssistantExecutionTarget>? supportedTargets,
bool? enabled,
String? unavailableReason,
SingleAgentProviderSource? source,
}) {
final resolvedProviderId = normalizeSingleAgentProviderId(
@ -255,6 +267,13 @@ class SingleAgentProvider {
label: resolvedLabel,
)
: resolvedBadge,
logoEmoji: (logoEmoji ?? this.logoEmoji).trim(),
supportedTargets:
supportedTargets ??
List<AssistantExecutionTarget>.from(this.supportedTargets),
enabled: enabled ?? this.enabled,
unavailableReason:
(unavailableReason ?? this.unavailableReason).trim(),
source: source ?? this.source,
);
}
@ -263,6 +282,10 @@ class SingleAgentProvider {
String? value, {
String? label,
String? badge,
String? logoEmoji,
List<AssistantExecutionTarget>? supportedTargets,
bool? enabled,
String? unavailableReason,
}) {
final normalized = normalizeSingleAgentProviderId(value ?? '');
final base = switch (normalized) {
@ -281,7 +304,14 @@ class SingleAgentProvider {
),
),
};
return base.copyWith(label: label, badge: badge);
return base.copyWith(
label: label,
badge: badge,
logoEmoji: logoEmoji,
supportedTargets: supportedTargets,
enabled: enabled,
unavailableReason: unavailableReason,
);
}
@override
@ -298,7 +328,19 @@ extension SingleAgentProviderCopy on SingleAgentProvider {
String? value, {
String? label,
String? badge,
}) => SingleAgentProvider.fromJsonValue(value, label: label, badge: badge);
String? logoEmoji,
List<AssistantExecutionTarget>? supportedTargets,
bool? enabled,
String? unavailableReason,
}) => SingleAgentProvider.fromJsonValue(
value,
label: label,
badge: badge,
logoEmoji: logoEmoji,
supportedTargets: supportedTargets,
enabled: enabled,
unavailableReason: unavailableReason,
);
}
enum SingleAgentProviderSource { externalExtension }
@ -319,26 +361,9 @@ List<SingleAgentProvider> normalizeSingleAgentProviderList(
const String kCanonicalGatewayProviderId = 'openclaw';
const String kCanonicalGatewayProviderLabel = 'OpenClaw';
const List<SingleAgentProvider> kBridgeOwnedSingleAgentProviders =
<SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
];
bool isBridgeOwnedSingleAgentProviderId(String providerId) {
final normalized = normalizeSingleAgentProviderId(providerId);
return kBridgeOwnedSingleAgentProviders.any(
(item) => item.providerId == normalized,
);
}
List<SingleAgentProvider> normalizeBridgeOwnedSingleAgentProviderList(
Iterable<SingleAgentProvider> providers,
) {
return normalizeSingleAgentProviderList(
providers.where(
(provider) => isBridgeOwnedSingleAgentProviderId(provider.providerId),
),
);
return normalized == 'codex' ||
normalized == 'opencode' ||
normalized == 'gemini';
}

View File

@ -11,7 +11,7 @@ import 'package:xworkmate/widgets/surface_card.dart';
void main() {
group('AssistantLowerPaneInternal', () {
testWidgets(
'keeps canonical agent providers visible when live capabilities are unavailable',
'does not fabricate providers when live capabilities are unavailable',
(tester) async {
final controller = AppController();
addTearDown(controller.dispose);
@ -33,15 +33,15 @@ void main() {
expect(
find.byKey(const Key('assistant-provider-menu-item-codex')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-opencode')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
findsNothing,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
@ -57,6 +57,18 @@ void main() {
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
],
initialGatewayProviderCatalog: <SingleAgentProvider>[
SingleAgentProvider.openclaw.copyWith(
logoEmoji: '🦞',
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
),
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);

View File

@ -51,6 +51,78 @@ void main() {
expect(provider.label, kCanonicalGatewayProviderLabel);
});
test(
'switching a session to gateway uses the bridge-provided gateway catalog',
() async {
final controller = AppController(
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
],
initialGatewayProviderCatalog: <SingleAgentProvider>[
SingleAgentProvider.openclaw.copyWith(
logoEmoji: '🦞',
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
),
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
expect(controller.currentAssistantExecutionTarget.isAgent, isTrue);
expect(
controller.assistantProviderForSession(controller.currentSessionKey),
SingleAgentProvider.codex,
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
final record = controller.requireTaskThreadForSessionInternal(
'session-1',
);
expect(
controller.assistantExecutionTargetForSession('session-1').isGateway,
isTrue,
);
expect(
assistantExecutionTargetFromExecutionMode(
record.executionBinding.executionMode,
),
AssistantExecutionTarget.gateway,
);
expect(
controller.assistantProviderForSession('session-1'),
SingleAgentProvider.openclaw,
);
},
);
test(
'returns an unavailable provider placeholder when a saved provider is no longer in the bridge catalog',
() {
final controller = AppController();
addTearDown(controller.dispose);
final unavailableProvider = controller.resolveProviderForExecutionTarget(
'gemini',
executionTarget: AssistantExecutionTarget.agent,
);
expect(unavailableProvider.providerId, 'gemini');
expect(unavailableProvider.enabled, isFalse);
},
);
test(
'refreshes agent provider catalog when agent mode is selected with an empty catalog',
() async {