From 47e2909cd76b7c9835e67e89d728f4ff3b7ab2fa Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 22:46:06 +0800 Subject: [PATCH] Unify bridge sync gating for ACP sessions --- ...pp_controller_desktop_runtime_helpers.dart | 56 +++------ ...ler_desktop_single_agent_go_task_flow.dart | 111 ++++++------------ ...ime_controllers_settings_account_impl.dart | 56 +++++++-- ...ontroller_desktop_thread_binding_test.dart | 51 ++++++++ ...sktop_working_directory_dispatch_test.dart | 78 +++++++++++- ...ime_controllers_settings_account_test.dart | 2 +- .../settings_account_auth_flow_test.dart | 102 ++++++++++++++++ 7 files changed, 336 insertions(+), 120 deletions(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 14002e7b..0ac73a61 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -226,6 +226,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController { }) { final raw = error.toString().trim(); final lowered = raw.toLowerCase(); + if ((lowered.contains('acp_endpoint_missing') || + lowered.contains('missing acp endpoint')) && + target == AssistantExecutionTarget.singleAgent) { + return appText( + '当前线程还没有同步到 Bridge Server。请先登录账号并在设置里完成同步后再重试。', + 'This thread does not have a synced bridge server yet. Sign in and complete Settings sync before trying again.', + ); + } if (lowered.contains('gateway not connected') || lowered.contains('code: offline') || lowered.contains('offlin') && lowered.contains('gateway')) { @@ -724,10 +732,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Uri? resolveGatewayAcpEndpointInternal() { - return resolveBridgeAcpEndpointInternal() ?? - _nonLoopbackGatewayProfileBaseUriInternal( - settings.primaryGatewayProfile, - ); + return resolveBridgeAcpEndpointInternal(); } Uri? resolveBridgeAcpEndpointInternal() { @@ -748,19 +753,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return uri.replace(query: null, fragment: null); } - Uri? resolveExternalAcpEndpointForTargetInternal( - AssistantExecutionTarget target, - ) { - final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); - if (bridgeEndpoint != null) { - return bridgeEndpoint; - } - if (target == AssistantExecutionTarget.gateway) { - return _nonLoopbackGatewayProfileBaseUriInternal( - settings.primaryGatewayProfile, - ); - } - return null; + Uri? resolveExternalAcpEndpointForTargetInternal(AssistantExecutionTarget _) { + return resolveBridgeAcpEndpointInternal(); } Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) { @@ -775,30 +769,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ); } - Uri? _nonLoopbackGatewayProfileBaseUriInternal( - GatewayConnectionProfile profile, - ) { - if (isLoopbackHostInternal(profile.host)) { - return null; - } - return gatewayProfileBaseUriInternal(profile); - } - Future resolveGatewayAcpAuthorizationHeaderInternal( Uri endpoint, ) async { final normalizedHost = endpoint.host.trim().toLowerCase(); - final bridgeHost = - Uri.tryParse( - settings - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .endpoint - .trim(), - )?.host.trim().toLowerCase() ?? - ''; - if (bridgeHost.isNotEmpty && normalizedHost == bridgeHost) { + final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); + final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; + final bridgePort = bridgeEndpoint?.port ?? 0; + final matchesBridgeEndpoint = + bridgeHost.isNotEmpty && + normalizedHost == bridgeHost && + (bridgePort <= 0 || endpoint.port == bridgePort); + if (matchesBridgeEndpoint) { final bridgeToken = (await storeInternal.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, 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 0470dd23..cca798b5 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 @@ -57,6 +57,9 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( sessionKey, ); final selection = controller.singleAgentProviderForSession(sessionKey); + final effectiveProvider = + controller.resolvedSingleAgentProviderInternal(selection) ?? + selection; final preflightWorkingDirectory = controller .resolveSingleAgentWorkingDirectoryForSessionInternal(sessionKey); if (preflightWorkingDirectory == null || @@ -76,37 +79,32 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( ); 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 effectiveProvider = - routingResolution.resolvedProviderId.trim().isEmpty - ? null - : SingleAgentProviderCopy.fromJsonValue( - routingResolution.resolvedProviderId, - ); final unavailableReason = - routingResolution.unavailable || - (routingResolution.resolvedExecutionTarget == 'single-agent' && - effectiveProvider == null) - ? (routingResolution.unavailableMessage.isNotEmpty - ? routingResolution.unavailableMessage - : selection.isUnspecified - ? appText( - '当前没有可用的 GoTaskService Provider。', - 'No GoTaskService provider is currently available.', - ) - : appText( - '当前 GoTaskService 不支持 ${selection.label}。', - 'GoTaskService does not currently support ${selection.label}.', - )) + controller.singleAgentShouldSuggestAcpSwitchForSession(sessionKey) + ? singleAgentUnavailableLabelDesktopInternal( + controller, + sessionKey, + null, + ) + : controller.singleAgentNeedsAiGatewayConfigurationForSession( + sessionKey, + ) + ? singleAgentUnavailableLabelDesktopInternal( + controller, + sessionKey, + appText( + 'Bridge 当前没有同步到可用 Provider。', + 'The bridge does not currently have any synced providers.', + ), + ) + : controller.resolveExternalAcpEndpointForTargetInternal( + AssistantExecutionTarget.singleAgent, + ) == + null + ? appText( + '当前线程还没有同步到 Bridge Server。请先登录账号并在设置里完成同步后再重试。', + 'This thread does not have a synced bridge server yet. Sign in and complete Settings sync before trying again.', + ) : null; if (unavailableReason != null) { controller.upsertTaskThreadInternal( @@ -120,17 +118,14 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( sessionKey, assistantErrorMessageSingleAgentDesktopInternal( controller, - singleAgentUnavailableLabelDesktopInternal( - controller, - sessionKey, - unavailableReason, - ), + unavailableReason, ), ); return; } - if (effectiveProvider != null) { + final aiGatewayApiKey = await controller.loadAiGatewayApiKey(); + if (!effectiveProvider.isUnspecified) { appendSingleAgentRuntimeStatusDesktopInternal( controller, sessionKey, @@ -140,7 +135,9 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( final workingDirectory = controller .resolveSingleAgentWorkingDirectoryForSessionInternal( sessionKey, - provider: effectiveProvider, + provider: effectiveProvider.isUnspecified + ? null + : effectiveProvider, ); final resolvedWorkingDirectory = workingDirectory == null || workingDirectory.trim().isEmpty @@ -159,25 +156,18 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( target: AssistantExecutionTarget.singleAgent, prompt: message, workingDirectory: resolvedWorkingDirectory, - model: routingResolution.resolvedModel.trim().isNotEmpty - ? routingResolution.resolvedModel - : controller.assistantModelForSession(sessionKey), + model: controller.assistantModelForSession(sessionKey), thinking: thinking, - selectedSkills: routingResolution.resolvedSkills.isNotEmpty - ? routingResolution.resolvedSkills - : selectedSkills, + selectedSkills: selectedSkills, inlineAttachments: attachments, localAttachments: localAttachments, aiGatewayBaseUrl: controller.aiGatewayUrl, aiGatewayApiKey: aiGatewayApiKey, agentId: '', metadata: const {}, - routing: _resolvedRoutingConfigDesktopInternal( - routing, - routingResolution, - ), + routing: routing, routingHint: 'single-agent', - provider: effectiveProvider ?? SingleAgentProvider.unspecified, + provider: effectiveProvider, remoteWorkingDirectoryHint: controller .requireTaskThreadForSessionInternal(sessionKey) @@ -231,31 +221,6 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( }); } -ExternalCodeAgentAcpRoutingConfig _resolvedRoutingConfigDesktopInternal( - ExternalCodeAgentAcpRoutingConfig original, - ExternalCodeAgentAcpRoutingResolution resolution, -) { - final explicitExecutionTarget = switch (resolution.resolvedExecutionTarget - .trim() - .toLowerCase()) { - 'single-agent' => 'single-agent', - 'multi-agent' => 'multi-agent', - 'gateway' => 'gateway', - _ => 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, diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 1e9329b0..3336452d 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -2,8 +2,6 @@ import 'account_runtime_client.dart'; import 'runtime_controllers_settings.dart'; import 'runtime_models.dart'; -const _kProductionBridgeEndpoint = 'https://xworkmate-bridge.svc.plus'; - Future loginAccountSettingsInternal( SettingsController controller, { required String baseUrl, @@ -260,6 +258,15 @@ Future syncAccountSettingsInternal( state: 'blocked', message: 'Bridge authorization is unavailable', ); + await controller.storeInternal.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncState: result.state, + syncMessage: result.message, + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncError: result.message, + profileScope: 'bridge', + ), + ); controller.accountStatusInternal = result.message; if (!quiet) { controller.accountBusyInternal = false; @@ -268,6 +275,11 @@ Future syncAccountSettingsInternal( return result; } + await controller.storeInternal.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: bridgeToken, + ); + final bridgeServerUrl = bridgeServerUrlOverride.trim().isNotEmpty ? bridgeServerUrlOverride.trim() : controller.accountSyncStateInternal?.syncedDefaults.bridgeServerUrl @@ -291,12 +303,36 @@ Future syncAccountSettingsInternal( .remoteServerSummary .endpoint .trim() - : _kProductionBridgeEndpoint; - - await controller.storeInternal.saveAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - value: bridgeToken, - ); + : ''; + if (bridgeServerUrl.isEmpty || + !isSupportedExternalAcpEndpoint(bridgeServerUrl)) { + const result = AccountSyncResult( + state: 'blocked', + message: 'Bridge server is unavailable', + ); + await controller.storeInternal.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncedDefaults: AccountRemoteProfile.defaults(), + syncState: result.state, + syncMessage: result.message, + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncError: result.message, + profileScope: 'bridge', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), + ), + ); + await controller.reloadDerivedStateInternal(); + controller.accountStatusInternal = result.message; + if (!quiet) { + controller.accountBusyInternal = false; + controller.notifyListeners(); + } + return result; + } await controller.storeInternal.clearAccountManagedSecret( target: kAccountManagedSecretTargetAIGatewayAccessToken, ); @@ -475,6 +511,10 @@ String _resolveBridgeAuthorizationToken(Map payload) { if (explicit.isNotEmpty) { return explicit; } + final internalServiceToken = _stringValue(payload['internalServiceToken']); + if (internalServiceToken.isNotEmpty) { + return internalServiceToken; + } return ''; } diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart index 82f26162..1165ad49 100644 --- a/test/app_controller_desktop_thread_binding_test.dart +++ b/test/app_controller_desktop_thread_binding_test.dart @@ -333,6 +333,56 @@ void main() { }); group('resolveGatewayAcpAuthorizationHeaderInternal', () { + test('requires synced bridge endpoint before ACP endpoint can resolve', () { + final controller = AppController(); + addTearDown(controller.dispose); + + expect(controller.resolveBridgeAcpEndpointInternal(), isNull); + expect( + controller.resolveExternalAcpEndpointForTargetInternal( + AssistantExecutionTarget.singleAgent, + ), + isNull, + ); + + controller.settingsController.snapshotInternal = controller.settings + .copyWith( + acpBridgeServerModeConfig: controller + .settings + .acpBridgeServerModeConfig + .copyWith( + cloudSynced: controller + .settings + .acpBridgeServerModeConfig + .cloudSynced + .copyWith( + remoteServerSummary: + const AcpBridgeServerRemoteServerSummary( + endpoint: 'https://bridge.customer.example/acp', + hasAdvancedOverrides: false, + ), + ), + ), + ); + + expect( + controller.resolveBridgeAcpEndpointInternal(), + Uri.parse('https://bridge.customer.example/acp'), + ); + expect( + controller.resolveExternalAcpEndpointForTargetInternal( + AssistantExecutionTarget.singleAgent, + ), + Uri.parse('https://bridge.customer.example/acp'), + ); + expect( + controller.resolveExternalAcpEndpointForTargetInternal( + AssistantExecutionTarget.gateway, + ), + Uri.parse('https://bridge.customer.example/acp'), + ); + }); + test( 'prefers the synced bridge bearer token over the account session token', () async { @@ -480,6 +530,7 @@ class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient { return { 'token': 'session-token', 'internalServiceToken': 'bridge-token', + 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge.svc.plus', 'expiresAt': '2026-04-12T00:00:00Z', 'user': { 'id': 'u-1', diff --git a/test/app_controller_desktop_working_directory_dispatch_test.dart b/test/app_controller_desktop_working_directory_dispatch_test.dart index d9ba521c..b648eb29 100644 --- a/test/app_controller_desktop_working_directory_dispatch_test.dart +++ b/test/app_controller_desktop_working_directory_dispatch_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller_desktop_core.dart'; import 'package:xworkmate/app/app_controller_desktop_runtime_helpers.dart'; @@ -6,6 +8,7 @@ import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart'; import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -14,14 +17,49 @@ void main() { test( 'single-agent requests reuse the unique thread workspace workingDirectory', () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-thread-working-directory-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: SettingsSnapshot.defaults() + .acpBridgeServerModeConfig + .copyWith( + cloudSynced: SettingsSnapshot.defaults() + .acpBridgeServerModeConfig + .cloudSynced + .copyWith( + remoteServerSummary: + const AcpBridgeServerRemoteServerSummary( + endpoint: 'https://bridge.customer.example', + hasAdvancedOverrides: false, + ), + ), + ), + ), + ); final client = _CapturingGoTaskServiceClient(); final controller = AppController( + store: store, goTaskServiceClient: client, availableSingleAgentProvidersOverride: const [ SingleAgentProvider.codex, ], ); - addTearDown(controller.dispose); + addTearDown(() async { + controller.dispose(); + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); const sessionKey = 'draft:single-agent-working-directory'; controller.initializeAssistantThreadContext( @@ -53,6 +91,42 @@ void main() { expectedThreadWorkingDirectory, ], ); + expect( + client.resolveExternalAcpRoutingCallCount, + 0, + reason: + 'single-agent turns should go straight to session.start/session.message without app-side routing preflight', + ); + }, + ); + + test( + 'single-agent turns stay blocked until bridge server has been synced', + () async { + final client = _CapturingGoTaskServiceClient(); + final controller = AppController( + goTaskServiceClient: client, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + ); + addTearDown(controller.dispose); + + const sessionKey = 'draft:single-agent-missing-bridge-server'; + controller.initializeAssistantThreadContext( + sessionKey, + executionTarget: AssistantExecutionTarget.singleAgent, + ); + await controller.switchSession(sessionKey); + + await controller.sendChatMessage('first turn'); + + expect(client.requests, isEmpty); + final messages = controller + .requireTaskThreadForSessionInternal(sessionKey) + .messages; + expect(messages, isNotEmpty); + expect(messages.last.text, contains('Bridge Server')); }, ); @@ -98,6 +172,7 @@ void main() { class _CapturingGoTaskServiceClient implements GoTaskServiceClient { final List requests = []; + int resolveExternalAcpRoutingCallCount = 0; @override Future cancelTask({ @@ -168,6 +243,7 @@ class _CapturingGoTaskServiceClient implements GoTaskServiceClient { String aiGatewayBaseUrl = '', String aiGatewayApiKey = '', }) async { + resolveExternalAcpRoutingCallCount += 1; return const ExternalCodeAgentAcpRoutingResolution( raw: { 'resolvedExecutionTarget': 'single-agent', diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 140954e4..df09f6d4 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -60,7 +60,7 @@ void main() { lastSyncSource: 'https://xworkmate-bridge.svc.plus', profileScope: 'bridge', tokenConfigured: const AccountTokenConfigured( - openclaw: true, + bridge: true, vault: false, apisix: false, ), diff --git a/test/runtime/settings_account_auth_flow_test.dart b/test/runtime/settings_account_auth_flow_test.dart index 87dbbe2b..d4d91f56 100644 --- a/test/runtime/settings_account_auth_flow_test.dart +++ b/test/runtime/settings_account_auth_flow_test.dart @@ -133,6 +133,74 @@ void main() { expect(controller.accountSession?.email, 'review@svc.plus'); expect(controller.accountSyncState?.syncState, 'ready'); }); + + test( + 'login stays blocked when bridge server is not included in sync data', + () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-account-auth-missing-bridge-server-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => root.path, + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => + _MissingBridgeServerAccountRuntimeClient(), + ); + addTearDown(() async { + controller.dispose(); + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + await controller.initialize(); + await controller.saveSnapshot( + controller.snapshot.copyWith( + accountBaseUrl: 'https://accounts.customer.example', + accountUsername: 'review@customer.example', + ), + ); + + await controller.loginAccount( + baseUrl: 'https://accounts.customer.example', + identifier: 'review@customer.example', + password: '***REMOVED-CREDENTIAL***', + ); + + expect(controller.accountSignedIn, isTrue); + expect( + controller.accountStatus, + 'Signed in as review@customer.example', + ); + expect(controller.accountSyncState?.syncState, 'blocked'); + expect( + controller.accountSyncState?.syncMessage, + 'Bridge server is unavailable', + ); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary + .endpoint, + isEmpty, + ); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + 'bridge-token', + ); + }, + ); }); } @@ -230,3 +298,37 @@ class _MfaAccountRuntimeClient extends AccountRuntimeClient { ); } } + +class _MissingBridgeServerAccountRuntimeClient extends AccountRuntimeClient { + _MissingBridgeServerAccountRuntimeClient() + : super(baseUrl: 'https://accounts.customer.example'); + + @override + Future> login({ + required String identifier, + required String password, + }) async { + return { + 'token': 'session-token', + 'BRIDGE_AUTH_TOKEN': 'bridge-token', + 'expiresAt': '2026-04-12T00:00:00Z', + 'user': { + 'id': 'u-2', + 'email': 'review@customer.example', + 'name': 'Customer Review', + 'role': 'readonly', + }, + }; + } + + @override + Future loadSession({required String token}) async { + return const AccountSessionSummary( + userId: 'u-2', + email: 'review@customer.example', + name: 'Customer Review', + role: 'readonly', + mfaEnabled: false, + ); + } +}