refactor: consume bridge-owned single-agent routing

This commit is contained in:
Haitao Pan 2026-04-10 21:20:35 +08:00
parent 94ecedee84
commit 13f39dcb8a
10 changed files with 381 additions and 190 deletions

View File

@ -25,7 +25,7 @@ flowchart TD
E --> F["xworkmate-bridge providerCatalog"]
F --> G["acp.capabilities"]
G --> H["providers[]
G --> H["providerCatalog[]
singleAgent / multiAgent"]
H --> I["refreshSingleAgentCapabilitiesRuntimeInternal()"]
@ -36,7 +36,7 @@ flowchart TD
G --> L["refreshAcpCapabilitiesRuntimeInternal()"]
L --> M["GatewayAcpCapabilities
providers / singleAgent / multiAgent"]
providerCatalog / singleAgent / multiAgent"]
M --> N["mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal()"]
N --> O["ManagedMountTargetState
codex / opencode / claude / gemini / aris / openclaw
@ -63,21 +63,25 @@ flowchart TD
恢复线程已选 providerId"]
V --> W["sendSingleAgentMessageDesktopGoTaskFlowInternal()"]
W --> X["再次拉取 acp.capabilities"]
X --> Y["按本次 bridge providers 解析
auto -> 当前 bridge 顺序第一个可用 provider
explicit -> 当前 bridge 已广告的 provider"]
W --> X["xworkmate.routing.resolve"]
X --> Y["bridge 返回 resolvedExecutionTarget /
resolvedProviderId /
unavailableCode /
unavailableMessage"]
Y --> Z{"provider resolved?"}
Z -->|"yes"| AA["executeTask(... provider ...)"]
Z -->|"no"| AB["provider unavailable UX"]
Y --> Z{"unavailable?"}
Z -->|"no"| AA["executeTask(... resolved routing ...)"]
Z -->|"yes"| AB["provider unavailable UX
直接使用 bridge unavailable message"]
```
## Notes
- `externalAcpEndpoints` still matters, but only as bridge sync input.
- Provider visibility, picker contents, and auto-provider resolution all come
from `acp.capabilities.providers`.
- Provider visibility and picker contents come from
`acp.capabilities.providerCatalog`.
- Auto-provider resolution and unavailable messaging come from
`xworkmate.routing.resolve`.
- `openclaw` and other mount-target discovery states are also bridge-owned and
come from ACP capabilities merged into `ManagedMountTargetState`.
- Persisted thread `providerId` restores the user's previous selection, but it

View File

@ -62,7 +62,7 @@ flowchart TD
E --> F["xworkmate-bridge providerCatalog"]
F --> G["acp.capabilities"]
G --> H["providers[]
G --> H["providerCatalog[]
singleAgent / multiAgent"]
H --> I["refreshSingleAgentCapabilitiesRuntimeInternal()"]
@ -73,7 +73,7 @@ flowchart TD
G --> L["refreshAcpCapabilitiesRuntimeInternal()"]
L --> M["GatewayAcpCapabilities
providers / singleAgent / multiAgent"]
providerCatalog / singleAgent / multiAgent"]
M --> N["mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal()"]
N --> O["ManagedMountTargetState
codex / opencode / claude / gemini / aris / openclaw
@ -100,14 +100,15 @@ flowchart TD
恢复线程已选 providerId"]
V --> W["sendSingleAgentMessageDesktopGoTaskFlowInternal()"]
W --> X["再次拉取 acp.capabilities"]
X --> Y["按本次 bridge providers 解析
auto -> 当前 bridge 顺序第一个可用 provider
explicit -> 当前 bridge 已广告的 provider"]
W --> X["xworkmate.routing.resolve"]
X --> Y["resolvedProviderId /
unavailableCode /
unavailableMessage"]
Y --> Z{"provider resolved?"}
Z -->|"yes"| AA["executeTask(... provider ...)"]
Z -->|"no"| AB["provider unavailable UX"]
Y --> Z{"unavailable?"}
Z -->|"no"| AA["executeTask(... resolved routing ...)"]
Z -->|"yes"| AB["provider unavailable UX
直接使用 bridge unavailable message"]
```
## 端侧桥接规则

View File

@ -575,16 +575,19 @@ class AppController extends ChangeNotifier {
List<SingleAgentProvider> get configuredSingleAgentProviders =>
normalizeSingleAgentProviderList(
(availableSingleAgentProvidersOverrideInternal ??
bridgeAdvertisedProvidersInternal)
.where((item) => item != SingleAgentProvider.auto)
.map(settings.resolveSingleAgentProvider),
bridgeAdvertisedProvidersInternal.where(
(item) => item != SingleAgentProvider.auto,
),
);
List<SingleAgentProvider> get availableSingleAgentProviders =>
configuredSingleAgentProviders
.where(canUseSingleAgentProviderInternal)
.toList(growable: false);
availableSingleAgentProvidersOverrideInternal != null
? normalizeSingleAgentProviderList(
availableSingleAgentProvidersOverrideInternal!,
)
: configuredSingleAgentProviders
.where(canUseSingleAgentProviderInternal)
.toList(growable: false);
List<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
@ -625,18 +628,13 @@ class AppController extends ChangeNotifier {
SingleAgentProvider? resolvedSingleAgentProviderInternal(
SingleAgentProvider selection,
) {
if (selection != SingleAgentProvider.auto) {
final resolvedSelection = settings.resolveSingleAgentProvider(selection);
return canUseSingleAgentProviderInternal(resolvedSelection)
? resolvedSelection
: null;
if (selection == SingleAgentProvider.auto) {
return null;
}
for (final provider in configuredSingleAgentProviders) {
if (canUseSingleAgentProviderInternal(provider)) {
return provider;
}
}
return null;
final resolvedSelection = settings.resolveSingleAgentProvider(selection);
return canUseSingleAgentProviderInternal(resolvedSelection)
? resolvedSelection
: null;
}
List<String> get aiGatewayConversationModelChoices {

View File

@ -56,8 +56,7 @@ Future<void> refreshAcpCapabilitiesRuntimeInternal(
final target = controller.assistantExecutionTargetForSession(
controller.sessionsControllerInternal.currentSessionKey,
);
final resolvedProvider =
target == AssistantExecutionTarget.singleAgent
final resolvedProvider = target == AssistantExecutionTarget.singleAgent
? (controller.singleAgentResolvedProviderForSession(
controller.sessionsControllerInternal.currentSessionKey,
) ??
@ -68,9 +67,10 @@ Future<void> refreshAcpCapabilitiesRuntimeInternal(
: controller.resolveSingleAgentEndpointInternal(resolvedProvider);
final authorizationOverride = resolvedProvider == null
? ''
: await controller.resolveSingleAgentAuthorizationHeaderForProviderInternal(
resolvedProvider,
);
: await controller
.resolveSingleAgentAuthorizationHeaderForProviderInternal(
resolvedProvider,
);
await controller.gatewayAcpClientInternal.loadCapabilities(
forceRefresh: forceRefresh,
endpointOverride: endpointOverride,
@ -109,28 +109,9 @@ Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
forceRefresh: forceRefresh,
);
controller.bridgeAdvertisedProvidersInternal =
controller.availableSingleAgentProvidersOverrideInternal != null
? normalizeSingleAgentProviderList(
controller.availableSingleAgentProvidersOverrideInternal!,
)
: normalizeSingleAgentProviderList(
capabilities.providers.map(
controller.settings.resolveSingleAgentProvider,
),
);
normalizeSingleAgentProviderList(capabilities.providerCatalog);
final next = <SingleAgentProvider, SingleAgentCapabilities>{};
final candidateProviders =
normalizeSingleAgentProviderList(<SingleAgentProvider>[
...controller.configuredSingleAgentProviders,
...capabilities.providers.map(
controller.settings.resolveSingleAgentProvider,
),
]);
for (final provider in candidateProviders) {
if (!capabilities.providers.contains(provider)) {
next[provider] = const SingleAgentCapabilities.unavailable(endpoint: '');
continue;
}
for (final provider in controller.bridgeAdvertisedProvidersInternal) {
next[provider] = SingleAgentCapabilities(
available: true,
supportedProviders: <SingleAgentProvider>[provider],
@ -150,7 +131,7 @@ mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
GatewayAcpCapabilities capabilities,
) {
final source = current.isEmpty ? ManagedMountTargetState.defaults() : current;
final providers = capabilities.providers
final providers = capabilities.providerCatalog
.map((item) => item.providerId)
.toSet();
return source

View File

@ -58,30 +58,48 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
sessionKey,
);
final selection = controller.singleAgentProviderForSession(sessionKey);
final capabilities = await controller.goTaskServiceClientInternal
.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,
forceRefresh: true,
final preflightWorkingDirectory = controller
.resolveSingleAgentWorkingDirectoryForSessionInternal(sessionKey);
if (preflightWorkingDirectory == null ||
preflightWorkingDirectory.trim().isEmpty) {
final error = StateError(
appText(
'当前线程缺少可运行的工作路径,无法启动单机智能体。',
'This thread does not have a runnable workspace path, so Single Agent cannot start.',
),
);
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
error.message,
),
);
throw error;
}
final aiGatewayApiKey = await controller.loadAiGatewayApiKey();
final routingResolution = await controller.goTaskServiceClientInternal
.resolveExternalAcpRouting(
taskPrompt: message,
workingDirectory: preflightWorkingDirectory,
routing: routing,
aiGatewayBaseUrl: controller.aiGatewayUrl,
aiGatewayApiKey: aiGatewayApiKey,
);
final advertisedProviders =
controller.availableSingleAgentProvidersOverrideInternal != null
? normalizeSingleAgentProviderList(
controller.availableSingleAgentProvidersOverrideInternal!,
)
: normalizeSingleAgentProviderList(
capabilities.providers.map(
controller.settings.resolveSingleAgentProvider,
),
final effectiveProvider =
routingResolution.resolvedProviderId.trim().isEmpty
? null
: SingleAgentProviderCopy.fromJsonValue(
routingResolution.resolvedProviderId,
);
controller.bridgeAdvertisedProvidersInternal = advertisedProviders;
final availableProviders = advertisedProviders
.where(capabilities.providers.contains)
.toList(growable: false);
final provider = selection == SingleAgentProvider.auto
? (availableProviders.isEmpty ? null : availableProviders.first)
: (capabilities.providers.contains(selection) ? selection : null);
final unavailableReason = provider == null
? (selection == SingleAgentProvider.auto
final unavailableReason =
routingResolution.unavailable ||
(routingResolution.resolvedExecutionTarget == 'single-agent' &&
effectiveProvider == null)
? (routingResolution.unavailableMessage.isNotEmpty
? routingResolution.unavailableMessage
: selection == SingleAgentProvider.auto
? appText(
'当前没有可用的 GoTaskService Provider。',
'No GoTaskService provider is currently available.',
@ -91,7 +109,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
'GoTaskService does not currently support ${selection.label}.',
))
: null;
if (provider == null) {
if (unavailableReason != null) {
controller.upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
@ -112,34 +130,23 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
);
return;
}
final effectiveProvider = provider;
appendSingleAgentRuntimeStatusDesktopInternal(
controller,
sessionKey,
effectiveProvider,
);
if (effectiveProvider != null) {
appendSingleAgentRuntimeStatusDesktopInternal(
controller,
sessionKey,
effectiveProvider,
);
}
final workingDirectory = controller
.resolveSingleAgentWorkingDirectoryForSessionInternal(
sessionKey,
provider: provider,
provider: effectiveProvider,
);
if (workingDirectory == null || workingDirectory.trim().isEmpty) {
final error = StateError(
appText(
'当前线程缺少可运行的工作路径,无法启动单机智能体。',
'This thread does not have a runnable workspace path, so Single Agent cannot start.',
),
);
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
error.message,
),
);
throw error;
}
final resolvedWorkingDirectory =
workingDirectory == null || workingDirectory.trim().isEmpty
? preflightWorkingDirectory
: workingDirectory;
final selectedSkills = controller
.assistantSelectedSkillsForSession(sessionKey)
@ -152,19 +159,26 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
threadId: sessionKey,
target: AssistantExecutionTarget.singleAgent,
prompt: message,
workingDirectory: workingDirectory,
model: controller.assistantModelForSession(sessionKey),
workingDirectory: resolvedWorkingDirectory,
model: routingResolution.resolvedModel.trim().isNotEmpty
? routingResolution.resolvedModel
: controller.assistantModelForSession(sessionKey),
thinking: thinking,
selectedSkills: selectedSkills,
selectedSkills: routingResolution.resolvedSkills.isNotEmpty
? routingResolution.resolvedSkills
: selectedSkills,
inlineAttachments: attachments,
localAttachments: localAttachments,
aiGatewayBaseUrl: controller.aiGatewayUrl,
aiGatewayApiKey: await controller.loadAiGatewayApiKey(),
aiGatewayApiKey: aiGatewayApiKey,
agentId: '',
metadata: const <String, dynamic>{},
routing: routing,
routing: _resolvedRoutingConfigDesktopInternal(
routing,
routingResolution,
),
routingHint: 'single-agent',
provider: effectiveProvider,
provider: effectiveProvider ?? SingleAgentProvider.auto,
remoteWorkingDirectoryHint:
controller
.requireTaskThreadForSessionInternal(sessionKey)
@ -218,6 +232,33 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
});
}
ExternalCodeAgentAcpRoutingConfig _resolvedRoutingConfigDesktopInternal(
ExternalCodeAgentAcpRoutingConfig original,
ExternalCodeAgentAcpRoutingResolution resolution,
) {
final explicitExecutionTarget = switch (resolution.resolvedExecutionTarget
.trim()
.toLowerCase()) {
'single-agent' => 'singleAgent',
'gateway' =>
resolution.resolvedEndpointTarget.trim().toLowerCase() == 'remote'
? 'remote'
: 'local',
_ => original.explicitExecutionTarget,
};
return ExternalCodeAgentAcpRoutingConfig(
mode: ExternalCodeAgentAcpRoutingMode.explicit,
preferredGatewayTarget: original.preferredGatewayTarget,
explicitExecutionTarget: explicitExecutionTarget,
explicitProviderId: resolution.resolvedProviderId,
explicitModel: resolution.resolvedModel,
explicitSkills: resolution.resolvedSkills,
allowSkillInstall: original.allowSkillInstall,
availableSkills: original.availableSkills,
installApproval: original.installApproval,
);
}
Future<void> _applySingleAgentGoTaskResultDesktopInternal(
AppController controller, {
required String sessionKey,
@ -245,7 +286,11 @@ Future<void> _applySingleAgentGoTaskResultDesktopInternal(
lastRemoteWorkspaceRefKind: result.remoteWorkspaceRefKind,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await _persistSingleAgentArtifactsDesktopInternal(controller, sessionKey, result);
await _persistSingleAgentArtifactsDesktopInternal(
controller,
sessionKey,
result,
);
controller.clearAiGatewayStreamingTextInternal(sessionKey);
if (!result.success) {
controller.appendAssistantThreadMessageInternal(
@ -356,7 +401,9 @@ String _sanitizeArtifactRelativePathInternal(String raw) {
}
final cleaned = trimmed
.split('/')
.where((segment) => segment.isNotEmpty && segment != '.' && segment != '..')
.where(
(segment) => segment.isNotEmpty && segment != '.' && segment != '..',
)
.join('/');
return cleaned;
}

View File

@ -36,35 +36,49 @@ class ExternalCodeAgentAcpDesktopTransport
);
final result = _castMap(response['result']);
final caps = _castMap(result['capabilities']);
final providers = <SingleAgentProvider>{};
for (final raw in <Object?>[
..._asList(result['providers']),
..._asList(caps['providers']),
]) {
if (raw == null) {
continue;
}
final provider = SingleAgentProviderCopy.fromJsonValue(
raw.toString().trim().toLowerCase(),
);
if (provider != SingleAgentProvider.auto) {
providers.add(provider);
}
}
final providerCatalog = _parseProviderCatalog(
result['providerCatalog'] ?? caps['providerCatalog'],
);
return ExternalCodeAgentAcpCapabilities(
singleAgent:
_boolValue(result['singleAgent']) ??
_boolValue(caps['single_agent']) ??
providers.isNotEmpty,
providerCatalog.isNotEmpty,
multiAgent:
_boolValue(result['multiAgent']) ??
_boolValue(caps['multi_agent']) ??
true,
providers: providers,
providerCatalog: providerCatalog,
raw: result,
);
}
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
String aiGatewayBaseUrl = '',
String aiGatewayApiKey = '',
}) async {
await _syncProviders();
final response = await _bridge.request(
method: 'xworkmate.routing.resolve',
params: <String, dynamic>{
'taskPrompt': taskPrompt,
'workingDirectory': workingDirectory.trim(),
if (aiGatewayBaseUrl.trim().isNotEmpty)
'aiGatewayBaseUrl': aiGatewayBaseUrl.trim(),
if (aiGatewayApiKey.trim().isNotEmpty)
'aiGatewayApiKey': aiGatewayApiKey.trim(),
'routing': routing.toJson(),
},
);
return ExternalCodeAgentAcpRoutingResolution(
raw: _castMap(response['result']),
);
}
@override
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
@ -200,4 +214,24 @@ class ExternalCodeAgentAcpDesktopTransport
}
return null;
}
List<SingleAgentProvider> _parseProviderCatalog(Object? raw) {
final providers = <SingleAgentProvider>[];
for (final item in _asList(raw)) {
final entry = _castMap(item);
final providerId = entry['providerId']?.toString().trim() ?? '';
if (providerId.isEmpty) {
continue;
}
final label = entry['label']?.toString().trim();
final provider = SingleAgentProviderCopy.fromJsonValue(
providerId,
label: label?.isNotEmpty == true ? label : null,
);
if (provider != SingleAgentProvider.auto) {
providers.add(provider);
}
}
return normalizeSingleAgentProviderList(providers);
}
}

View File

@ -20,7 +20,7 @@ class GatewayAcpCapabilities {
const GatewayAcpCapabilities({
required this.singleAgent,
required this.multiAgent,
required this.providers,
required this.providerCatalog,
required this.raw,
this.diagnostics = const <String, dynamic>{},
});
@ -28,13 +28,13 @@ class GatewayAcpCapabilities {
const GatewayAcpCapabilities.empty()
: singleAgent = false,
multiAgent = false,
providers = const <SingleAgentProvider>{},
providerCatalog = const <SingleAgentProvider>[],
raw = const <String, dynamic>{},
diagnostics = const <String, dynamic>{};
final bool singleAgent;
final bool multiAgent;
final Set<SingleAgentProvider> providers;
final List<SingleAgentProvider> providerCatalog;
final Map<String, dynamic> raw;
final Map<String, dynamic> diagnostics;
}
@ -123,25 +123,13 @@ class GatewayAcpClient {
);
final result = asMap(response['result']);
final caps = asMap(result['capabilities']);
final providers = <SingleAgentProvider>{};
for (final raw in <Object?>[
...asList(result['providers']),
...asList(caps['providers']),
]) {
if (raw == null) {
continue;
}
final provider = SingleAgentProviderCopy.fromJsonValue(
raw.toString().trim().toLowerCase(),
);
if (provider != SingleAgentProvider.auto) {
providers.add(provider);
}
}
final providerCatalog = _parseProviderCatalog(
result['providerCatalog'] ?? caps['providerCatalog'],
);
final singleAgent =
boolValue(result['singleAgent']) ??
boolValue(caps['single_agent']) ??
providers.isNotEmpty;
providerCatalog.isNotEmpty;
final multiAgent =
boolValue(result['multiAgent']) ??
boolValue(caps['multi_agent']) ??
@ -149,7 +137,7 @@ class GatewayAcpClient {
_cachedCapabilities = GatewayAcpCapabilities(
singleAgent: singleAgent,
multiAgent: multiAgent,
providers: providers,
providerCatalog: providerCatalog,
raw: result,
diagnostics: asMap(response['_xworkmateDiagnostics']),
);
@ -157,6 +145,26 @@ class GatewayAcpClient {
return _cachedCapabilities;
}
List<SingleAgentProvider> _parseProviderCatalog(Object? raw) {
final providers = <SingleAgentProvider>[];
for (final item in asList(raw)) {
final entry = asMap(item);
final providerId = entry['providerId']?.toString().trim() ?? '';
if (providerId.isEmpty) {
continue;
}
final label = entry['label']?.toString().trim();
final provider = SingleAgentProviderCopy.fromJsonValue(
providerId,
label: label?.isNotEmpty == true ? label : null,
);
if (provider != SingleAgentProvider.auto) {
providers.add(provider);
}
}
return normalizeSingleAgentProviderList(providers);
}
Stream<MultiAgentRunEvent> runMultiAgent(
GatewayAcpMultiAgentRequest request,
) {

View File

@ -8,22 +8,68 @@ class ExternalCodeAgentAcpCapabilities {
const ExternalCodeAgentAcpCapabilities({
required this.singleAgent,
required this.multiAgent,
required this.providers,
required this.providerCatalog,
required this.raw,
});
const ExternalCodeAgentAcpCapabilities.empty()
: singleAgent = false,
multiAgent = false,
providers = const <SingleAgentProvider>{},
providerCatalog = const <SingleAgentProvider>[],
raw = const <String, dynamic>{};
final bool singleAgent;
final bool multiAgent;
final Set<SingleAgentProvider> providers;
final List<SingleAgentProvider> providerCatalog;
final Map<String, dynamic> raw;
}
class ExternalCodeAgentAcpRoutingResolution {
const ExternalCodeAgentAcpRoutingResolution({required this.raw});
final Map<String, dynamic> raw;
String get resolvedExecutionTarget =>
raw['resolvedExecutionTarget']?.toString().trim() ?? '';
String get resolvedEndpointTarget =>
raw['resolvedEndpointTarget']?.toString().trim() ?? '';
String get resolvedProviderId =>
raw['resolvedProviderId']?.toString().trim() ?? '';
String get resolvedModel => raw['resolvedModel']?.toString().trim() ?? '';
List<String> get resolvedSkills {
final rawList = raw['resolvedSkills'];
if (rawList is! List) {
return const <String>[];
}
return rawList
.map((item) => item?.toString().trim() ?? '')
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
String get skillResolutionSource =>
raw['skillResolutionSource']?.toString().trim() ?? '';
bool get needsSkillInstall => _boolValue(raw['needsSkillInstall']) ?? false;
String get skillInstallRequestId =>
raw['skillInstallRequestId']?.toString().trim() ?? '';
List<Map<String, dynamic>> get skillCandidates =>
_castMapList(raw['skillCandidates']);
bool get unavailable => _boolValue(raw['unavailable']) ?? false;
String get unavailableCode => raw['unavailableCode']?.toString().trim() ?? '';
String get unavailableMessage =>
raw['unavailableMessage']?.toString().trim() ?? '';
}
class ExternalCodeAgentAcpSyncedProvider {
const ExternalCodeAgentAcpSyncedProvider({
required this.providerId,
@ -493,9 +539,8 @@ class GoTaskServiceResult {
return rawArtifacts
.whereType<Map>()
.map(
(item) => GoTaskServiceArtifact.fromJson(
item.cast<String, dynamic>(),
),
(item) =>
GoTaskServiceArtifact.fromJson(item.cast<String, dynamic>()),
)
.where((item) => item.relativePath.isNotEmpty)
.toList(growable: false);
@ -571,6 +616,14 @@ abstract class ExternalCodeAgentAcpTransport {
bool forceRefresh = false,
});
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
String aiGatewayBaseUrl = '',
String aiGatewayApiKey = '',
});
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,
@ -601,6 +654,14 @@ abstract class GoTaskServiceClient {
bool forceRefresh = false,
});
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
String aiGatewayBaseUrl = '',
String aiGatewayApiKey = '',
});
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,

View File

@ -24,6 +24,21 @@ class DesktopGoTaskService implements GoTaskServiceClient {
forceRefresh: forceRefresh,
);
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
String aiGatewayBaseUrl = '',
String aiGatewayApiKey = '',
}) => _acpTransport.resolveExternalAcpRouting(
taskPrompt: taskPrompt,
workingDirectory: workingDirectory,
routing: routing,
aiGatewayBaseUrl: aiGatewayBaseUrl,
aiGatewayApiKey: aiGatewayApiKey,
);
@override
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {

View File

@ -28,7 +28,23 @@ class _FakeGoAcpStdioBridge extends GoAcpStdioBridge {
'result': <String, dynamic>{
'singleAgent': true,
'multiAgent': true,
'providers': <String>['codex', 'opencode', 'gemini'],
'providerCatalog': <Map<String, dynamic>>[
<String, dynamic>{'providerId': 'codex', 'label': 'Codex'},
<String, dynamic>{'providerId': 'opencode', 'label': 'OpenCode'},
<String, dynamic>{'providerId': 'gemini', 'label': 'Gemini'},
],
},
};
}
if (method == 'xworkmate.routing.resolve') {
return <String, dynamic>{
'result': <String, dynamic>{
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'gemini',
'resolvedModel': 'gemini-2.5-pro',
'resolvedSkills': <String>['pptx'],
'unavailable': false,
},
};
}
@ -43,38 +59,64 @@ class _FakeGoAcpStdioBridge extends GoAcpStdioBridge {
void main() {
group('ExternalCodeAgentAcpDesktopTransport', () {
test('reads bridge capabilities without pushing an empty provider sync', () async {
final bridge = _FakeGoAcpStdioBridge();
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
test(
'reads bridge capabilities without pushing an empty provider sync',
() async {
final bridge = _FakeGoAcpStdioBridge();
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
final capabilities = await transport.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,
);
final capabilities = await transport.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,
);
expect(bridge.methods, <String>['acp.capabilities']);
expect(
capabilities.providers.map((item) => item.providerId).toList()..sort(),
<String>['codex', 'gemini', 'opencode'],
);
});
expect(bridge.methods, <String>['acp.capabilities']);
expect(
capabilities.providerCatalog.map((item) => item.providerId).toList(),
<String>['codex', 'opencode', 'gemini'],
);
},
);
test('only syncs when app has explicit provider overrides to send', () async {
final bridge = _FakeGoAcpStdioBridge();
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
test(
'only syncs when app has explicit provider overrides to send',
() async {
final bridge = _FakeGoAcpStdioBridge();
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
await transport.syncExternalProviders(
const <ExternalCodeAgentAcpSyncedProvider>[
ExternalCodeAgentAcpSyncedProvider(
providerId: 'codex',
label: 'Codex',
endpoint: 'https://acp-server.svc.plus/codex/acp/rpc',
authorizationHeader: '',
enabled: true,
await transport
.syncExternalProviders(const <ExternalCodeAgentAcpSyncedProvider>[
ExternalCodeAgentAcpSyncedProvider(
providerId: 'codex',
label: 'Codex',
endpoint: 'https://acp-server.svc.plus/codex/acp/rpc',
authorizationHeader: '',
enabled: true,
),
]);
expect(bridge.methods, <String>['xworkmate.providers.sync']);
},
);
test(
'uses bridge routing resolve for preflight provider selection',
() async {
final bridge = _FakeGoAcpStdioBridge();
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
final resolution = await transport.resolveExternalAcpRouting(
taskPrompt: 'make slides',
workingDirectory: '/tmp/workspace',
routing: const ExternalCodeAgentAcpRoutingConfig.auto(
preferredGatewayTarget: 'local',
),
],
);
);
expect(bridge.methods, <String>['xworkmate.providers.sync']);
});
expect(bridge.methods, <String>['xworkmate.routing.resolve']);
expect(resolution.resolvedProviderId, 'gemini');
expect(resolution.resolvedModel, 'gemini-2.5-pro');
expect(resolution.resolvedSkills, <String>['pptx']);
},
);
});
}