diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 1122a8fe..42519d54 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -236,7 +236,9 @@ extension AppControllerDesktopThreadActions on AppController { var connectionState = currentAssistantConnectionState; if (!connectionState.connected && isBridgeAcpRuntimeConfiguredInternal() && - !bridgeCapabilitiesRefreshAttemptedInternal) { + bridgeCapabilityRefreshNeededForAssistantTargetInternal( + currentTarget, + )) { 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 cb12b908..bea88ab0 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -173,6 +173,18 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ ); } +bool bridgeCapabilityReadyForExecutionTargetInternal({ + required AssistantExecutionTarget target, + required bool bridgeConfigured, + required List providers, + required List availableTargets, +}) { + if (!bridgeConfigured || providers.isEmpty) { + return false; + } + return availableTargets.isEmpty || availableTargets.contains(target); +} + extension AppControllerDesktopThreadSessions on AppController { AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordsInternal( TaskThread? primaryRecord, { @@ -361,10 +373,12 @@ extension AppControllerDesktopThreadSessions on AppController { final providers = providerCatalogForExecutionTarget(target); final availableTargets = bridgeAvailableExecutionTargets; final bridgeConfigured = isBridgeAcpRuntimeConfiguredInternal(); - final bridgeReady = - bridgeConfigured && - providers.isNotEmpty && - (availableTargets.isEmpty || availableTargets.contains(target)); + final bridgeReady = bridgeCapabilityReadyForExecutionTargetInternal( + target: target, + bridgeConfigured: bridgeConfigured, + providers: providers, + availableTargets: availableTargets, + ); final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); final bridgeLabel = bridgeEndpoint?.host.trim().isNotEmpty == true ? bridgeEndpoint!.host.trim() @@ -382,6 +396,26 @@ extension AppControllerDesktopThreadSessions on AppController { ); } + bool bridgeCapabilityReadyForAssistantTargetInternal( + AssistantExecutionTarget target, + ) { + return bridgeCapabilityReadyForExecutionTargetInternal( + target: target, + bridgeConfigured: isBridgeAcpRuntimeConfiguredInternal(), + providers: providerCatalogForExecutionTarget(target), + availableTargets: bridgeAvailableExecutionTargets, + ); + } + + bool bridgeCapabilityRefreshNeededForAssistantTargetInternal( + AssistantExecutionTarget target, + ) { + if (!isBridgeAcpRuntimeConfiguredInternal()) { + return false; + } + return !bridgeCapabilityReadyForAssistantTargetInternal(target); + } + String get assistantConnectionStatusLabel => currentAssistantConnectionState.primaryLabel; String get assistantConnectionTargetLabel => diff --git a/test/features/assistant/assistant_connection_status_test.dart b/test/features/assistant/assistant_connection_status_test.dart index 4423c822..80261dc5 100644 --- a/test/features/assistant/assistant_connection_status_test.dart +++ b/test/features/assistant/assistant_connection_status_test.dart @@ -28,6 +28,33 @@ void main() { }, ); + test('requires the target capability contract before reporting ready', () { + expect( + bridgeCapabilityReadyForExecutionTargetInternal( + target: AssistantExecutionTarget.gateway, + bridgeConfigured: true, + providers: const [SingleAgentProvider.openclaw], + availableTargets: const [ + AssistantExecutionTarget.agent, + ], + ), + isFalse, + ); + + expect( + bridgeCapabilityReadyForExecutionTargetInternal( + target: AssistantExecutionTarget.gateway, + bridgeConfigured: true, + providers: const [SingleAgentProvider.openclaw], + availableTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ), + isTrue, + ); + }); + test('maps blocked bridge authorization into the token-missing state', () { final state = resolveGatewayThreadConnectionStateInternal( target: AssistantExecutionTarget.gateway, diff --git a/test/runtime/assistant_connection_state_test.dart b/test/runtime/assistant_connection_state_test.dart index 84dcba16..5f852ceb 100644 --- a/test/runtime/assistant_connection_state_test.dart +++ b/test/runtime/assistant_connection_state_test.dart @@ -54,6 +54,74 @@ void main() { }, ); + test( + 'uses the same gateway capability readiness for status and send guard', + () async { + final controller = await _isolatedController( + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + ], + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + environmentOverride: const { + 'BRIDGE_AUTH_TOKEN': 'bridge-token', + }, + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + final state = controller.currentAssistantConnectionState; + expect(state.connected, isTrue); + expect( + controller.bridgeCapabilityRefreshNeededForAssistantTargetInternal( + AssistantExecutionTarget.gateway, + ), + isFalse, + ); + }, + ); + + test( + 'refreshes gateway capabilities when previous discovery missed openclaw', + () async { + final controller = await _isolatedController( + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + environmentOverride: const { + 'BRIDGE_AUTH_TOKEN': 'bridge-token', + }, + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + expect(controller.currentAssistantConnectionState.connected, isFalse); + expect( + controller.bridgeCapabilityRefreshNeededForAssistantTargetInternal( + AssistantExecutionTarget.gateway, + ), + isTrue, + ); + }, + ); + test('keeps signed-out generic runtime failures disconnected', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); @@ -221,6 +289,7 @@ Future _isolatedController({ List? initialBridgeProviderCatalog, List? initialGatewayProviderCatalog, List? initialAvailableExecutionTargets, + Map environmentOverride = const {}, }) async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-assistant-connection-state-', @@ -242,7 +311,7 @@ Future _isolatedController({ ); await store.initialize(); return AppController( - environmentOverride: const {}, + environmentOverride: environmentOverride, store: store, initialBridgeProviderCatalog: initialBridgeProviderCatalog, initialGatewayProviderCatalog: initialGatewayProviderCatalog,