diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 2a0465a1..c4ff515d 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -23,6 +23,7 @@ import '../runtime/settings_store.dart'; import '../runtime/secure_config_store.dart'; import '../runtime/embedded_agent_launch_policy.dart'; import '../runtime/runtime_coordinator.dart'; +import '../runtime/runtime_dispatch_resolver.dart'; import '../runtime/gateway_acp_client.dart'; import '../runtime/codex_runtime.dart'; import '../runtime/codex_config_bridge.dart'; @@ -67,6 +68,7 @@ class AppController extends ChangeNotifier { AccountRuntimeClient Function(String baseUrl)? accountClientFactory, Map? environmentOverride, GoTaskServiceClient? goTaskServiceClient, + RuntimeDispatchResolver? dispatchResolver, }) { environmentOverrideInternal = environmentOverride == null ? null @@ -145,10 +147,11 @@ class AppController extends ChangeNotifier { desktopPlatformServiceInternal = desktopPlatformService ?? createDesktopPlatformService(); runtimeCoordinatorInternal.attachDispatchResolver( - GoRuntimeDispatchDesktopClient( - client: gatewayAcpClientInternal, - endpointResolver: resolveGatewayAcpEndpointInternal, - ), + dispatchResolver ?? + GoRuntimeDispatchDesktopClient( + client: gatewayAcpClientInternal, + endpointResolver: resolveGatewayAcpEndpointInternal, + ), ); goTaskServiceClientInternal = goTaskServiceClient ?? diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 42c36773..59bf00ad 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -933,6 +933,14 @@ extension AppControllerDesktopThreadActions on AppController { ..writeln('TaskThread workspace context:') ..writeln('- sessionKey: $sessionKey') ..writeln('- currentTaskWorkspace: $currentTaskWorkspace'); + if (workingDirectory.trim().isNotEmpty) { + buffer.writeln('- localWorkspace: ${workingDirectory.trim()}'); + } + if (remoteWorkingDirectoryHint.trim().isNotEmpty) { + buffer.writeln( + '- remoteWorkspaceHint: ${remoteWorkingDirectoryHint.trim()}', + ); + } final visibleTaskInputAttachments = taskInputAttachments .where((item) => item.name.trim().isNotEmpty && item.key.isNotEmpty) .toList(growable: false); diff --git a/lib/runtime/code_agent_node_orchestrator.dart b/lib/runtime/code_agent_node_orchestrator.dart index 74239675..616cf10d 100644 --- a/lib/runtime/code_agent_node_orchestrator.dart +++ b/lib/runtime/code_agent_node_orchestrator.dart @@ -70,7 +70,8 @@ class CodeAgentNodeOrchestrator { metadata: resolution.metadata, ); } - } catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace'); + } catch (e, stackTrace) { + debugPrint('Error: $e\n$stackTrace'); // Dispatch metadata is advisory; task execution still carries routing. } } diff --git a/lib/runtime/runtime_coordinator.dart b/lib/runtime/runtime_coordinator.dart index e07c26e5..967efc8e 100644 --- a/lib/runtime/runtime_coordinator.dart +++ b/lib/runtime/runtime_coordinator.dart @@ -64,9 +64,15 @@ class RuntimeCoordinator extends ChangeNotifier { } final normalizedCommand = provider.command.trim(); if (normalizedCommand.isEmpty) { - throw ArgumentError.value(provider.command, 'provider.command', 'Cannot be empty'); + throw ArgumentError.value( + provider.command, + 'provider.command', + 'Cannot be empty', + ); } - final normalizedCapabilities = _normalizeCapabilitySet(provider.capabilities).toList(growable: false)..sort(); + final normalizedCapabilities = _normalizeCapabilitySet( + provider.capabilities, + ).toList(growable: false)..sort(); _externalCodeAgents[normalizedId] = ExternalCodeAgentProvider( id: normalizedId, @@ -96,7 +102,8 @@ class RuntimeCoordinator extends ChangeNotifier { Iterable requiredCapabilities = const [], }) { final required = _normalizeCapabilitySet(requiredCapabilities); - final providers = _externalCodeAgents.values + final providers = + _externalCodeAgents.values .where((provider) => _providerSupports(provider, required)) .toList(growable: false) ..sort((a, b) => a.id.compareTo(b.id)); @@ -147,7 +154,9 @@ class RuntimeCoordinator extends ChangeNotifier { } } - final discovered = discoverExternalCodeAgents(requiredCapabilities: required); + final discovered = discoverExternalCodeAgents( + requiredCapabilities: required, + ); if (discovered.isEmpty) { return null; } @@ -250,7 +259,9 @@ class RuntimeCoordinator extends ChangeNotifier { } Future stopCodeAgentRuntime() async { - _state = gateway.isConnected ? CoordinatorState.ready : CoordinatorState.disconnected; + _state = gateway.isConnected + ? CoordinatorState.ready + : CoordinatorState.disconnected; notifyListeners(); } @@ -264,12 +275,18 @@ class RuntimeCoordinator extends ChangeNotifier { bool supportsCapability(String capability) { switch (capability) { - case 'cloud-memory': return capabilities.hasCloudMemory; - case 'task-queue': return capabilities.hasTaskQueue; - case 'multi-agent': return capabilities.hasMultiAgent; - case 'local-models': return capabilities.hasLocalModels; - case 'code-agent': return capabilities.hasCodeAgent; - default: return false; + case 'cloud-memory': + return capabilities.hasCloudMemory; + case 'task-queue': + return capabilities.hasTaskQueue; + case 'multi-agent': + return capabilities.hasMultiAgent; + case 'local-models': + return capabilities.hasLocalModels; + case 'code-agent': + return capabilities.hasCodeAgent; + default: + return false; } } @@ -295,16 +312,24 @@ class RuntimeCoordinator extends ChangeNotifier { Future _switchMode(GatewayMode mode) { switch (mode) { - case GatewayMode.remote: return modeSwitcher.switchToRemote(); - case GatewayMode.offline: return modeSwitcher.switchToOffline(); + case GatewayMode.remote: + return modeSwitcher.switchToRemote(); + case GatewayMode.offline: + return modeSwitcher.switchToOffline(); } } static Set _normalizeCapabilitySet(Iterable capabilities) { - return capabilities.map((item) => item.trim().toLowerCase()).where((item) => item.isNotEmpty).toSet(); + return capabilities + .map((item) => item.trim().toLowerCase()) + .where((item) => item.isNotEmpty) + .toSet(); } - static bool _providerSupports(ExternalCodeAgentProvider provider, Set requiredCapabilities) { + static bool _providerSupports( + ExternalCodeAgentProvider provider, + Set requiredCapabilities, + ) { if (requiredCapabilities.isEmpty) return true; final provided = _normalizeCapabilitySet(provider.capabilities); return requiredCapabilities.every(provided.contains); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index bbf0accc..166f59be 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -12,6 +12,7 @@ import 'package:xworkmate/features/assistant/assistant_page_composer_skill_picke import 'package:xworkmate/runtime/gateway_acp_client.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/runtime_dispatch_resolver.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; @@ -1645,6 +1646,7 @@ void main() { 'continue with the same image', attachments: [imageAttachment], ); + await fakeGoTaskService.waitForRequestCount(2); expect(fakeGoTaskService.requests, hasLength(2)); expect( @@ -4608,6 +4610,7 @@ AppController _sandboxController({ 'HOME': actualHome, }, goTaskServiceClient: goTaskServiceClient, + dispatchResolver: _NoopRuntimeDispatchResolver(), ); } @@ -4847,6 +4850,16 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { ); } + Future waitForRequestCount(int count) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (requests.length < count && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 10)); + } + if (requests.length < count) { + throw StateError('Timed out waiting for $count requests.'); + } + } + @override Future getTask({ required AssistantExecutionTarget target, @@ -5008,3 +5021,107 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient { @override Future dispose() async {} } + +class _NoopRuntimeDispatchResolver implements RuntimeDispatchResolver { + @override + Future resolveGatewayDispatch({ + required List providers, + required String preferredProviderId, + required Iterable requiredCapabilities, + required Map nodeState, + required Map nodeInfo, + }) async { + final selectedProvider = _selectLocalProvider( + providers, + preferredProviderId, + requiredCapabilities, + ); + final metadata = { + 'node': { + 'id': nodeInfo['id']?.toString() ?? 'xworkmate-app', + 'name': nodeInfo['name']?.toString() ?? '', + 'version': nodeInfo['version']?.toString() ?? '', + 'kind': 'app-mediated-cooperative-node', + 'gatewayTransport': 'websocket-rpc', + }, + 'dispatch': { + 'mode': nodeState['bridgeEnabled'] == true + ? 'cooperative' + : 'gateway-only', + 'executionTarget': nodeState['executionTarget']?.toString() ?? '', + }, + 'bridge': { + 'enabled': nodeState['bridgeEnabled'] == true, + 'state': nodeState['bridgeState']?.toString() ?? '', + 'gatewayConnected': nodeState['gatewayConnected'] == true, + 'runtimeMode': nodeState['runtimeMode']?.toString() ?? '', + 'localTransport': 'stdio-jsonrpc', + }, + if (selectedProvider != null) + 'provider': { + 'id': selectedProvider.id, + 'name': selectedProvider.name, + 'defaultArgs': selectedProvider.defaultArgs, + 'capabilities': selectedProvider.capabilities, + }, + }; + return RuntimeDispatchResolution( + agentId: + nodeState['selectedAgentId']?.toString().trim().isNotEmpty == true + ? nodeState['selectedAgentId'].toString().trim() + : null, + providerId: selectedProvider?.id, + metadata: metadata, + raw: metadata, + ); + } + + @override + Future selectProviderId({ + required List providers, + String preferredProviderId = '', + Iterable requiredCapabilities = const [], + }) async { + return null; + } + + @override + Future dispose() async {} + + ExternalCodeAgentProvider? _selectLocalProvider( + List providers, + String preferredProviderId, + Iterable requiredCapabilities, + ) { + final required = requiredCapabilities + .map((item) => item.trim().toLowerCase()) + .where((item) => item.isNotEmpty) + .toSet(); + + bool supports(ExternalCodeAgentProvider provider) { + if (required.isEmpty) { + return true; + } + final capabilities = provider.capabilities + .map((item) => item.trim().toLowerCase()) + .where((item) => item.isNotEmpty) + .toSet(); + return required.every(capabilities.contains); + } + + final normalizedPreferred = preferredProviderId.trim(); + if (normalizedPreferred.isNotEmpty) { + for (final provider in providers) { + if (provider.id == normalizedPreferred && supports(provider)) { + return provider; + } + } + } + for (final provider in providers) { + if (supports(provider)) { + return provider; + } + } + return null; + } +}