From 288da17a47d652c77fff83d1058a7aafed4f72b1 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 08:49:46 +0800 Subject: [PATCH] refactor: remove silent local gateway fallback --- ...ntroller_desktop_external_acp_routing.dart | 3 +- ...ler_desktop_runtime_coordination_impl.dart | 1 + ...pp_controller_desktop_runtime_helpers.dart | 147 +++++++++++ ...ler_desktop_single_agent_go_task_flow.dart | 106 +------- ..._controller_desktop_skill_permissions.dart | 20 +- ...app_controller_desktop_thread_actions.dart | 2 + ...app_controller_desktop_thread_binding.dart | 11 +- ...op_thread_sessions_collaboration_impl.dart | 5 + ...app_controller_desktop_thread_storage.dart | 19 +- lib/features/mobile/mobile_shell_core.dart | 3 + lib/runtime/runtime_models_connection.dart | 6 +- ...ontroller_desktop_thread_binding_test.dart | 238 ++++++++++++++++++ ...t_execution_target_picker_widget_test.dart | 228 +++++++++++++++++ .../external_acp_bridge_sync_order_test.dart | 118 +++++++++ 14 files changed, 783 insertions(+), 124 deletions(-) create mode 100644 test/assistant_execution_target_picker_widget_test.dart create mode 100644 test/runtime/external_acp_bridge_sync_order_test.dart diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index 401db133..4e6975d7 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -72,7 +72,8 @@ extension AppControllerDesktopExternalAcpRouting on AppController { .toList(growable: false); final resolvedExplicitProviderId = - thread?.hasExplicitProviderSelection ?? false + sessionTarget == AssistantExecutionTarget.singleAgent && + (thread?.hasExplicitProviderSelection ?? false) ? singleAgentProviderForSession(normalizedSessionKey).providerId : ''; final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 65c229ea..be9c1268 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -103,6 +103,7 @@ Future refreshSingleAgentCapabilitiesRuntimeInternal( AppController controller, { bool forceRefresh = false, }) async { + await controller.syncExternalAcpProvidersInternal(); final capabilities = await controller.goTaskServiceClientInternal .loadExternalAcpCapabilities( target: AssistantExecutionTarget.singleAgent, diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 2ed81a91..b9f27ded 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -27,6 +27,7 @@ import '../runtime/codex_config_bridge.dart'; import '../runtime/code_agent_node_orchestrator.dart'; import '../runtime/assistant_artifacts.dart'; import '../runtime/desktop_thread_artifact_service.dart'; +import '../runtime/go_task_service_client.dart'; import '../runtime/mode_switcher.dart'; import '../runtime/agent_registry.dart'; import '../runtime/multi_agent_orchestrator.dart'; @@ -712,6 +713,104 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return resolveSingleAgentAuthorizationHeaderInternal(endpoint); } + Future> + buildExternalAcpSyncedProvidersInternal() async { + final providers = []; + for (final profile in settings.externalAcpEndpoints) { + final provider = settings.singleAgentProviderForId(profile.providerKey); + if (provider == SingleAgentProvider.auto) { + continue; + } + final endpoint = profile.endpoint.trim(); + if (!profile.enabled || endpoint.isEmpty) { + continue; + } + final authorizationHeader = profile.authRef.trim().isEmpty + ? '' + : await settingsControllerInternal.resolveSecretValueInternal( + refName: profile.authRef.trim(), + ); + providers.add( + ExternalCodeAgentAcpSyncedProvider( + providerId: provider.providerId, + label: provider.label, + endpoint: endpoint, + authorizationHeader: authorizationHeader, + enabled: true, + ), + ); + } + return providers; + } + + Future syncExternalAcpProvidersInternal() async { + await goTaskServiceClientInternal.syncExternalProviders( + await buildExternalAcpSyncedProvidersInternal(), + ); + } + + Future persistGoTaskArtifactsForSessionInternal( + String sessionKey, + GoTaskServiceResult result, + ) async { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final artifacts = result.artifacts; + final syncedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + if (artifacts.isEmpty) { + upsertTaskThreadInternal( + normalizedSessionKey, + lastArtifactSyncAtMs: syncedAtMs, + lastArtifactSyncStatus: 'no-artifacts', + updatedAtMs: syncedAtMs, + ); + return; + } + final existingThread = requireTaskThreadForSessionInternal( + normalizedSessionKey, + ); + if (existingThread.workspaceBinding.workspaceKind != + WorkspaceKind.localFs) { + upsertTaskThreadInternal( + normalizedSessionKey, + lastArtifactSyncAtMs: syncedAtMs, + lastArtifactSyncStatus: 'skipped-non-local-workspace', + updatedAtMs: syncedAtMs, + ); + return; + } + final root = Directory(existingThread.workspaceBinding.workspacePath); + await root.create(recursive: true); + + var wroteArtifact = false; + for (final artifact in artifacts) { + if (!artifact.hasInlineContent) { + continue; + } + final relativePath = _sanitizeArtifactRelativePathInternal( + artifact.relativePath, + ); + if (relativePath.isEmpty) { + continue; + } + final target = await _nextArtifactTargetFileInternal(root, relativePath); + await target.parent.create(recursive: true); + await target.writeAsBytes( + _decodeArtifactContentInternal(artifact), + flush: true, + ); + wroteArtifact = true; + } + + upsertTaskThreadInternal( + normalizedSessionKey, + lastArtifactSyncAtMs: syncedAtMs, + lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content', + updatedAtMs: syncedAtMs, + ); + } + Uri? resolveGatewayAcpEndpointInternal() { final target = assistantExecutionTargetForSession( sessionsControllerInternal.currentSessionKey, @@ -821,3 +920,51 @@ extension AppControllerDesktopRuntimeHelpers on AppController { }; } } + +String _sanitizeArtifactRelativePathInternal(String raw) { + final trimmed = raw.trim().replaceAll('\\', '/'); + if (trimmed.isEmpty) { + return ''; + } + return trimmed + .split('/') + .where( + (segment) => segment.isNotEmpty && segment != '.' && segment != '..', + ) + .join('/'); +} + +List _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) { + final encoding = artifact.encoding.trim().toLowerCase(); + if (encoding == 'base64') { + return base64Decode(artifact.content); + } + return utf8.encode(artifact.content); +} + +Future _nextArtifactTargetFileInternal( + Directory root, + String relativePath, +) async { + final segments = relativePath.split('/'); + final fileName = segments.removeLast(); + final parent = segments.isEmpty + ? root + : Directory('${root.path}/${segments.join('/')}'); + final dotIndex = fileName.lastIndexOf('.'); + final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); + final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex); + var candidate = File('${parent.path}/$fileName'); + if (!await candidate.exists()) { + return candidate; + } + for (var version = 2; version < 1000; version += 1) { + candidate = File('${parent.path}/$baseName.v$version$extension'); + if (!await candidate.exists()) { + return candidate; + } + } + return File( + '${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension', + ); +} 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 b94d1cc5..e349e03e 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 @@ -1,8 +1,6 @@ // ignore_for_file: unused_import, unnecessary_import import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; @@ -153,6 +151,7 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( .map((item) => item.label.trim().isNotEmpty ? item.label : item.key) .where((item) => item.trim().isNotEmpty) .toList(growable: false); + await controller.syncExternalAcpProvidersInternal(); final result = await controller.goTaskServiceClientInternal.executeTask( GoTaskServiceRequest( sessionId: sessionKey, @@ -340,105 +339,4 @@ Future _persistSingleAgentArtifactsDesktopInternal( AppController controller, String sessionKey, GoTaskServiceResult result, -) async { - final artifacts = result.artifacts; - if (artifacts.isEmpty) { - controller.upsertTaskThreadInternal( - sessionKey, - lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastArtifactSyncStatus: 'no-artifacts', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - return; - } - final existingThread = controller.requireTaskThreadForSessionInternal( - sessionKey, - ); - if (existingThread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) { - controller.upsertTaskThreadInternal( - sessionKey, - lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastArtifactSyncStatus: 'skipped-non-local-workspace', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - return; - } - final root = Directory(existingThread.workspaceBinding.workspacePath); - await root.create(recursive: true); - - var wroteArtifact = false; - for (final artifact in artifacts) { - if (!artifact.hasInlineContent) { - continue; - } - final relativePath = _sanitizeArtifactRelativePathInternal( - artifact.relativePath, - ); - if (relativePath.isEmpty) { - continue; - } - final target = await _nextArtifactTargetFileInternal(root, relativePath); - await target.parent.create(recursive: true); - await target.writeAsBytes( - _decodeArtifactContentInternal(artifact), - flush: true, - ); - wroteArtifact = true; - } - - controller.upsertTaskThreadInternal( - sessionKey, - lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); -} - -String _sanitizeArtifactRelativePathInternal(String raw) { - final trimmed = raw.trim().replaceAll('\\', '/'); - if (trimmed.isEmpty) { - return ''; - } - final cleaned = trimmed - .split('/') - .where( - (segment) => segment.isNotEmpty && segment != '.' && segment != '..', - ) - .join('/'); - return cleaned; -} - -List _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) { - final encoding = artifact.encoding.trim().toLowerCase(); - if (encoding == 'base64') { - return base64Decode(artifact.content); - } - return utf8.encode(artifact.content); -} - -Future _nextArtifactTargetFileInternal( - Directory root, - String relativePath, -) async { - final segments = relativePath.split('/'); - final fileName = segments.removeLast(); - final parent = segments.isEmpty - ? root - : Directory('${root.path}/${segments.join('/')}'); - final dotIndex = fileName.lastIndexOf('.'); - final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); - final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex); - var candidate = File('${parent.path}/$fileName'); - if (!await candidate.exists()) { - return candidate; - } - for (var version = 2; version < 1000; version += 1) { - candidate = File('${parent.path}/$baseName.v$version$extension'); - if (!await candidate.exists()) { - return candidate; - } - } - return File( - '${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension', - ); -} +) => controller.persistGoTaskArtifactsForSessionInternal(sessionKey, result); diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index 541613a5..4df60855 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -334,10 +334,18 @@ extension AppControllerDesktopSkillPermissions on AppController { ); } final nextProvider = - singleAgentProvider ?? - SingleAgentProviderCopy.fromJsonValue( - executionBinding?.providerId ?? existing?.executionBinding.providerId, - ); + nextExecutionTarget == AssistantExecutionTarget.singleAgent + ? (singleAgentProvider ?? + SingleAgentProviderCopy.fromJsonValue( + executionBinding?.providerId ?? + existing?.executionBinding.providerId, + )) + : SingleAgentProvider.auto; + final nextProviderSource = + nextExecutionTarget == AssistantExecutionTarget.singleAgent + ? (singleAgentProviderSource ?? + existing?.executionBinding.providerSource) + : ThreadSelectionSource.inherited; final nextExecutionBinding = (executionBinding ?? existing?.executionBinding ?? @@ -361,9 +369,7 @@ extension AppControllerDesktopSkillPermissions on AppController { executionModeSource: executionTargetSource ?? existing?.executionBinding.executionModeSource, - providerSource: - singleAgentProviderSource ?? - existing?.executionBinding.providerSource, + providerSource: nextProviderSource, ); final nextContextState = (contextState ?? diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 48d7d93f..ec3e7866 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -306,6 +306,7 @@ extension AppControllerDesktopThreadActions on AppController { try { final dispatch = await codeAgentNodeOrchestratorInternal .buildGatewayDispatch(buildCodeAgentNodeStateInternal()); + await syncExternalAcpProvidersInternal(); final result = await goTaskServiceClientInternal.executeTask( GoTaskServiceRequest( sessionId: sessionKey, @@ -347,6 +348,7 @@ extension AppControllerDesktopThreadActions on AppController { lastResultCode: result.success ? 'success' : 'error', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); + await persistGoTaskArtifactsForSessionInternal(sessionKey, result); if (!result.success) { appendLocalSessionMessageInternal( sessionKey, diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 9ac867f9..671852c4 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -255,9 +255,10 @@ extension AppControllerDesktopThreadBinding on AppController { required SingleAgentProvider singleAgentProvider, ExecutionBinding? existingBinding, }) { - final sanitizedProvider = settings.sanitizeSingleAgentProviderSelection( - singleAgentProvider, - ); + final sanitizedProvider = + executionTarget == AssistantExecutionTarget.singleAgent + ? settings.sanitizeSingleAgentProviderSelection(singleAgentProvider) + : SingleAgentProvider.auto; return (existingBinding ?? ExecutionBinding( executionMode: ThreadExecutionMode.localAgent, @@ -275,6 +276,10 @@ extension AppControllerDesktopThreadBinding on AppController { }, executorId: sanitizedProvider.providerId, providerId: sanitizedProvider.providerId, + providerSource: + executionTarget == AssistantExecutionTarget.singleAgent + ? existingBinding?.providerSource + : ThreadSelectionSource.inherited, ); } diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart index c63e531e..e8cf17cf 100644 --- a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -160,6 +160,7 @@ Future runMultiAgentCollaborationThreadSessionInternal( ); controller.recomputeTasksInternal(); try { + await controller.syncExternalAcpProvidersInternal(); final result = await controller.goTaskServiceClientInternal.executeTask( GoTaskServiceRequest( sessionId: sessionKey, @@ -203,6 +204,10 @@ Future runMultiAgentCollaborationThreadSessionInternal( ); }, ); + await controller.persistGoTaskArtifactsForSessionInternal( + sessionKey, + result, + ); controller.appendLocalSessionMessageInternal( sessionKey, GatewayChatMessage( diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index fc515325..c1104da5 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -444,7 +444,7 @@ extension AppControllerDesktopThreadStorage on AppController { path: resolvedRootPath, bookmark: rootSpec.bookmark, ), - ); + ); if (accessHandle == null) { continue; } @@ -690,11 +690,14 @@ extension AppControllerDesktopThreadStorage on AppController { final recordExecutionTarget = assistantExecutionTargetFromExecutionMode( record.executionBinding.executionMode, ); - final recordProvider = settings.sanitizeSingleAgentProviderSelection( - SingleAgentProviderCopy.fromJsonValue( - record.executionBinding.providerId, - ), - ); + final recordProvider = + recordExecutionTarget == AssistantExecutionTarget.singleAgent + ? settings.sanitizeSingleAgentProviderSelection( + SingleAgentProviderCopy.fromJsonValue( + record.executionBinding.providerId, + ), + ) + : SingleAgentProvider.auto; final workspaceBinding = record.workspaceBinding.copyWith( workspaceId: sessionKey, displayPath: record.workspaceKind == WorkspaceKind.localFs @@ -728,6 +731,10 @@ extension AppControllerDesktopThreadStorage on AppController { ), executorId: recordProvider.providerId, providerId: recordProvider.providerId, + providerSource: + recordExecutionTarget == AssistantExecutionTarget.singleAgent + ? record.executionBinding.providerSource + : ThreadSelectionSource.inherited, ), lifecycleState: record.lifecycleState.copyWith(status: 'ready'), ); diff --git a/lib/features/mobile/mobile_shell_core.dart b/lib/features/mobile/mobile_shell_core.dart index 104047f8..e00b1899 100644 --- a/lib/features/mobile/mobile_shell_core.dart +++ b/lib/features/mobile/mobile_shell_core.dart @@ -276,6 +276,9 @@ class MobileShellStateInternal extends State { ); return; } + if (!mounted) { + return; + } final codeController = TextEditingController(); final enteredCode = await showDialog( context: context, diff --git a/lib/runtime/runtime_models_connection.dart b/lib/runtime/runtime_models_connection.dart index b321c1fb..e0314409 100644 --- a/lib/runtime/runtime_models_connection.dart +++ b/lib/runtime/runtime_models_connection.dart @@ -127,12 +127,12 @@ AssistantExecutionTarget resolveGatewayExecutionTargetFromVisibleTargets( visible.contains(currentTarget)) { return currentTarget; } - if (visible.contains(AssistantExecutionTarget.local)) { - return AssistantExecutionTarget.local; - } if (visible.contains(AssistantExecutionTarget.remote)) { return AssistantExecutionTarget.remote; } + if (visible.contains(AssistantExecutionTarget.local)) { + return AssistantExecutionTarget.local; + } return AssistantExecutionTarget.remote; } diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart index 77db7f4d..37f18e76 100644 --- a/test/app_controller_desktop_thread_binding_test.dart +++ b/test/app_controller_desktop_thread_binding_test.dart @@ -1,9 +1,25 @@ +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/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_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('resolveDesktopThreadBindingSnapshotInternal', () { const localOwner = ThreadOwnerScope( realm: ThreadRealm.local, @@ -92,6 +108,33 @@ void main() { }); }); + group('resolveGatewayExecutionTargetFromVisibleTargets', () { + test('prefers remote bridge target over silent local fallback', () { + final target = resolveGatewayExecutionTargetFromVisibleTargets( + const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ], + currentTarget: AssistantExecutionTarget.singleAgent, + ); + + expect(target, AssistantExecutionTarget.remote); + }); + + test('preserves explicit local gateway selection when already active', () { + final target = resolveGatewayExecutionTargetFromVisibleTargets( + const [ + AssistantExecutionTarget.local, + AssistantExecutionTarget.remote, + ], + currentTarget: AssistantExecutionTarget.local, + ); + + expect(target, AssistantExecutionTarget.local); + }); + }); + group('resolveGatewayThreadConnectionStateInternal', () { test('uses the thread target profile as the only address source', () { final state = resolveGatewayThreadConnectionStateInternal( @@ -130,4 +173,199 @@ void main() { 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: '127.0.0.1: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.remote, + ); + controller.upsertTaskThreadInternal( + sessionKey, + executionTarget: AssistantExecutionTarget.remote, + executionTargetSource: ThreadSelectionSource.explicit, + ); + + final state = controller.assistantConnectionStateForSession(sessionKey); + + expect(state.status, RuntimeConnectionStatus.connected); + expect(state.detailLabel, 'openclaw.svc.plus:443'); + 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.remote, + singleAgentProvider: SingleAgentProvider.opencode, + ); + controller.upsertTaskThreadInternal( + sessionKey, + executionTarget: AssistantExecutionTarget.remote, + executionTargetSource: ThreadSelectionSource.explicit, + singleAgentProvider: SingleAgentProvider.opencode, + singleAgentProviderSource: ThreadSelectionSource.explicit, + ); + + final routing = controller.buildExternalAcpRoutingForSessionInternal( + sessionKey, + ); + + expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit); + expect(routing.explicitExecutionTarget, 'remote'); + 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.remote, + 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); + }, + ); + }); +} + +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, + databasePathResolver: () async => '${root.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => root.path, + defaultSupportDirectoryPathResolver: () 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; } diff --git a/test/assistant_execution_target_picker_widget_test.dart b/test/assistant_execution_target_picker_widget_test.dart new file mode 100644 index 00000000..295e5d61 --- /dev/null +++ b/test/assistant_execution_target_picker_widget_test.dart @@ -0,0 +1,228 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller_desktop_core.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; +import 'package:xworkmate/runtime/desktop_platform_service.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'; +import 'package:xworkmate/runtime/skill_directory_access.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'compact gateway picker selects remote bridge route instead of local fallback', + (tester) async { + final root = Directory.systemTemp.createTempSync( + 'xworkmate-picker-widget-test-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${root.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => root.path, + defaultSupportDirectoryPathResolver: () async => root.path, + ); + final controller = AppController( + store: store, + desktopPlatformService: UnsupportedDesktopPlatformService(), + skillDirectoryAccessService: _FakeSkillDirectoryAccessService( + root.path, + ), + goTaskServiceClient: const _FakeGoTaskServiceClient(), + singleAgentSharedSkillScanRootOverrides: const [], + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + ); + final inputController = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(() async { + controller.dispose(); + inputController.dispose(); + focusNode.dispose(); + if (root.existsSync()) { + await root.delete(recursive: true); + } + }); + + controller.settingsController.snapshotInternal = controller.settings + .copyWith(savedGatewayTargets: const ['local', 'remote']); + controller.lastObservedSettingsSnapshotInternal = + controller.settingsController.snapshotInternal; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(platform: TargetPlatform.macOS), + home: Scaffold( + body: ComposerBarInternal( + controller: controller, + inputController: inputController, + focusNode: focusNode, + thinkingLabel: 'Normal', + showModelControl: false, + modelLabel: '', + modelOptions: const [], + attachments: const [], + availableSkills: const [], + selectedSkillKeys: const [], + onRemoveAttachment: (_) {}, + onToggleSkill: (_) {}, + onThinkingChanged: (_) {}, + onModelChanged: (_) async {}, + onOpenGateway: () {}, + onOpenAiGatewaySettings: () {}, + onReconnectGateway: () async {}, + onPickAttachments: () {}, + onAddAttachment: (_) {}, + onPasteImageAttachment: () async => null, + onContentHeightChanged: (_) {}, + onInputHeightChanged: (_) {}, + onSend: () async {}, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + controller.assistantExecutionTarget, + AssistantExecutionTarget.singleAgent, + ); + + final buttonFinder = find.byKey( + const Key('assistant-execution-target-button'), + ); + expect(buttonFinder, findsOneWidget); + + final button = tester.widget>( + buttonFinder, + ); + final items = button.itemBuilder(tester.element(buttonFinder)); + final values = items + .whereType>() + .map((item) => item.value) + .toList(growable: false); + + expect(values, contains(AssistantExecutionTarget.remote)); + expect(values, isNot(contains(AssistantExecutionTarget.local))); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + await tester.pump(); + }, + ); +} + +class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { + const _FakeSkillDirectoryAccessService(this.homeDirectory); + + final String homeDirectory; + + @override + bool get isSupported => false; + + @override + Future> authorizeDirectories({ + List suggestedPaths = const [], + }) async { + return const []; + } + + @override + Future authorizeDirectory({ + String suggestedPath = '', + }) async { + return null; + } + + @override + Future openDirectory( + AuthorizedSkillDirectory directory, + ) async { + return null; + } + + @override + Future resolveUserHomeDirectory() async { + return homeDirectory; + } +} + +class _FakeGoTaskServiceClient implements GoTaskServiceClient { + const _FakeGoTaskServiceClient(); + + @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 {} + + @override + Future executeTask( + GoTaskServiceRequest request, { + required void Function(GoTaskServiceUpdate update) onUpdate, + }) async { + return const GoTaskServiceResult( + success: true, + message: '', + turnId: '', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + } + + @override + Future resolveExternalAcpRouting({ + required String taskPrompt, + required String workingDirectory, + required ExternalCodeAgentAcpRoutingConfig routing, + String aiGatewayBaseUrl = '', + String aiGatewayApiKey = '', + }) async { + return const ExternalCodeAgentAcpRoutingResolution( + raw: { + 'resolvedExecutionTarget': 'single-agent', + 'resolvedEndpointTarget': 'singleAgent', + 'resolvedProviderId': 'codex', + 'resolvedModel': '', + 'resolvedSkills': [], + 'unavailable': false, + }, + ); + } + + @override + Future loadExternalAcpCapabilities({ + required AssistantExecutionTarget target, + bool forceRefresh = false, + }) async { + return const ExternalCodeAgentAcpCapabilities.empty(); + } + + @override + Future syncExternalProviders( + List providers, + ) async {} +} diff --git a/test/runtime/external_acp_bridge_sync_order_test.dart b/test/runtime/external_acp_bridge_sync_order_test.dart new file mode 100644 index 00000000..bc0e325c --- /dev/null +++ b/test/runtime/external_acp_bridge_sync_order_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart'; +import 'package:xworkmate/runtime/go_acp_stdio_bridge.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +class _FakeGoAcpStdioBridgeWithSyncOrder extends GoAcpStdioBridge { + final List methods = []; + final StreamController> _notifications = + StreamController>.broadcast(); + + @override + Stream> get notifications => _notifications.stream; + + @override + Future> request({ + required String method, + required Map params, + Duration timeout = const Duration(seconds: 120), + }) async { + methods.add(method); + return switch (method) { + 'acp.capabilities' => { + 'result': { + 'singleAgent': true, + 'multiAgent': true, + 'providers': ['codex'], + }, + }, + _ => { + 'result': { + 'success': true, + 'output': 'ok', + 'resolvedExecutionTarget': 'single-agent', + }, + }, + }; + } + + @override + Future dispose() async { + await _notifications.close(); + } +} + +void main() { + group('External ACP bridge sync order', () { + test('syncs providers before capabilities requests', () async { + final bridge = _FakeGoAcpStdioBridgeWithSyncOrder(); + final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge); + + await transport + .syncExternalProviders(const [ + ExternalCodeAgentAcpSyncedProvider( + providerId: 'codex', + label: 'Codex', + endpoint: 'https://acp-server.svc.plus/codex/acp/rpc', + authorizationHeader: '', + enabled: true, + ), + ]); + + await transport.loadExternalAcpCapabilities( + target: AssistantExecutionTarget.singleAgent, + ); + + expect(bridge.methods, [ + 'xworkmate.providers.sync', + 'xworkmate.providers.sync', + 'acp.capabilities', + ]); + }); + + test('syncs providers before session start requests', () async { + final bridge = _FakeGoAcpStdioBridgeWithSyncOrder(); + final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge); + + await transport + .syncExternalProviders(const [ + ExternalCodeAgentAcpSyncedProvider( + providerId: 'codex', + label: 'Codex', + endpoint: 'https://acp-server.svc.plus/codex/acp/rpc', + authorizationHeader: '', + enabled: true, + ), + ]); + + await transport.executeTask( + const GoTaskServiceRequest( + sessionId: 's1', + threadId: 't1', + target: AssistantExecutionTarget.singleAgent, + prompt: 'hello', + workingDirectory: '/tmp', + model: '', + thinking: '', + selectedSkills: [], + inlineAttachments: [], + localAttachments: [], + aiGatewayBaseUrl: '', + aiGatewayApiKey: '', + agentId: '', + metadata: {}, + ), + onUpdate: (_) {}, + ); + + expect(bridge.methods, [ + 'xworkmate.providers.sync', + 'xworkmate.providers.sync', + 'session.start', + ]); + }); + }); +}