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_external_acp_routing.dart'; import 'package:xworkmate/app/app_controller_desktop_runtime_helpers.dart'; import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart'; import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart'; import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; import 'package:xworkmate/runtime/account_runtime_client.dart'; import 'package:xworkmate/runtime/codex_config_bridge.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/runtime_controllers.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('resolveDesktopThreadBindingSnapshotInternal', () { const localOwner = ThreadOwnerScope( realm: ThreadRealm.local, subjectType: ThreadSubjectType.user, subjectId: 'u1', displayName: 'User', ); TaskThread buildThread({ required String threadId, required ThreadExecutionMode mode, required String providerId, }) { return TaskThread( threadId: threadId, ownerScope: localOwner, workspaceBinding: const WorkspaceBinding( workspaceId: 'ws-1', workspaceKind: WorkspaceKind.localFs, workspacePath: '/tmp/ws', displayPath: '/tmp/ws', writable: true, ), executionBinding: ExecutionBinding( executionMode: mode, executorId: providerId, providerId: providerId, endpointId: '', ), ); } test('prefers the latest record after async binding resumes', () { final latestRecord = buildThread( threadId: 'thread-1', mode: ThreadExecutionMode.localAgent, providerId: SingleAgentProvider.opencode.providerId, ); final snapshot = resolveDesktopThreadBindingSnapshotInternal( defaultExecutionTarget: AssistantExecutionTarget.gateway, latestRecord: latestRecord, ); expect(snapshot.executionTarget, AssistantExecutionTarget.singleAgent); expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode); expect(snapshot.record, same(latestRecord)); }); test( 'keeps an explicit execution override while preserving latest provider', () { final latestRecord = buildThread( threadId: 'thread-2', mode: ThreadExecutionMode.localAgent, providerId: SingleAgentProvider.opencode.providerId, ); final snapshot = resolveDesktopThreadBindingSnapshotInternal( defaultExecutionTarget: AssistantExecutionTarget.gateway, executionTargetOverride: AssistantExecutionTarget.gateway, latestRecord: latestRecord, ); expect(snapshot.executionTarget, AssistantExecutionTarget.gateway); expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode); }, ); test('does not recover provider from stale fallback-only records', () { final staleRecord = buildThread( threadId: 'thread-3', mode: ThreadExecutionMode.gateway, providerId: SingleAgentProvider.codex.providerId, ); final snapshot = resolveDesktopThreadBindingSnapshotInternal( defaultExecutionTarget: AssistantExecutionTarget.gateway, latestRecord: null, ); expect(snapshot.executionTarget, AssistantExecutionTarget.gateway); expect(snapshot.singleAgentProvider.isUnspecified, isTrue); expect(snapshot.record, isNull); expect(staleRecord.executionBinding.providerId, isNotEmpty); }); }); group('resolveGatewayExecutionTargetFromVisibleTargets', () { test('prefers remote bridge target over silent local fallback', () { final target = resolveGatewayExecutionTargetFromVisibleTargets( const [ AssistantExecutionTarget.singleAgent, AssistantExecutionTarget.gateway, ], currentTarget: AssistantExecutionTarget.singleAgent, ); expect(target, AssistantExecutionTarget.gateway); }); test( 'falls back to remote when legacy local gateway selection is active', () { final target = resolveGatewayExecutionTargetFromVisibleTargets( const [AssistantExecutionTarget.gateway], currentTarget: AssistantExecutionTarget.gateway, ); expect(target, AssistantExecutionTarget.gateway); }, ); }); group('resolveGatewayThreadConnectionStateInternal', () { test('uses the thread target profile as the only address source', () { final targetProfile = GatewayConnectionProfile.defaults().copyWith( mode: RuntimeConnectionMode.remote, host: 'bridge.example.internal', port: 443, tls: true, ); final state = resolveGatewayThreadConnectionStateInternal( target: AssistantExecutionTarget.gateway, connection: GatewayConnectionSnapshot.initial( mode: RuntimeConnectionMode.remote, ).copyWith( status: RuntimeConnectionStatus.connected, remoteAddress: 'legacy-loopback:18789', ), targetProfile: targetProfile, ); expect(state.status, RuntimeConnectionStatus.connected); expect(state.detailLabel, 'bridge.example.internal:443'); expect(state.ready, isTrue); }); test('marks mismatched local snapshot as offline for remote threads', () { final targetProfile = GatewayConnectionProfile.defaults().copyWith( mode: RuntimeConnectionMode.remote, host: 'bridge.example.internal', port: 443, tls: true, ); final state = resolveGatewayThreadConnectionStateInternal( target: AssistantExecutionTarget.gateway, connection: GatewayConnectionSnapshot.initial( mode: RuntimeConnectionMode.local, ).copyWith( status: RuntimeConnectionStatus.connected, remoteAddress: 'legacy-loopback:18789', ), targetProfile: targetProfile, ); expect(state.status, RuntimeConnectionStatus.offline); expect(state.detailLabel, 'bridge.example.internal:443'); expect(state.ready, isFalse); expect(state.lastError, isNull); }); }); group('assistantConnectionStateForSession', () { test( 'uses target profile address instead of connection snapshot address', () { final gateway = _FakeGatewayRuntime( GatewayConnectionSnapshot.initial( mode: RuntimeConnectionMode.remote, ).copyWith( status: RuntimeConnectionStatus.connected, remoteAddress: 'legacy-loopback:18789', ), ); final controller = AppController( runtimeCoordinator: RuntimeCoordinator( gateway: gateway, codex: CodexRuntime(), configBridge: CodexConfigBridge(), ), ); addTearDown(() async { controller.dispose(); await gateway.disposeTestResources(); }); const sessionKey = 'draft:remote-status'; controller.initializeAssistantThreadContext( sessionKey, executionTarget: AssistantExecutionTarget.gateway, ); controller.upsertTaskThreadInternal( sessionKey, executionTarget: AssistantExecutionTarget.gateway, executionTargetSource: ThreadSelectionSource.explicit, ); final state = controller.assistantConnectionStateForSession(sessionKey); expect(state.status, RuntimeConnectionStatus.connected); expect(state.detailLabel, '未连接目标'); expect(state.ready, isTrue); }, ); }); group('buildExternalAcpRoutingForSessionInternal', () { test('never emits explicit provider id for gateway threads', () { final controller = AppController(); addTearDown(controller.dispose); const sessionKey = 'draft:routing'; controller.initializeAssistantThreadContext( sessionKey, executionTarget: AssistantExecutionTarget.gateway, singleAgentProvider: SingleAgentProvider.opencode, ); controller.upsertTaskThreadInternal( sessionKey, executionTarget: AssistantExecutionTarget.gateway, executionTargetSource: ThreadSelectionSource.explicit, singleAgentProvider: SingleAgentProvider.opencode, singleAgentProviderSource: ThreadSelectionSource.explicit, ); final routing = controller.buildExternalAcpRoutingForSessionInternal( sessionKey, ); expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit); expect(routing.explicitExecutionTarget, 'gateway'); expect(routing.explicitProviderId, isEmpty); }); }); group('persistGoTaskArtifactsForSessionInternal', () { test( 'writes bridge-returned artifacts into the local thread workspace', () async { final root = Directory.systemTemp.createTempSync( 'xworkmate-thread-artifacts-test-', ); final controller = AppController(); addTearDown(() async { controller.dispose(); if (root.existsSync()) { await root.delete(recursive: true); } }); const sessionKey = 'draft:remote-artifacts'; controller.upsertTaskThreadInternal( sessionKey, executionTarget: AssistantExecutionTarget.gateway, executionTargetSource: ThreadSelectionSource.explicit, workspaceBinding: WorkspaceBinding( workspaceId: 'workspace-1', workspaceKind: WorkspaceKind.localFs, workspacePath: root.path, displayPath: root.path, writable: true, ), ); await controller.persistGoTaskArtifactsForSessionInternal( sessionKey, GoTaskServiceResult( success: true, message: 'ok', turnId: 'turn-1', raw: { 'artifacts': >[ { 'relativePath': '../notes/result.md', 'encoding': 'utf-8', 'content': 'artifact-body', }, { 'relativePath': 'bin/data.txt', 'encoding': 'base64', 'content': 'YmluYXJ5LWRhdGE=', }, ], }, errorMessage: '', resolvedModel: '', route: GoTaskServiceRoute.externalAcpSingle, ), ); expect( File('${root.path}/notes/result.md').readAsStringSync(), 'artifact-body', ); expect( File('${root.path}/bin/data.txt').readAsStringSync(), 'binary-data', ); final record = controller.requireTaskThreadForSessionInternal( sessionKey, ); expect(record.lastArtifactSyncStatus, 'synced'); expect(record.lastArtifactSyncAtMs, isNotNull); }, ); }); group('resolveGatewayAcpAuthorizationHeaderInternal', () { test('uses only synced or persisted BRIDGE_SERVER_URL values', () { 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 { final root = await Directory.systemTemp.createTemp( 'xworkmate-bridge-auth-header-', ); final store = SecureConfigStore( enableSecureStorage: false, appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', secretRootPathResolver: () async => root.path, supportRootPathResolver: () async => root.path, ); final controller = AppController( store: store, accountClientFactory: (_) => _BridgeSyncAccountRuntimeClient(), ); addTearDown(() async { controller.dispose(); if (await root.exists()) { try { await root.delete(recursive: true); } on FileSystemException { // Temp cleanup is best-effort on macOS when sqlite/watch handles lag. } } }); await store.initialize(); await controller.settingsController.initialize(); await controller.settingsController.saveSnapshot( controller.settings.copyWith( accountBaseUrl: 'https://accounts.svc.plus', accountUsername: 'review@svc.plus', ), ); await controller.settingsController.loginAccount( baseUrl: 'https://accounts.svc.plus', identifier: 'review@svc.plus', password: 'Review123!', ); await controller.settingsController.saveGatewaySecrets( profileIndex: kGatewayRemoteProfileIndex, token: 'local-token', password: '', ); final bridgeAuthorization = await controller .resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('https://xworkmate-bridge.svc.plus/acp'), ); final nonBridgeAuthorization = await controller .resolveGatewayAcpAuthorizationHeaderInternal( Uri.parse('https://remote.example.com/acp'), ); expect(bridgeAuthorization, 'Bearer bridge-token'); expect(nonBridgeAuthorization, isNull); }, ); }); group('thread working directory', () { test( 'uses the unique thread workspace as the only workingDirectory source', () async { final controller = AppController(); addTearDown(controller.dispose); const sessionKey = 'draft:thread-working-directory'; controller.initializeAssistantThreadContext( sessionKey, executionTarget: AssistantExecutionTarget.singleAgent, ); final record = controller.requireTaskThreadForSessionInternal( sessionKey, ); expect( controller.assistantWorkingDirectoryForSessionInternal(sessionKey), record.workspaceBinding.workspacePath, ); }, ); }); } class _FakeGatewayRuntime extends GatewayRuntime { factory _FakeGatewayRuntime(GatewayConnectionSnapshot snapshot) { final deps = _FakeGatewayRuntimeDeps(); return _FakeGatewayRuntime._(snapshot, deps); } _FakeGatewayRuntime._(this._snapshot, this._deps) : super(store: _deps.store, identityStore: _deps.identityStore); final GatewayConnectionSnapshot _snapshot; final _FakeGatewayRuntimeDeps _deps; @override GatewayConnectionSnapshot get snapshot => _snapshot; @override bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; @override Future initialize() async {} Future disposeTestResources() async { if (_deps.root.existsSync()) { await _deps.root.delete(recursive: true); } } } class _FakeGatewayRuntimeDeps { factory _FakeGatewayRuntimeDeps() { final root = Directory.systemTemp.createTempSync( 'xworkmate-gateway-runtime-test-', ); final store = SecureConfigStore( enableSecureStorage: false, appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', secretRootPathResolver: () async => root.path, supportRootPathResolver: () async => root.path, ); return _FakeGatewayRuntimeDeps._(root, store, DeviceIdentityStore(store)); } _FakeGatewayRuntimeDeps._(this.root, this.store, this.identityStore); final Directory root; final SecureConfigStore store; final DeviceIdentityStore identityStore; } class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient { _BridgeSyncAccountRuntimeClient() : super(baseUrl: 'https://accounts.svc.plus'); @override Future> login({ required String identifier, required String password, }) async { 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', 'email': identifier, 'name': 'Review', 'role': 'member', 'mfaEnabled': false, }, }; } }