fix: route gateway execution through managed bridge
This commit is contained in:
parent
179294d059
commit
f73a7b55bd
@ -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 =
|
||||
|
||||
@ -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
|
||||
: '';
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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': '',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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"')));
|
||||
},
|
||||
|
||||
@ -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"'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user