import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/runtime/gateway_acp_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { group('Assistant connection state', () { test('does not report bridge runtime configured by default', () async { final controller = await _isolatedController( initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, ], initialGatewayProviderCatalog: const [ SingleAgentProvider.openclaw, ], initialAvailableExecutionTargets: const [ AssistantExecutionTarget.agent, AssistantExecutionTarget.gateway, ], ); addTearDown(controller.dispose); expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse); }); test( 'keeps signed-out sessions disconnected even when provider catalogs exist', () async { final controller = await _isolatedController( initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, ], initialGatewayProviderCatalog: const [ SingleAgentProvider.openclaw, ], initialAvailableExecutionTargets: const [ AssistantExecutionTarget.agent, AssistantExecutionTarget.gateway, ], ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( 'unit-fixture-task-a', ); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); final state = controller.currentAssistantConnectionState; expect(state.connected, isFalse); expect(state.status, RuntimeConnectionStatus.offline); expect(state.detailLabel, '请先登录 svc.plus'); }, ); 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 { 'AI_WORKSPACE_AUTH_TOKEN': 'bridge-token', }, ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( 'unit-fixture-task-a', ); 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( 'unit-fixture-task-a', ); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); expect(controller.currentAssistantConnectionState.connected, isFalse); expect( controller.bridgeCapabilityRefreshNeededForAssistantTargetInternal( AssistantExecutionTarget.gateway, ), isTrue, ); }, ); test( 'labels gateway offline errors as OpenClaw runtime failures', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); final label = controller.gatewayExecutionErrorLabelInternal( 'gateway not connected', target: AssistantExecutionTarget.gateway, ); expect(label, contains('OpenClaw Gateway 当前未连接')); expect(label, isNot(contains('xworkmate-bridge 未连接'))); }, ); test( 'labels OpenClaw socket close without exposing raw JSON-RPC error', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); final label = controller.gatewayExecutionErrorLabelInternal( const GatewayAcpException( 'OPENCLAW_GATEWAY_SOCKET_CLOSED: OpenClaw gateway connection closed during task execution', code: '-32002', detailCode: 'OPENCLAW_GATEWAY_SOCKET_CLOSED', ), target: AssistantExecutionTarget.gateway, ); expect(label, contains('OpenClaw Gateway 连接在任务执行中断开')); expect(label, isNot(contains('-32002'))); expect(label, isNot(contains('socket closed'))); }, ); test('labels interrupted ACP HTTP reads as incomplete results', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); final label = controller.gatewayExecutionErrorLabelInternal( const GatewayAcpException( 'ACP HTTP connection closed before the response finished arriving', code: 'ACP_HTTP_CONNECTION_CLOSED', ), target: AssistantExecutionTarget.gateway, ); expect( label, 'Bridge 响应读取中断,本轮结果未完成。请重新发送请求。错误码:ACP_HTTP_CONNECTION_CLOSED', ); expect(label, isNot(contains('下一次发送会继续同一会话'))); expect(label, isNot(contains('closed before the response'))); }); test( 'labels interrupted ACP HTTP handshakes as incomplete requests', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); final label = controller.gatewayExecutionErrorLabelInternal( const GatewayAcpException( 'ACP HTTP handshake was interrupted before the response started', code: gatewayAcpHttpHandshakeInterruptedCode, ), target: AssistantExecutionTarget.gateway, ); expect( label, 'Bridge 握手中断,本轮请求未完成。请重新发送请求。错误码:ACP_HTTP_HANDSHAKE_INTERRUPTED', ); expect(label, isNot(contains('下一次发送会继续同一会话'))); expect( label, isNot(contains('Connection terminated during handshake')), ); expect(label, isNot(contains('handshake was interrupted'))); }, ); test( 'labels ACP HTTP connect timeouts as unconfirmed retryable requests', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); final label = controller.gatewayExecutionErrorLabelInternal( const GatewayAcpException( 'SocketException: HTTP connection timed out after 0:00:08.000000, host: xworkmate-bridge.svc.plus, port: 443', code: gatewayAcpHttpConnectTimeoutCode, ), target: AssistantExecutionTarget.gateway, ); expect(label, 'Bridge 连接超时,本轮请求未确认,可重试。错误码:ACP_HTTP_CONNECT_TIMEOUT'); expect(label, isNot(contains('SocketException'))); expect(label, isNot(contains('0:00:08'))); }, ); test( 'labels ACP HTTP connect failures as unconfirmed retryable requests', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); final label = controller.gatewayExecutionErrorLabelInternal( const GatewayAcpException( 'Connection refused', code: gatewayAcpHttpConnectFailedCode, ), target: AssistantExecutionTarget.gateway, ); expect(label, 'Bridge 连接失败,本轮请求未确认,可重试。错误码:ACP_HTTP_CONNECT_FAILED'); expect(label, isNot(contains('Connection refused'))); }, ); test( 'labels unavailable session continuation without starting a new flow', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); final label = controller.gatewayExecutionErrorLabelInternal( const GatewayAcpException( 'SESSION_CONTINUATION_UNAVAILABLE: provider session state is unavailable', code: '-32002', detailCode: 'SESSION_CONTINUATION_UNAVAILABLE', ), target: AssistantExecutionTarget.agent, ); expect(label, contains('会话状态不可续写')); expect(label, contains('SESSION_CONTINUATION_UNAVAILABLE')); expect(label, isNot(contains('-32002'))); }, ); test('keeps signed-out generic runtime failures disconnected', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); await controller.sessionsController.switchSession('unit-fixture-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); controller.runtimeInternal.snapshotInternal = controller .runtimeInternal .snapshot .copyWith( status: RuntimeConnectionStatus.error, statusText: 'Connection failed', remoteAddress: 'openclaw.svc.plus:443', lastError: 'unsupported Ed25519 private key length: 0', lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED', lastErrorDetailCode: null, ); final state = controller.currentAssistantConnectionState; expect(state.status, RuntimeConnectionStatus.offline); expect(state.primaryLabel, '已退出登录'); expect(state.detailLabel, '请先登录 svc.plus'); }); test('keeps true offline state as bridge not connected', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); await controller.sessionsController.switchSession('unit-fixture-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); controller.runtimeInternal.snapshotInternal = GatewayConnectionSnapshot.initial( mode: controller.runtimeInternal.snapshot.mode, ); final state = controller.currentAssistantConnectionState; expect(state.status, RuntimeConnectionStatus.offline); expect(state.primaryLabel, '已退出登录'); expect(state.detailLabel, '请先登录 svc.plus'); }); test( 'keeps signed-out generic failures without address disconnected', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); await controller.sessionsController.switchSession( 'unit-fixture-task-a', ); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); controller.runtimeInternal.snapshotInternal = controller .runtimeInternal .snapshot .copyWith( status: RuntimeConnectionStatus.error, statusText: 'Connection failed', lastError: 'socket closed', lastErrorCode: 'SOCKET_CLOSED', lastErrorDetailCode: null, clearRemoteAddress: true, ); final state = controller.currentAssistantConnectionState; expect(state.status, RuntimeConnectionStatus.offline); expect(state.primaryLabel, '已退出登录'); expect(state.detailLabel, '请先登录 svc.plus'); }, ); test( 'keeps gateway token missing as dedicated app-visible state', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); await controller.sessionsController.switchSession( 'unit-fixture-task-a', ); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); controller.runtimeInternal.snapshotInternal = controller .runtimeInternal .snapshot .copyWith( status: RuntimeConnectionStatus.error, statusText: 'Connection failed', lastError: 'gateway token missing', lastErrorCode: 'AUTH_FAILED', lastErrorDetailCode: 'AUTH_TOKEN_MISSING', clearRemoteAddress: true, ); final state = controller.currentAssistantConnectionState; expect(state.status, RuntimeConnectionStatus.offline); expect(state.primaryLabel, '已退出登录'); expect(state.detailLabel, '请先登录 svc.plus'); }, ); test( 'treats missing endpoint as true offline instead of bridge failure', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); await controller.sessionsController.switchSession( 'unit-fixture-task-a', ); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); controller.runtimeInternal.snapshotInternal = controller .runtimeInternal .snapshot .copyWith( status: RuntimeConnectionStatus.error, statusText: 'Missing gateway endpoint', lastError: 'Configure setup code or manual host / port first.', lastErrorCode: 'MISSING_ENDPOINT', clearRemoteAddress: true, ); final state = controller.currentAssistantConnectionState; expect(state.status, RuntimeConnectionStatus.offline); expect(state.primaryLabel, '已退出登录'); expect(state.detailLabel, '请先登录 svc.plus'); }, ); test('desktop snapshot uses derived assistant connection labels', () async { final controller = await _isolatedController(); addTearDown(controller.dispose); await controller.sessionsController.switchSession('unit-fixture-task-a'); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); controller.runtimeInternal.snapshotInternal = controller .runtimeInternal .snapshot .copyWith( status: RuntimeConnectionStatus.error, statusText: 'Connection failed', remoteAddress: 'openclaw.svc.plus:443', lastError: 'unsupported Ed25519 private key length: 0', lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED', ); final snapshot = controller.desktopStatusSnapshot(); expect(snapshot['connectionStatus'], 'disconnected'); expect(snapshot['connectionLabel'], '已退出登录'); }); }); } Future _isolatedController({ List? initialBridgeProviderCatalog, List? initialGatewayProviderCatalog, List? initialAvailableExecutionTargets, Map environmentOverride = const {}, }) async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-assistant-connection-state-', ); addTearDown(() async { if (await storeRoot.exists()) { try { await storeRoot.delete(recursive: true); } on FileSystemException { // Temp cleanup is best effort here. } } }); final store = SecureConfigStore( secretRootPathResolver: () async => '${storeRoot.path}/secrets', appDataRootPathResolver: () async => '${storeRoot.path}/app-data', supportRootPathResolver: () async => '${storeRoot.path}/support', enableSecureStorage: false, ); await store.initialize(); return AppController( environmentOverride: environmentOverride, store: store, initialBridgeProviderCatalog: initialBridgeProviderCatalog, initialGatewayProviderCatalog: initialGatewayProviderCatalog, initialAvailableExecutionTargets: initialAvailableExecutionTargets, ); }