Fix gateway dispatch test pipeline

This commit is contained in:
Haitao Pan 2026-06-08 13:20:41 +08:00
parent ae16a2d978
commit 7f42fbbda0
5 changed files with 174 additions and 20 deletions

View File

@ -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<String, String>? 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 ??

View File

@ -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);

View File

@ -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.
}
}

View File

@ -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<String> requiredCapabilities = const <String>[],
}) {
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<void> 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<ModeSwitchResult> _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<String> _normalizeCapabilitySet(Iterable<String> 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<String> requiredCapabilities) {
static bool _providerSupports(
ExternalCodeAgentProvider provider,
Set<String> requiredCapabilities,
) {
if (requiredCapabilities.isEmpty) return true;
final provided = _normalizeCapabilitySet(provider.capabilities);
return requiredCapabilities.every(provided.contains);

View File

@ -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: <GatewayChatAttachmentPayload>[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<void> waitForRequestCount(int count) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (requests.length < count && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
if (requests.length < count) {
throw StateError('Timed out waiting for $count requests.');
}
}
@override
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
@ -5008,3 +5021,107 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient {
@override
Future<void> dispose() async {}
}
class _NoopRuntimeDispatchResolver implements RuntimeDispatchResolver {
@override
Future<RuntimeDispatchResolution> resolveGatewayDispatch({
required List<ExternalCodeAgentProvider> providers,
required String preferredProviderId,
required Iterable<String> requiredCapabilities,
required Map<String, dynamic> nodeState,
required Map<String, dynamic> nodeInfo,
}) async {
final selectedProvider = _selectLocalProvider(
providers,
preferredProviderId,
requiredCapabilities,
);
final metadata = <String, dynamic>{
'node': <String, dynamic>{
'id': nodeInfo['id']?.toString() ?? 'xworkmate-app',
'name': nodeInfo['name']?.toString() ?? '',
'version': nodeInfo['version']?.toString() ?? '',
'kind': 'app-mediated-cooperative-node',
'gatewayTransport': 'websocket-rpc',
},
'dispatch': <String, dynamic>{
'mode': nodeState['bridgeEnabled'] == true
? 'cooperative'
: 'gateway-only',
'executionTarget': nodeState['executionTarget']?.toString() ?? '',
},
'bridge': <String, dynamic>{
'enabled': nodeState['bridgeEnabled'] == true,
'state': nodeState['bridgeState']?.toString() ?? '',
'gatewayConnected': nodeState['gatewayConnected'] == true,
'runtimeMode': nodeState['runtimeMode']?.toString() ?? '',
'localTransport': 'stdio-jsonrpc',
},
if (selectedProvider != null)
'provider': <String, dynamic>{
'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<String?> selectProviderId({
required List<ExternalCodeAgentProvider> providers,
String preferredProviderId = '',
Iterable<String> requiredCapabilities = const <String>[],
}) async {
return null;
}
@override
Future<void> dispose() async {}
ExternalCodeAgentProvider? _selectLocalProvider(
List<ExternalCodeAgentProvider> providers,
String preferredProviderId,
Iterable<String> 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;
}
}