fix: route gateway execution through managed bridge

This commit is contained in:
Haitao Pan 2026-05-02 12:10:08 +08:00
parent 179294d059
commit f73a7b55bd
11 changed files with 108 additions and 29 deletions

View File

@ -240,6 +240,9 @@ class AppController extends ChangeNotifier {
bridgeAvailableExecutionTargetsInternal = compactAssistantExecutionTargets(
initialAvailableExecutionTargets ?? const <AssistantExecutionTarget>[],
);
bridgeCapabilitiesRefreshAttemptedInternal =
bridgeAgentProviderCatalogInternal.isNotEmpty ||
bridgeGatewayProviderCatalogInternal.isNotEmpty;
attachChildListenersInternal();
unawaited(initializeInternal());
@ -308,6 +311,8 @@ class AppController extends ChangeNotifier {
const <SingleAgentProvider>[];
List<AssistantExecutionTarget> bridgeAvailableExecutionTargetsInternal =
const <AssistantExecutionTarget>[];
bool bridgeCapabilitiesRefreshAttemptedInternal = false;
String bridgeCapabilitiesRefreshErrorInternal = '';
final Map<String, List<GatewayChatMessage>> assistantThreadMessagesInternal =
<String, List<GatewayChatMessage>>{};
late final DesktopTaskThreadRepository taskThreadRepositoryInternal =

View File

@ -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
: '';

View File

@ -51,14 +51,18 @@ Future<void> 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<void> 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<void> refreshSingleAgentCapabilitiesRuntimeInternal(
compactAssistantExecutionTargets(
capabilities.availableExecutionTargets,
);
} catch (_) {
controller.bridgeCapabilitiesRefreshAttemptedInternal = true;
controller.bridgeCapabilitiesRefreshErrorInternal = '';
} catch (error) {
controller.bridgeCapabilitiesRefreshAttemptedInternal = true;
controller.bridgeCapabilitiesRefreshErrorInternal = error.toString().trim();
controller.bridgeAgentProviderCatalogInternal =
const <SingleAgentProvider>[];
controller.bridgeGatewayProviderCatalogInternal =

View File

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

View File

@ -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,
);
}

View File

@ -24,7 +24,7 @@ class GoRuntimeDispatchDesktopClient implements RuntimeDispatchResolver {
'routing': <String, dynamic>{
'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': <String, dynamic>{
'routingMode': 'auto',
if (preferredProviderId.trim().isNotEmpty)
'preferredGatewayTarget': preferredProviderId.trim(),
'preferredGatewayProviderId': preferredProviderId.trim(),
'explicitExecutionTarget': '',
'explicitProviderId': preferredProviderId.trim(),
'explicitModel': '',

View File

@ -144,7 +144,7 @@ class ExternalCodeAgentAcpRoutingConfig {
return <String, dynamic>{
'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<String, dynamic> 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 = <String, dynamic>{
'sessionId': sessionId,
'threadId': threadId,
@ -293,11 +291,7 @@ class GoTaskServiceRequest {
},
)
.toList(growable: false),
if (providerId.isNotEmpty) 'provider': providerId,
if (gatewayProviderId.isNotEmpty) ...<String, dynamic>{
'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();

View File

@ -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');
});
});
}

View File

@ -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 <String, String>{});
final controller = AppController(
environmentOverride: const <String, String>{},
);
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<void>.delayed(const Duration(milliseconds: 200));
expect(controller.assistantProviderCatalog, isEmpty);
@ -484,6 +489,8 @@ void main() {
AssistantExecutionTarget.agent,
);
await Future<void>.delayed(const Duration(milliseconds: 200));
controller.bridgeCapabilitiesRefreshAttemptedInternal = true;
controller.bridgeCapabilitiesRefreshErrorInternal = '';
await expectLater(
controller.sendChatMessage('hi'),
@ -491,14 +498,17 @@ void main() {
isA<StateError>().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'),
);
},
);
});

View File

@ -569,14 +569,13 @@ void main() {
expect(capture.requestPath, isNot(contains('/gateway/openclaw')));
final params = _lastRequestParams(capture);
final routing = params['routing'] as Map<String, dynamic>;
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"')));
},

View File

@ -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"'));
});
}