From f73a7b55bd45dc5e50293e476d41adc2b967c8dc Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 2 May 2026 12:10:08 +0800 Subject: [PATCH] fix: route gateway execution through managed bridge --- lib/app/app_controller_desktop_core.dart | 5 +++ ...ntroller_desktop_external_acp_routing.dart | 3 +- ...ler_desktop_runtime_coordination_impl.dart | 16 ++++++- ...app_controller_desktop_thread_actions.dart | 4 +- ...pp_controller_desktop_thread_sessions.dart | 42 +++++++++++++++++-- .../go_runtime_dispatch_desktop_client.dart | 4 +- lib/runtime/go_task_service_client.dart | 14 ++----- .../assistant_connection_status_test.dart | 20 +++++++++ .../assistant_execution_target_test.dart | 18 ++++++-- .../runtime/gateway_acp_client_auth_test.dart | 9 ++-- ..._runtime_dispatch_desktop_client_test.dart | 2 +- 11 files changed, 108 insertions(+), 29 deletions(-) diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 42a2d823..bb7edb0f 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -240,6 +240,9 @@ class AppController extends ChangeNotifier { bridgeAvailableExecutionTargetsInternal = compactAssistantExecutionTargets( initialAvailableExecutionTargets ?? const [], ); + bridgeCapabilitiesRefreshAttemptedInternal = + bridgeAgentProviderCatalogInternal.isNotEmpty || + bridgeGatewayProviderCatalogInternal.isNotEmpty; attachChildListenersInternal(); unawaited(initializeInternal()); @@ -308,6 +311,8 @@ class AppController extends ChangeNotifier { const []; List bridgeAvailableExecutionTargetsInternal = const []; + bool bridgeCapabilitiesRefreshAttemptedInternal = false; + String bridgeCapabilitiesRefreshErrorInternal = ''; final Map> assistantThreadMessagesInternal = >{}; late final DesktopTaskThreadRepository taskThreadRepositoryInternal = diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index 1f954431..492a4c13 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -69,7 +69,8 @@ extension AppControllerDesktopExternalAcpRouting on AppController { ); final resolvedProvider = assistantProviderForSession(normalizedSessionKey); final resolvedExplicitProviderId = - (thread?.hasExplicitProviderSelection == true || currentTarget.isGateway) && + thread?.hasExplicitProviderSelection == true && + !currentTarget.isGateway && !resolvedProvider.isUnspecified ? resolvedProvider.providerId : ''; diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 480b3e60..56c31aa3 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -51,14 +51,18 @@ Future refreshAcpCapabilitiesRuntimeInternal( bool persistMountTargets = false, }) async { GatewayAcpCapabilities? capabilities; + Object? refreshError; try { capabilities = await controller.gatewayAcpClientInternal.loadCapabilities( forceRefresh: forceRefresh, ); - } catch (_) { + } catch (error) { + refreshError = error; // Keep mount refresh resilient when ACP is temporarily unavailable. } + controller.bridgeCapabilitiesRefreshAttemptedInternal = true; if (capabilities != null) { + controller.bridgeCapabilitiesRefreshErrorInternal = ''; controller.bridgeAgentProviderCatalogInternal = normalizeSingleAgentProviderList(capabilities.providerCatalog); controller.bridgeGatewayProviderCatalogInternal = @@ -67,6 +71,10 @@ Future refreshAcpCapabilitiesRuntimeInternal( compactAssistantExecutionTargets( capabilities.availableExecutionTargets, ); + } else if (refreshError != null) { + controller.bridgeCapabilitiesRefreshErrorInternal = refreshError + .toString() + .trim(); } if (persistMountTargets && !controller.disposedInternal) { final currentConfig = controller.settings.multiAgent; @@ -102,7 +110,11 @@ Future refreshSingleAgentCapabilitiesRuntimeInternal( compactAssistantExecutionTargets( capabilities.availableExecutionTargets, ); - } catch (_) { + controller.bridgeCapabilitiesRefreshAttemptedInternal = true; + controller.bridgeCapabilitiesRefreshErrorInternal = ''; + } catch (error) { + controller.bridgeCapabilitiesRefreshAttemptedInternal = true; + controller.bridgeCapabilitiesRefreshErrorInternal = error.toString().trim(); controller.bridgeAgentProviderCatalogInternal = const []; controller.bridgeGatewayProviderCatalogInternal = diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index a4ff6594..1122a8fe 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -234,7 +234,9 @@ extension AppControllerDesktopThreadActions on AppController { final currentSessionKey = sessionsControllerInternal.currentSessionKey; final currentTarget = assistantExecutionTargetForSession(currentSessionKey); var connectionState = currentAssistantConnectionState; - if (!connectionState.connected && isBridgeAcpRuntimeConfiguredInternal()) { + if (!connectionState.connected && + isBridgeAcpRuntimeConfiguredInternal() && + !bridgeCapabilitiesRefreshAttemptedInternal) { try { await refreshAcpCapabilitiesInternal(forceRefresh: true); connectionState = currentAssistantConnectionState; diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 05cedd5e..cb12b908 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -55,6 +55,9 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ required AccountSyncState? accountSyncState, required bool accountSignedIn, required bool bridgeConfigured, + bool bridgeDiscoveryAttempted = false, + String bridgeDiscoveryError = '', + bool providerCatalogEmpty = false, }) { if (bridgeReady) { return AssistantThreadConnectionState( @@ -114,14 +117,41 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ ); } + final discoveryError = bridgeDiscoveryError.trim(); + if (bridgeConfigured && bridgeDiscoveryAttempted) { + final status = RuntimeConnectionStatus.error; + final detailLabel = discoveryError.isNotEmpty + ? discoveryError + : providerCatalogEmpty + ? appText( + 'Gateway ACP 未报告可用的 provider', + 'Gateway ACP did not report a usable provider', + ) + : appText( + 'xworkmate-bridge 连接失败', + 'xworkmate-bridge connection failed', + ); + return AssistantThreadConnectionState( + executionTarget: target, + status: status, + primaryLabel: appText('连接失败', 'Connection Failed'), + detailLabel: detailLabel, + ready: false, + gatewayTokenMissing: false, + lastError: detailLabel, + ); + } + // BridgeDiscovering logic (Signed in, not blocked, but not ready yet) if (bridgeConfigured) { return AssistantThreadConnectionState( executionTarget: target, status: RuntimeConnectionStatus.offline, primaryLabel: appText('正在发现', 'Discovering'), - detailLabel: - appText('正在加载 Bridge 能力...', 'Loading Bridge capabilities...'), + detailLabel: appText( + '正在加载 Bridge 能力...', + 'Loading Bridge capabilities...', + ), ready: false, gatewayTokenMissing: false, lastError: null, @@ -133,7 +163,10 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ executionTarget: target, status: RuntimeConnectionStatus.offline, primaryLabel: RuntimeConnectionStatus.offline.label, - detailLabel: appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected'), + detailLabel: appText( + 'xworkmate-bridge 未连接', + 'xworkmate-bridge is not connected', + ), ready: false, gatewayTokenMissing: false, lastError: null, @@ -343,6 +376,9 @@ extension AppControllerDesktopThreadSessions on AppController { accountSyncState: settingsControllerInternal.accountSyncState, accountSignedIn: settingsControllerInternal.accountSignedIn, bridgeConfigured: bridgeConfigured, + bridgeDiscoveryAttempted: bridgeCapabilitiesRefreshAttemptedInternal, + bridgeDiscoveryError: bridgeCapabilitiesRefreshErrorInternal, + providerCatalogEmpty: providers.isEmpty, ); } diff --git a/lib/runtime/go_runtime_dispatch_desktop_client.dart b/lib/runtime/go_runtime_dispatch_desktop_client.dart index c0286d81..525c3fe6 100644 --- a/lib/runtime/go_runtime_dispatch_desktop_client.dart +++ b/lib/runtime/go_runtime_dispatch_desktop_client.dart @@ -24,7 +24,7 @@ class GoRuntimeDispatchDesktopClient implements RuntimeDispatchResolver { 'routing': { 'routingMode': 'auto', if (preferredProviderId.trim().isNotEmpty) - 'preferredGatewayTarget': preferredProviderId.trim(), + 'preferredGatewayProviderId': preferredProviderId.trim(), 'explicitExecutionTarget': '', 'explicitProviderId': preferredProviderId.trim(), 'explicitModel': '', @@ -61,7 +61,7 @@ class GoRuntimeDispatchDesktopClient implements RuntimeDispatchResolver { 'routing': { 'routingMode': 'auto', if (preferredProviderId.trim().isNotEmpty) - 'preferredGatewayTarget': preferredProviderId.trim(), + 'preferredGatewayProviderId': preferredProviderId.trim(), 'explicitExecutionTarget': '', 'explicitProviderId': preferredProviderId.trim(), 'explicitModel': '', diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index 672b1d02..ffd7c918 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -144,7 +144,7 @@ class ExternalCodeAgentAcpRoutingConfig { return { 'routingMode': mode.name, if (preferredGatewayTarget.trim().isNotEmpty) - 'preferredGatewayTarget': preferredGatewayTarget.trim(), + 'preferredGatewayProviderId': preferredGatewayTarget.trim(), if (explicitExecutionTarget.trim().isNotEmpty) 'explicitExecutionTarget': explicitExecutionTarget.trim(), if (explicitProviderId.trim().isNotEmpty) @@ -256,9 +256,7 @@ class GoTaskServiceRequest { Map toExternalAcpParams() { final resolvedRouting = effectiveRouting; final providerId = provider.isUnspecified ? '' : provider.providerId; - final gatewayProviderId = normalizedTarget.isGateway - ? (providerId.isEmpty ? kCanonicalGatewayProviderId : providerId) - : ''; + final agentProviderId = normalizedTarget.isGateway ? '' : providerId; final params = { 'sessionId': sessionId, 'threadId': threadId, @@ -293,11 +291,7 @@ class GoTaskServiceRequest { }, ) .toList(growable: false), - if (providerId.isNotEmpty) 'provider': providerId, - if (gatewayProviderId.isNotEmpty) ...{ - 'gatewayProvider': gatewayProviderId, - 'gatewayProviderId': gatewayProviderId, - }, + if (agentProviderId.isNotEmpty) 'provider': agentProviderId, if (remoteWorkingDirectoryHint.trim().isNotEmpty) 'remoteWorkingDirectoryHint': remoteWorkingDirectoryHint.trim(), if (model.trim().isNotEmpty) 'model': model.trim(), @@ -324,7 +318,7 @@ class GoTaskServiceRequest { AssistantExecutionTarget.agent => 'agent', AssistantExecutionTarget.gateway => 'gateway', }; - final explicitProviderId = provider.isUnspecified + final explicitProviderId = provider.isUnspecified || gatewayTarget.isGateway ? '' : provider.providerId; final explicitModelValue = model.trim(); diff --git a/test/features/assistant/assistant_connection_status_test.dart b/test/features/assistant/assistant_connection_status_test.dart index 26c1aa6d..4423c822 100644 --- a/test/features/assistant/assistant_connection_status_test.dart +++ b/test/features/assistant/assistant_connection_status_test.dart @@ -100,5 +100,25 @@ void main() { expect(state.detailLabel, '正在加载 Bridge 能力...'); expect(state.gatewayTokenMissing, isFalse); }); + + test('surfaces failed discovery after capability refresh is attempted', () { + final state = resolveGatewayThreadConnectionStateInternal( + target: AssistantExecutionTarget.gateway, + bridgeReady: false, + bridgeLabel: 'xworkmate-bridge.svc.plus', + accountSyncState: null, + accountSignedIn: true, + bridgeConfigured: true, + bridgeDiscoveryAttempted: true, + bridgeDiscoveryError: 'ACP_HTTP_502: upstream failed', + providerCatalogEmpty: true, + ); + + expect(state.connected, isFalse); + expect(state.status, RuntimeConnectionStatus.error); + expect(state.primaryLabel, '连接失败'); + expect(state.detailLabel, 'ACP_HTTP_502: upstream failed'); + expect(state.lastError, 'ACP_HTTP_502: upstream failed'); + }); }); } diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 94b9d89a..cab23c14 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -113,7 +113,9 @@ void main() { test( 'returns unspecified when a saved provider is no longer in the current catalog', () { - final controller = AppController(environmentOverride: const {}); + final controller = AppController( + environmentOverride: const {}, + ); addTearDown(controller.dispose); final unavailableProvider = controller @@ -206,7 +208,8 @@ void main() { expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit); expect(routing.explicitExecutionTarget, 'gateway'); - expect(routing.explicitProviderId, 'openclaw'); + expect(routing.preferredGatewayTarget, 'openclaw'); + expect(routing.explicitProviderId, ''); }, ); @@ -315,6 +318,8 @@ void main() { await controller.setAssistantExecutionTarget( AssistantExecutionTarget.agent, ); + controller.bridgeCapabilitiesRefreshAttemptedInternal = true; + controller.bridgeCapabilitiesRefreshErrorInternal = ''; await Future.delayed(const Duration(milliseconds: 200)); expect(controller.assistantProviderCatalog, isEmpty); @@ -484,6 +489,8 @@ void main() { AssistantExecutionTarget.agent, ); await Future.delayed(const Duration(milliseconds: 200)); + controller.bridgeCapabilitiesRefreshAttemptedInternal = true; + controller.bridgeCapabilitiesRefreshErrorInternal = ''; await expectLater( controller.sendChatMessage('hi'), @@ -491,14 +498,17 @@ void main() { isA().having( (error) => error.message, 'message', - contains('正在加载 Bridge 能力'), + contains('Gateway ACP 未报告可用的 provider'), ), ), ); expect(fakeGoTaskService.executeCount, 0); expect(capture.requestCount, lessThanOrEqualTo(2)); - expect(controller.chatMessages.last.text, contains('正在加载 Bridge 能力')); + expect( + controller.chatMessages.last.text, + contains('Gateway ACP 未报告可用的 provider'), + ); }, ); }); diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index 66528a45..f23d49f0 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -569,14 +569,13 @@ void main() { expect(capture.requestPath, isNot(contains('/gateway/openclaw'))); final params = _lastRequestParams(capture); final routing = params['routing'] as Map; - expect(params['provider'], 'openclaw'); - expect(params['gatewayProvider'], 'openclaw'); - expect(params['gatewayProviderId'], 'openclaw'); + expect(params.containsKey('gatewayProvider'), isFalse); + expect(params.containsKey('gatewayProviderId'), isFalse); expect(params['executionTarget'], 'gateway'); expect(params['requestedExecutionTarget'], 'gateway'); - expect(routing['preferredGatewayTarget'], 'openclaw'); + expect(routing['preferredGatewayProviderId'], 'openclaw'); expect(routing['explicitExecutionTarget'], 'gateway'); - expect(routing['explicitProviderId'], 'openclaw'); + expect(routing.containsKey('explicitProviderId'), isFalse); expect(capture.requestBody, contains('"method":"session.start"')); expect(capture.requestBody, isNot(contains('"method":"thread/start"'))); }, diff --git a/test/runtime/go_runtime_dispatch_desktop_client_test.dart b/test/runtime/go_runtime_dispatch_desktop_client_test.dart index c7dbb552..e072a6b3 100644 --- a/test/runtime/go_runtime_dispatch_desktop_client_test.dart +++ b/test/runtime/go_runtime_dispatch_desktop_client_test.dart @@ -30,7 +30,7 @@ void main() { expect(capture.method, 'xworkmate.routing.resolve'); expect(capture.body, contains('"routingMode":"auto"')); - expect(capture.body, contains('"preferredGatewayTarget":"codex"')); + expect(capture.body, contains('"preferredGatewayProviderId":"codex"')); }); }