diff --git a/docs/architecture/settings-integration-configuration-model.md b/docs/architecture/settings-integration-configuration-model.md index 1226c4e4..d1c70ffa 100644 --- a/docs/architecture/settings-integration-configuration-model.md +++ b/docs/architecture/settings-integration-configuration-model.md @@ -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 diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md index 1da4f2db..82540b26 100644 --- a/docs/architecture/task-control-plane-unification.md +++ b/docs/architecture/task-control-plane-unification.md @@ -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"] ``` ## 端侧桥接规则 diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index d587c06a..ac269098 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -575,16 +575,19 @@ class AppController extends ChangeNotifier { List get configuredSingleAgentProviders => normalizeSingleAgentProviderList( - (availableSingleAgentProvidersOverrideInternal ?? - bridgeAdvertisedProvidersInternal) - .where((item) => item != SingleAgentProvider.auto) - .map(settings.resolveSingleAgentProvider), + bridgeAdvertisedProvidersInternal.where( + (item) => item != SingleAgentProvider.auto, + ), ); List get availableSingleAgentProviders => - configuredSingleAgentProviders - .where(canUseSingleAgentProviderInternal) - .toList(growable: false); + availableSingleAgentProvidersOverrideInternal != null + ? normalizeSingleAgentProviderList( + availableSingleAgentProvidersOverrideInternal!, + ) + : configuredSingleAgentProviders + .where(canUseSingleAgentProviderInternal) + .toList(growable: false); List visibleAssistantExecutionTargets( Iterable 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 get aiGatewayConversationModelChoices { diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 85be703b..65c229ea 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -56,8 +56,7 @@ Future 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 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 refreshSingleAgentCapabilitiesRuntimeInternal( forceRefresh: forceRefresh, ); controller.bridgeAdvertisedProvidersInternal = - controller.availableSingleAgentProvidersOverrideInternal != null - ? normalizeSingleAgentProviderList( - controller.availableSingleAgentProvidersOverrideInternal!, - ) - : normalizeSingleAgentProviderList( - capabilities.providers.map( - controller.settings.resolveSingleAgentProvider, - ), - ); + normalizeSingleAgentProviderList(capabilities.providerCatalog); final next = {}; - final candidateProviders = - normalizeSingleAgentProviderList([ - ...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: [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 diff --git a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart index 2702ebd9..b94d1cc5 100644 --- a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart +++ b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart @@ -58,30 +58,48 @@ Future 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 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 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 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 {}, - routing: routing, + routing: _resolvedRoutingConfigDesktopInternal( + routing, + routingResolution, + ), routingHint: 'single-agent', - provider: effectiveProvider, + provider: effectiveProvider ?? SingleAgentProvider.auto, remoteWorkingDirectoryHint: controller .requireTaskThreadForSessionInternal(sessionKey) @@ -218,6 +232,33 @@ Future 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 _applySingleAgentGoTaskResultDesktopInternal( AppController controller, { required String sessionKey, @@ -245,7 +286,11 @@ Future _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; } diff --git a/lib/runtime/external_code_agent_acp_desktop_transport.dart b/lib/runtime/external_code_agent_acp_desktop_transport.dart index eb5b4af8..911b6431 100644 --- a/lib/runtime/external_code_agent_acp_desktop_transport.dart +++ b/lib/runtime/external_code_agent_acp_desktop_transport.dart @@ -36,35 +36,49 @@ class ExternalCodeAgentAcpDesktopTransport ); final result = _castMap(response['result']); final caps = _castMap(result['capabilities']); - final providers = {}; - for (final raw in [ - ..._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 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: { + '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 executeTask( GoTaskServiceRequest request, { @@ -200,4 +214,24 @@ class ExternalCodeAgentAcpDesktopTransport } return null; } + + List _parseProviderCatalog(Object? raw) { + final providers = []; + 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); + } } diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 22540ea8..d7792c1c 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -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 {}, }); @@ -28,13 +28,13 @@ class GatewayAcpCapabilities { const GatewayAcpCapabilities.empty() : singleAgent = false, multiAgent = false, - providers = const {}, + providerCatalog = const [], raw = const {}, diagnostics = const {}; final bool singleAgent; final bool multiAgent; - final Set providers; + final List providerCatalog; final Map raw; final Map diagnostics; } @@ -123,25 +123,13 @@ class GatewayAcpClient { ); final result = asMap(response['result']); final caps = asMap(result['capabilities']); - final providers = {}; - for (final raw in [ - ...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 _parseProviderCatalog(Object? raw) { + final providers = []; + 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 runMultiAgent( GatewayAcpMultiAgentRequest request, ) { diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index a0070aab..a6cc23de 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -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 {}, + providerCatalog = const [], raw = const {}; final bool singleAgent; final bool multiAgent; - final Set providers; + final List providerCatalog; final Map raw; } +class ExternalCodeAgentAcpRoutingResolution { + const ExternalCodeAgentAcpRoutingResolution({required this.raw}); + + final Map 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 get resolvedSkills { + final rawList = raw['resolvedSkills']; + if (rawList is! List) { + return const []; + } + 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> 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( - (item) => GoTaskServiceArtifact.fromJson( - item.cast(), - ), + (item) => + GoTaskServiceArtifact.fromJson(item.cast()), ) .where((item) => item.relativePath.isNotEmpty) .toList(growable: false); @@ -571,6 +616,14 @@ abstract class ExternalCodeAgentAcpTransport { bool forceRefresh = false, }); + Future resolveExternalAcpRouting({ + required String taskPrompt, + required String workingDirectory, + required ExternalCodeAgentAcpRoutingConfig routing, + String aiGatewayBaseUrl = '', + String aiGatewayApiKey = '', + }); + Future executeTask( GoTaskServiceRequest request, { required void Function(GoTaskServiceUpdate update) onUpdate, @@ -601,6 +654,14 @@ abstract class GoTaskServiceClient { bool forceRefresh = false, }); + Future resolveExternalAcpRouting({ + required String taskPrompt, + required String workingDirectory, + required ExternalCodeAgentAcpRoutingConfig routing, + String aiGatewayBaseUrl = '', + String aiGatewayApiKey = '', + }); + Future executeTask( GoTaskServiceRequest request, { required void Function(GoTaskServiceUpdate update) onUpdate, diff --git a/lib/runtime/go_task_service_desktop_service.dart b/lib/runtime/go_task_service_desktop_service.dart index 9c8b8961..87c28f01 100644 --- a/lib/runtime/go_task_service_desktop_service.dart +++ b/lib/runtime/go_task_service_desktop_service.dart @@ -24,6 +24,21 @@ class DesktopGoTaskService implements GoTaskServiceClient { forceRefresh: forceRefresh, ); + @override + Future 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 executeTask( GoTaskServiceRequest request, { diff --git a/test/runtime/external_code_agent_acp_desktop_transport_test.dart b/test/runtime/external_code_agent_acp_desktop_transport_test.dart index 14e41910..703808b2 100644 --- a/test/runtime/external_code_agent_acp_desktop_transport_test.dart +++ b/test/runtime/external_code_agent_acp_desktop_transport_test.dart @@ -28,7 +28,23 @@ class _FakeGoAcpStdioBridge extends GoAcpStdioBridge { 'result': { 'singleAgent': true, 'multiAgent': true, - 'providers': ['codex', 'opencode', 'gemini'], + 'providerCatalog': >[ + {'providerId': 'codex', 'label': 'Codex'}, + {'providerId': 'opencode', 'label': 'OpenCode'}, + {'providerId': 'gemini', 'label': 'Gemini'}, + ], + }, + }; + } + if (method == 'xworkmate.routing.resolve') { + return { + 'result': { + 'resolvedExecutionTarget': 'single-agent', + 'resolvedEndpointTarget': 'singleAgent', + 'resolvedProviderId': 'gemini', + 'resolvedModel': 'gemini-2.5-pro', + 'resolvedSkills': ['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, ['acp.capabilities']); - expect( - capabilities.providers.map((item) => item.providerId).toList()..sort(), - ['codex', 'gemini', 'opencode'], - ); - }); + expect(bridge.methods, ['acp.capabilities']); + expect( + capabilities.providerCatalog.map((item) => item.providerId).toList(), + ['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( - providerId: 'codex', - label: 'Codex', - endpoint: 'https://acp-server.svc.plus/codex/acp/rpc', - authorizationHeader: '', - enabled: true, + await transport + .syncExternalProviders(const [ + ExternalCodeAgentAcpSyncedProvider( + providerId: 'codex', + label: 'Codex', + endpoint: 'https://acp-server.svc.plus/codex/acp/rpc', + authorizationHeader: '', + enabled: true, + ), + ]); + + expect(bridge.methods, ['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, ['xworkmate.providers.sync']); - }); + expect(bridge.methods, ['xworkmate.routing.resolve']); + expect(resolution.resolvedProviderId, 'gemini'); + expect(resolution.resolvedModel, 'gemini-2.5-pro'); + expect(resolution.resolvedSkills, ['pptx']); + }, + ); }); }