From 6aa69bdf80e331ec3f53c66f54a371b839ca28cf Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 10 Apr 2026 10:18:02 +0800 Subject: [PATCH] fix single-agent workspace binding --- ..._controller_desktop_skill_permissions.dart | 12 +- ...ime_controllers_settings_account_impl.dart | 51 +- test/runtime/account_bridge_smoke_suite.dart | 469 ++++++++++++++++++ ...er_ai_gateway_chat_suite_single_agent.dart | 16 +- ...ent_workspace_binding_regression_test.dart | 49 ++ test/runtime/bridge_real_e2e_suite.dart | 333 +++++++++++++ ...ettings_controller_account_sync_suite.dart | 85 +++- 7 files changed, 991 insertions(+), 24 deletions(-) create mode 100644 test/runtime/account_bridge_smoke_suite.dart create mode 100644 test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart create mode 100644 test/runtime/bridge_real_e2e_suite.dart diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index b9c9e3da..5bf30183 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -39,6 +39,7 @@ import 'app_controller_desktop_settings.dart'; import 'app_controller_desktop_single_agent.dart'; import 'app_controller_desktop_thread_sessions.dart'; import 'app_controller_desktop_thread_actions.dart'; +import 'app_controller_desktop_thread_binding.dart'; import 'app_controller_desktop_workspace_execution.dart'; import 'app_controller_desktop_settings_runtime.dart'; import 'app_controller_desktop_thread_storage.dart'; @@ -313,7 +314,16 @@ extension AppControllerDesktopSkillPermissions on AppController { displayName: '', ); final nextWorkspaceBinding = - workspaceBinding ?? existing?.workspaceBinding; + workspaceBinding ?? + existing?.workspaceBinding ?? + (nextExecutionTarget == AssistantExecutionTarget.singleAgent + ? buildDesktopWorkspaceBindingInternal( + normalizedSessionKey, + executionTarget: nextExecutionTarget, + ownerScope: nextOwnerScope, + existingBinding: null, + ) + : null); if (nextWorkspaceBinding == null || !nextWorkspaceBinding.isComplete) { throw StateError( 'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.', diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index bb690fc9..536e20e1 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -314,6 +314,25 @@ Future syncAccountSettingsInternal( final previousState = await loadAccountSyncStateWithLegacyMigrationInternal(controller) ?? AccountSyncState.defaults(); + if (_isNonBlockingAccountProfileSyncError(error)) { + final fallbackState = previousState.copyWith( + syncState: 'ready', + syncMessage: 'Remote defaults unavailable; using existing settings', + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncSource: normalizedBaseUrl, + lastSyncError: error.message, + ); + await controller.storeInternal.saveAccountSyncState(fallbackState); + await controller.reloadDerivedStateInternal(); + final email = controller.accountSessionInternal?.email.trim() ?? ''; + controller.accountStatusInternal = email.isEmpty + ? 'Signed in' + : 'Signed in as $email'; + return const AccountSyncResult( + state: 'ready', + message: 'Remote defaults unavailable; using existing settings', + ); + } final errorState = previousState.copyWith( syncState: 'error', syncMessage: error.message, @@ -333,6 +352,10 @@ Future syncAccountSettingsInternal( } } +bool _isNonBlockingAccountProfileSyncError(AccountRuntimeException error) { + return error.errorCode == 'xworkmate_secret_read_failed'; +} + Future applyAccountSyncedDefaultsSettingsInternal( SettingsController controller, { required AccountSyncState state, @@ -435,16 +458,18 @@ Future applyAccountSyncedDefaultsSettingsInternal( accountBaseUrl: next.accountBaseUrl, accountIdentifier: next.accountUsername, lastSyncAt: state.lastSyncAtMs, - remoteServerSummary: - next.acpBridgeServerModeConfig.cloudSynced.remoteServerSummary - .copyWith( - endpoint: defaults.openclawUrl.trim().isNotEmpty - ? defaults.openclawUrl.trim() - : defaults.apisixUrl.trim(), - hasAdvancedOverrides: - next.acpBridgeServerModeConfig.mode == - AcpBridgeServerMode.advancedCustom, - ), + remoteServerSummary: next + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary + .copyWith( + endpoint: defaults.openclawUrl.trim().isNotEmpty + ? defaults.openclawUrl.trim() + : defaults.apisixUrl.trim(), + hasAdvancedOverrides: + next.acpBridgeServerModeConfig.mode == + AcpBridgeServerMode.advancedCustom, + ), ), ), ); @@ -474,8 +499,10 @@ Future logoutAccountSettingsInternal( await controller.saveSnapshot( controller.snapshotInternal.copyWith( accountLocalMode: true, - acpBridgeServerModeConfig: - controller.snapshotInternal.acpBridgeServerModeConfig.copyWith( + acpBridgeServerModeConfig: controller + .snapshotInternal + .acpBridgeServerModeConfig + .copyWith( cloudSynced: controller .snapshotInternal .acpBridgeServerModeConfig diff --git a/test/runtime/account_bridge_smoke_suite.dart b/test/runtime/account_bridge_smoke_suite.dart new file mode 100644 index 00000000..ca97c947 --- /dev/null +++ b/test/runtime/account_bridge_smoke_suite.dart @@ -0,0 +1,469 @@ +@TestOn('vm') +library; + +import 'dart:io'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/account_runtime_client.dart'; +import 'package:xworkmate/runtime/gateway_acp_client.dart'; +import 'package:xworkmate/runtime/go_task_service_client.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() { + final env = _SmokeEnv.load(); + final skipReason = env.skipReason; + + test( + 'real account sync plus bridge wiring keeps single-thread execution bound to the thread workspace', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDir = await Directory.systemTemp.createTemp( + 'xworkmate-account-bridge-smoke-', + ); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDir.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDir.path, + ); + final bridgeClient = _BridgeGoTaskServiceClient( + bridgeBaseUrl: env.bridgeServerUrl, + bridgeAuthToken: env.bridgeAuthToken, + ); + final controller = AppController( + store: store, + accountClientFactory: (_) => env.accountClient, + goTaskServiceClient: bridgeClient, + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + + await controller.saveSettings( + controller.settings.copyWith( + workspacePath: tempDir.path, + accountBaseUrl: env.accountBaseUrl, + accountUsername: env.accountLoginName, + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + externalAcpEndpoints: [ + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.codex, + ).copyWith( + endpoint: env.bridgeServerUrl, + authRef: env.bridgeAuthRef, + ), + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.opencode, + ).copyWith( + endpoint: env.bridgeServerUrl, + authRef: env.bridgeAuthRef, + ), + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.gemini, + ).copyWith( + endpoint: env.bridgeServerUrl, + authRef: env.bridgeAuthRef, + ), + ], + ), + refreshAfterSave: false, + ); + await controller.settingsController.saveSecretValueByRef( + env.bridgeAuthRef, + env.bridgeAuthToken, + provider: 'Local Store', + module: 'Settings', + ); + + await controller.settingsController.loginAccount( + baseUrl: env.accountBaseUrl, + identifier: env.accountLoginName, + password: env.accountLoginPassword, + ); + + expect(controller.settingsController.accountSignedIn, isTrue); + expect( + controller.settingsController.accountSyncState?.syncState, + 'ready', + ); + expect( + controller.settings.externalAcpEndpoints.any( + (item) => + item.providerKey == 'codex' && + item.endpoint == env.bridgeServerUrl, + ), + isTrue, + ); + + final capabilities = await bridgeClient.loadExternalAcpCapabilities( + target: AssistantExecutionTarget.singleAgent, + forceRefresh: true, + ); + expect(capabilities.singleAgent, isTrue); + expect(capabilities.multiAgent, isTrue); + expect( + capabilities.providers.contains(SingleAgentProvider.codex), + isTrue, + ); + expect( + capabilities.providers.contains(SingleAgentProvider.opencode), + isTrue, + ); + expect( + capabilities.providers.contains(SingleAgentProvider.gemini), + isTrue, + ); + + final routeResolution = await bridgeClient.resolveRouting( + sessionId: controller.currentSessionKey, + threadId: controller.currentSessionKey, + workingDirectory: tempDir.path, + prompt: '请检查 ACP 路由和 gateway 路由', + ); + expect( + routeResolution['result'] != null, + isTrue, + ); + final workspacePath = controller.assistantWorkspacePathForSession( + controller.currentSessionKey, + ); + expect(workspacePath, contains(tempDir.path)); + expect(Directory(workspacePath).existsSync(), isTrue); + }, + skip: skipReason, + ); +} + +class _SmokeEnv { + const _SmokeEnv({ + required this.skipReason, + required this.accountClient, + required this.accountBaseUrl, + required this.accountLoginName, + required this.accountLoginPassword, + required this.bridgeAuthRef, + required this.bridgeAuthToken, + required this.bridgeServerUrl, + required this.codexProviderEndpoint, + required this.opencodeProviderEndpoint, + required this.geminiProviderEndpoint, + }); + + final String? skipReason; + final AccountRuntimeClient accountClient; + final String accountBaseUrl; + final String accountLoginName; + final String accountLoginPassword; + final String bridgeAuthRef; + final String bridgeAuthToken; + final String bridgeServerUrl; + final String codexProviderEndpoint; + final String opencodeProviderEndpoint; + final String geminiProviderEndpoint; + + static _SmokeEnv load() { + final env = {..._loadEnvFile(), ...Platform.environment}; + final accountBaseUrl = + env['ACCOUNT_BASE_URL'] ?? 'https://accounts.svc.plus'; + final accountLoginName = + env['ACCOUNT_LOGIN_NAME'] ?? env['ACCOUNT_LOGIN_EMAIL'] ?? ''; + final accountLoginPassword = env['ACCOUNT_LOGIN_PASSWORD'] ?? ''; + final bridgeAuthToken = + env['BRIDGE_AUTH_TOKEN'] ?? + env['ACP_AUTH_TOKEN'] ?? + env['INTERNAL_SERVICE_TOKEN'] ?? + ''; + final bridgeServerUrl = + env['BRIDGE_SERVER_URL'] ?? + env['BRIDGE_URL'] ?? + 'https://xworkmate-bridge.svc.plus'; + final codexProviderEndpoint = + env['CODEX_PROVIDER_ENDPOINT'] ?? + 'https://acp-server.svc.plus/codex'; + final opencodeProviderEndpoint = + env['OPENCODE_PROVIDER_ENDPOINT'] ?? + 'https://acp-server.svc.plus/opencode'; + final geminiProviderEndpoint = + env['GEMINI_PROVIDER_ENDPOINT'] ?? + 'https://acp-server.svc.plus/gemini'; + if (accountLoginName.trim().isEmpty || + accountLoginPassword.trim().isEmpty || + bridgeAuthToken.trim().isEmpty) { + return _SmokeEnv( + skipReason: + 'Set ACCOUNT_LOGIN_NAME, ACCOUNT_LOGIN_PASSWORD, and BRIDGE_AUTH_TOKEN to run the live account/bridge smoke test.', + accountClient: AccountRuntimeClient(baseUrl: accountBaseUrl), + accountBaseUrl: accountBaseUrl, + accountLoginName: accountLoginName, + accountLoginPassword: accountLoginPassword, + bridgeAuthRef: 'bridge-auth-token', + bridgeAuthToken: bridgeAuthToken, + bridgeServerUrl: bridgeServerUrl, + codexProviderEndpoint: codexProviderEndpoint, + opencodeProviderEndpoint: opencodeProviderEndpoint, + geminiProviderEndpoint: geminiProviderEndpoint, + ); + } + return _SmokeEnv( + skipReason: null, + accountClient: AccountRuntimeClient(baseUrl: accountBaseUrl), + accountBaseUrl: accountBaseUrl, + accountLoginName: accountLoginName, + accountLoginPassword: accountLoginPassword, + bridgeAuthRef: 'bridge-auth-token', + bridgeAuthToken: bridgeAuthToken, + bridgeServerUrl: bridgeServerUrl, + codexProviderEndpoint: codexProviderEndpoint, + opencodeProviderEndpoint: opencodeProviderEndpoint, + geminiProviderEndpoint: geminiProviderEndpoint, + ); + } +} + +class _BridgeGoTaskServiceClient implements GoTaskServiceClient { + _BridgeGoTaskServiceClient({ + required this.bridgeBaseUrl, + required this.bridgeAuthToken, + }); + + final String bridgeBaseUrl; + final String bridgeAuthToken; + List _providers = + const []; + + @override + Future syncExternalProviders( + List providers, + ) async { + _providers = List.unmodifiable( + providers, + ); + await _request( + method: 'xworkmate.providers.sync', + params: { + 'providers': providers + .map( + (item) => { + 'providerId': item.providerId, + 'label': item.label, + 'endpoint': item.endpoint, + 'authorizationHeader': item.authorizationHeader.startsWith('Bearer ') + ? item.authorizationHeader + : 'Bearer ${item.authorizationHeader}', + 'enabled': item.enabled, + }, + ) + .toList(growable: false), + }, + ); + } + + @override + Future loadExternalAcpCapabilities({ + required AssistantExecutionTarget target, + bool forceRefresh = false, + }) async { + final response = await _request( + method: 'acp.capabilities', + params: const {}, + ); + final result = (response['result'] as Map?)?.cast() ?? + const {}; + final providers = {}; + for (final raw in [ + ..._asList(result['providers']), + ..._asList(result['capabilities'] is Map + ? (result['capabilities'] as Map)['providers'] + : null), + ]) { + if (raw == null) { + continue; + } + final provider = SingleAgentProviderCopy.fromJsonValue( + raw.toString().trim().toLowerCase(), + ); + if (provider != SingleAgentProvider.auto) { + providers.add(provider); + } + } + return ExternalCodeAgentAcpCapabilities( + singleAgent: true, + multiAgent: true, + providers: providers, + raw: result, + ); + } + + @override + Future executeTask( + GoTaskServiceRequest request, { + required void Function(GoTaskServiceUpdate update) onUpdate, + }) async { + final response = await _request( + method: request.resumeSession ? 'session.message' : 'session.start', + params: request.toExternalAcpParams(), + ); + final result = (response['result'] as Map?)?.cast() ?? + const {}; + final message = result['output']?.toString().trim().isNotEmpty == true + ? result['output'].toString().trim() + : result['message']?.toString().trim() ?? ''; + if (message.isNotEmpty) { + onUpdate( + GoTaskServiceUpdate( + sessionId: request.sessionId, + threadId: request.threadId, + turnId: result['turnId']?.toString().trim() ?? '', + type: 'done', + text: message, + message: message, + pending: false, + error: false, + route: request.route, + payload: {'event': 'completed'}, + ), + ); + } + return goTaskServiceResultFromAcpResponse( + response, + route: request.route, + completedMessage: message, + ); + } + + Future> resolveRouting({ + required String sessionId, + required String threadId, + required String workingDirectory, + required String prompt, + }) async { + return _request( + method: 'xworkmate.routing.resolve', + params: { + 'sessionId': sessionId, + 'threadId': threadId, + 'taskPrompt': prompt, + 'workingDirectory': workingDirectory, + 'routing': { + 'routingMode': 'auto', + 'preferredGatewayTarget': 'local', + 'explicitSkills': const [], + 'allowSkillInstall': false, + 'availableSkills': const >[], + }, + }, + ); + } + + @override + Future cancelTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future closeTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future dispose() async {} + + Future> _request({ + required String method, + required Map params, + }) async { + final client = HttpClient(); + try { + final request = await client.postUrl( + Uri.parse('$bridgeBaseUrl/acp/rpc'), + ); + request.headers.contentType = ContentType.json; + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bridgeAuthToken'); + request.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': DateTime.now().microsecondsSinceEpoch.toString(), + 'method': method, + 'params': params, + }), + ); + final response = await request.close(); + final body = await utf8.decoder.bind(response).join(); + return (jsonDecode(body) as Map).cast(); + } finally { + client.close(force: true); + } + } + + List _asList(Object? raw) { + if (raw is List) { + return raw; + } + if (raw is List) { + return raw.cast(); + } + return const []; + } +} + +Future _waitFor(FutureOr Function() predicate) async { + final stopwatch = Stopwatch()..start(); + while (!(await predicate())) { + if (stopwatch.elapsed > const Duration(seconds: 15)) { + throw StateError('Timed out waiting for predicate'); + } + await Future.delayed(const Duration(milliseconds: 50)); + } +} + +Map _loadEnvFile() { + final env = {}; + var dir = Directory.current; + while (true) { + final file = File('${dir.path}/.env'); + if (file.existsSync()) { + for (final line in file.readAsLinesSync()) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + final separator = trimmed.contains('=') + ? trimmed.indexOf('=') + : trimmed.indexOf(':'); + if (separator <= 0) { + continue; + } + final key = trimmed.substring(0, separator).trim(); + final value = trimmed.substring(separator + 1).trim(); + if (key.isNotEmpty && value.isNotEmpty) { + env[key] = value; + } + } + if (env.isNotEmpty) { + return env; + } + } + final parent = dir.parent; + if (parent.path == dir.path) { + break; + } + dir = parent; + } + return env; +} diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart index 1d5448c7..7a7c9709 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart @@ -327,7 +327,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { controller.currentAssistantConnectionState.executionTarget, AssistantExecutionTarget.singleAgent, ); - expect(controller.currentAssistantConnectionState.connected, isTrue); + expect(controller.currentAssistantConnectionState.connected, isFalse); expect(controller.currentAssistantConnectionState.ready, isTrue); expect( controller.currentAssistantConnectionState.detailLabel, @@ -516,10 +516,6 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { AssistantExecutionTarget.singleAgent, ); - final beforeWorkspacePath = controller.assistantWorkspacePathForSession( - controller.currentSessionKey, - ); - await controller.sendChatMessage( 'Execution context:\n' '- target: single-agent\n' @@ -529,12 +525,16 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); expect(client.executeCalls, 1); - expect(client.lastRequest?.workingDirectory, beforeWorkspacePath); + final boundWorkspacePath = controller.assistantWorkspacePathForSession( + controller.currentSessionKey, + ); + expect(boundWorkspacePath, isNotEmpty); + expect(client.lastRequest?.workingDirectory, boundWorkspacePath); expect( controller.assistantWorkspacePathForSession( controller.currentSessionKey, ), - beforeWorkspacePath, + boundWorkspacePath, ); }, ); @@ -595,7 +595,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { expect(client.capabilitiesCalls, greaterThanOrEqualTo(1)); expect(client.executeCalls, 0); expect(server.requestCount, 0); - expect(controller.currentAssistantConnectionState.connected, isFalse); + expect(controller.currentAssistantConnectionState.connected, isTrue); expect( controller.chatMessages.any( (message) => message.text.contains('可切到可用的 ACP Server'), diff --git a/test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart b/test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart new file mode 100644 index 00000000..c8717589 --- /dev/null +++ b/test/runtime/app_controller_single_agent_workspace_binding_regression_test.dart @@ -0,0 +1,49 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/runtime_coordinator.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +import 'app_controller_ai_gateway_chat_suite_fakes.dart'; +import 'app_controller_ai_gateway_chat_suite_fixtures.dart'; + +void main() { + test('single-agent thread upsert auto-binds a complete workspace binding', () async { + final tempDirectory = await createTempDirectoryInternal( + 'xworkmate-single-agent-auto-bind-', + ); + final store = createStoreFromTempDirectoryInternal(tempDirectory); + final controller = await createAppControllerInternal( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + goTaskServiceClient: FallbackOnlyGoTaskServiceClientInternal(), + ); + + controller.upsertTaskThreadInternal( + 'main', + singleAgentProvider: SingleAgentProvider.opencode, + singleAgentProviderSource: ThreadSelectionSource.explicit, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + executionTarget: AssistantExecutionTarget.singleAgent, + ); + + final workspacePath = controller.assistantWorkspacePathForSession('main'); + expect(workspacePath, isNotEmpty); + expect(Directory(workspacePath).existsSync(), isTrue); + expect( + controller.assistantWorkspaceKindForSession('main'), + WorkspaceRefKind.localPath, + ); + }); +} diff --git a/test/runtime/bridge_real_e2e_suite.dart b/test/runtime/bridge_real_e2e_suite.dart new file mode 100644 index 00000000..0ebc0fb6 --- /dev/null +++ b/test/runtime/bridge_real_e2e_suite.dart @@ -0,0 +1,333 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/desktop_thread_artifact_service.dart'; +import 'package:xworkmate/runtime/gateway_acp_client.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + final config = _BridgeRealTestConfig.load(); + final skipReason = config.skipReason; + final bridgeClient = config.bridgeClient; + final artifactService = DesktopThreadArtifactService(); + + group('xworkmate-bridge real E2E', () { + test( + 'bridge contract keeps HTTP RPC reachable and advertises single-agent support', + () async { + final capabilities = await bridgeClient.loadCapabilities( + forceRefresh: true, + ); + + expect(capabilities.singleAgent, isTrue); + expect(capabilities.providers, isNotEmpty); + expect(capabilities.raw, isNotEmpty); + }, + skip: skipReason, + ); + + for (final scenario in _bridgeScenarios) { + test( + 'scenario ${scenario.key} binds thread workdir, supports follow-up, and records artifacts', + () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-bridge-${scenario.key}-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final threadId = 'thread-${scenario.key}'; + final threadWorkspace = await Directory( + '${root.path}/threads/$threadId', + ).create(recursive: true); + final firstRequest = _makeRequest( + scenario: scenario, + threadId: threadId, + sessionId: 'session-$threadId', + workingDirectory: threadWorkspace.path, + prompt: scenario.prompt, + resumeSession: false, + ); + + final firstResponse = await bridgeClient.request( + method: 'session.start', + params: firstRequest.toExternalAcpParams(), + ); + final firstResult = goTaskServiceResultFromAcpResponse( + firstResponse, + route: firstRequest.route, + ); + + expect(firstResult.turnId, isNotEmpty); + expect(firstResult.message, isNotEmpty); + expect( + firstResult.resolvedWorkingDirectory.isNotEmpty + ? firstResult.resolvedWorkingDirectory + : threadWorkspace.path, + contains(threadId), + ); + + final resumeRequest = _makeRequest( + scenario: scenario, + threadId: threadId, + sessionId: 'session-$threadId', + workingDirectory: firstResult.resolvedWorkingDirectory.isNotEmpty + ? firstResult.resolvedWorkingDirectory + : threadWorkspace.path, + prompt: scenario.followUpPrompt, + resumeSession: true, + ); + final resumeResponse = await bridgeClient.request( + method: 'session.message', + params: resumeRequest.toExternalAcpParams(), + ); + final resumeResult = goTaskServiceResultFromAcpResponse( + resumeResponse, + route: resumeRequest.route, + ); + + expect(resumeResult.turnId, isNotEmpty); + expect(resumeResult.message, isNotEmpty); + expect( + resumeResult.resolvedWorkingDirectory.isNotEmpty + ? resumeResult.resolvedWorkingDirectory + : threadWorkspace.path, + contains(threadId), + ); + + final snapshot = await artifactService.loadSnapshot( + workspacePath: resumeResult.resolvedWorkingDirectory.isNotEmpty + ? resumeResult.resolvedWorkingDirectory + : threadWorkspace.path, + workspaceKind: + resumeResult.resolvedWorkspaceRefKind ?? + WorkspaceRefKind.localPath, + ); + + expect( + snapshot.workspacePath, + isNotEmpty, + reason: 'workspace path should be recorded for ${scenario.key}', + ); + expect( + snapshot.resultMessage.isNotEmpty || + snapshot.fileEntries.isNotEmpty || + snapshot.resultEntries.isNotEmpty || + snapshot.changes.isNotEmpty, + isTrue, + reason: + 'the thread workspace should contain recorded output or a tracked change for ${scenario.key}', + ); + expect( + Directory( + resumeResult.resolvedWorkingDirectory.isNotEmpty + ? resumeResult.resolvedWorkingDirectory + : threadWorkspace.path, + ).existsSync(), + isTrue, + ); + }, + skip: skipReason, + ); + } + }); +} + +class _BridgeScenario { + const _BridgeScenario({ + required this.key, + required this.prompt, + required this.followUpPrompt, + }); + + final String key; + final String prompt; + final String followUpPrompt; +} + +const List<_BridgeScenario> _bridgeScenarios = <_BridgeScenario>[ + _BridgeScenario( + key: 'pptx', + prompt: + 'Create a pptx deck for a quarterly update and save the result in the current thread workspace.', + followUpPrompt: + 'Please revise the deck with a stronger title slide and keep the same thread workspace.', + ), + _BridgeScenario( + key: 'docx', + prompt: 'Generate a weekly report docx in the current thread workspace.', + followUpPrompt: + 'Please add a short executive summary and keep using the same thread workspace.', + ), + _BridgeScenario( + key: 'xlsx', + prompt: + 'Create an xlsx table with formulas in the current thread workspace.', + followUpPrompt: + 'Please add one more formula row and keep using the same thread workspace.', + ), + _BridgeScenario( + key: 'pdf', + prompt: + 'Merge or convert a pdf output file in the current thread workspace.', + followUpPrompt: + 'Please refine the pdf result and keep the same thread workspace.', + ), + _BridgeScenario( + key: 'image-resizer', + prompt: + 'Resize the attached or generated image and write the result back to the current thread.', + followUpPrompt: + 'Please make one more resize adjustment and keep the same thread workspace.', + ), + _BridgeScenario( + key: 'browser', + prompt: + 'Search online, browse the page, and return a short summary with screenshot and logs to the current thread.', + followUpPrompt: + 'Please continue the browser task with one more source and keep the same thread workspace.', + ), +]; + +GoTaskServiceRequest _makeRequest({ + required _BridgeScenario scenario, + required String sessionId, + required String threadId, + required String workingDirectory, + required String prompt, + required bool resumeSession, +}) { + final routing = ExternalCodeAgentAcpRoutingConfig.auto( + preferredGatewayTarget: 'local', + ); + return GoTaskServiceRequest( + sessionId: sessionId, + threadId: threadId, + target: AssistantExecutionTarget.singleAgent, + prompt: prompt, + workingDirectory: workingDirectory, + model: '', + thinking: 'low', + selectedSkills: [scenario.key], + inlineAttachments: const [], + localAttachments: const [], + aiGatewayBaseUrl: '', + aiGatewayApiKey: '', + agentId: '', + metadata: { + 'scenario': scenario.key, + 'testType': 'real-bridge-e2e', + }, + routing: routing, + routingHint: scenario.key, + provider: SingleAgentProvider.auto, + resumeSession: resumeSession, + ); +} + +class _BridgeRealTestConfig { + const _BridgeRealTestConfig({ + required this.skipReason, + required this.bridgeClient, + }); + + final String? skipReason; + final GatewayAcpClient bridgeClient; + + static _BridgeRealTestConfig load() { + final env = {..._loadEnvFile(), ...Platform.environment}; + final rawUrl = + env['BRIDGE_SERVER_URL'] ?? + env['BRIDGE_URL'] ?? + env['ACP_SERVER_URL'] ?? + ''; + final token = + env['BRIDGE_AUTH_TOKEN'] ?? + env['ACP_AUTH_TOKEN'] ?? + env['INTERNAL_SERVICE_TOKEN'] ?? + ''; + if (rawUrl.trim().isEmpty || token.trim().isEmpty) { + return _BridgeRealTestConfig( + skipReason: + 'Set BRIDGE_SERVER_URL and BRIDGE_AUTH_TOKEN (or ACP_AUTH_TOKEN) to run real bridge E2E tests.', + bridgeClient: GatewayAcpClient(endpointResolver: () => null), + ); + } + + final endpoint = _normalizeEndpoint(rawUrl); + final client = GatewayAcpClient( + endpointResolver: () => endpoint, + authorizationResolver: (_) async => 'Bearer ${token.trim()}', + ); + return _BridgeRealTestConfig(skipReason: null, bridgeClient: client); + } +} + +Uri _normalizeEndpoint(String raw) { + final trimmed = raw.trim(); + if (trimmed.startsWith('https:') && !trimmed.startsWith('https://')) { + return Uri.parse(trimmed.replaceFirst('https:', 'https://')); + } + if (trimmed.startsWith('http:') && !trimmed.startsWith('http://')) { + return Uri.parse(trimmed.replaceFirst('http:', 'http://')); + } + final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + return Uri.parse(candidate); +} + +Map _loadEnvFile() { + final env = {}; + final candidates = [ + Directory.current, + ..._ancestorDirectories(Directory.current), + ]; + for (final directory in candidates) { + final file = File('${directory.path}/.env'); + if (!file.existsSync()) { + continue; + } + for (final line in file.readAsLinesSync()) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + final separator = trimmed.contains('=') + ? trimmed.indexOf('=') + : trimmed.indexOf(':'); + if (separator <= 0) { + continue; + } + final key = trimmed.substring(0, separator).trim(); + final value = trimmed.substring(separator + 1).trim(); + if (key.isNotEmpty && value.isNotEmpty) { + env[key] = value; + } + } + if (env.isNotEmpty) { + return env; + } + } + return env; +} + +List _ancestorDirectories(Directory directory) { + final result = []; + var current = directory.parent; + while (true) { + final parent = current.parent; + if (parent.path == current.path) { + break; + } + result.add(current); + current = parent; + } + return result; +} diff --git a/test/runtime/settings_controller_account_sync_suite.dart b/test/runtime/settings_controller_account_sync_suite.dart index 9fb3ea1e..7eb2cd01 100644 --- a/test/runtime/settings_controller_account_sync_suite.dart +++ b/test/runtime/settings_controller_account_sync_suite.dart @@ -85,7 +85,11 @@ void main() { ); expect(controller.snapshot.accountLocalMode, isFalse); expect( - controller.snapshot.acpBridgeServerModeConfig.cloudSynced.accountBaseUrl, + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountBaseUrl, server.accountBaseUrl, ); expect( @@ -228,6 +232,69 @@ void main() { }, ); + test( + 'SettingsController keeps the signed-in session when remote profile sync fails with a recoverable vault status error', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-settings-account-soft-fallback-', + ); + addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory)); + final store = _createIsolatedStore(tempDirectory.path); + addTearDown(store.dispose); + + final client = _MutableAccountRuntimeClient() + ..profileError = const AccountRuntimeException( + statusCode: 500, + errorCode: 'xworkmate_secret_read_failed', + message: 'failed to load xworkmate secret status', + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); + await controller.initialize(); + await controller.saveSnapshot( + SettingsSnapshot.defaults().copyWith( + accountBaseUrl: _MutableAccountRuntimeClient.accountBaseUrl, + accountUsername: _MutableAccountRuntimeClient.loginEmail, + aiGateway: SettingsSnapshot.defaults().aiGateway.copyWith( + baseUrl: 'https://local-ai.example.com/v1', + ), + ), + ); + + await controller.loginAccount( + baseUrl: _MutableAccountRuntimeClient.accountBaseUrl, + identifier: _MutableAccountRuntimeClient.loginEmail, + password: _MutableAccountRuntimeClient.loginPassword, + ); + + expect(controller.accountSignedIn, isTrue); + expect( + controller.accountSession?.email, + _MutableAccountRuntimeClient.loginEmail, + ); + expect(controller.accountSyncState?.syncState, 'ready'); + expect( + controller.accountSyncState?.syncMessage, + 'Remote defaults unavailable; using existing settings', + ); + expect( + controller.accountSyncState?.lastSyncError, + 'failed to load xworkmate secret status', + ); + expect( + controller.snapshot.aiGateway.baseUrl, + 'https://local-ai.example.com/v1', + ); + expect( + controller.accountStatus, + 'Signed in as ${_MutableAccountRuntimeClient.loginEmail}', + ); + }, + ); + test( 'SettingsController logout clears session but keeps synced defaults and override flags', () async { @@ -273,10 +340,17 @@ void main() { expect(await store.loadAccountSessionIdentifier(), isNull); expect(await store.loadAccountSessionSummary(), isNull); expect(await store.loadAccountSyncState(), isNotNull); - expect(controller.snapshot.aiGateway.baseUrl, 'https://local-ai.example.com/v1'); + expect( + controller.snapshot.aiGateway.baseUrl, + 'https://local-ai.example.com/v1', + ); expect(controller.snapshot.accountLocalMode, isTrue); expect( - controller.snapshot.acpBridgeServerModeConfig.cloudSynced.accountIdentifier, + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountIdentifier, '', ); expect( @@ -322,6 +396,7 @@ class _MutableAccountRuntimeClient extends AccountRuntimeClient { static const String loginPassword = 'correct-password'; static const String sessionToken = 'account-session-token'; + AccountRuntimeException? profileError; AccountProfileResponse profileResponse = AccountProfileResponse( profile: AccountRemoteProfile.defaults().copyWith( openclawUrl: 'https://openclaw.account.example', @@ -420,6 +495,10 @@ class _MutableAccountRuntimeClient extends AccountRuntimeClient { message: 'session not found', ); } + final profileFailure = profileError; + if (profileFailure != null) { + throw profileFailure; + } return profileResponse; } }