refactor(runtime): move dispatch resolution into go-core
This commit is contained in:
parent
9d475123bb
commit
027a63d629
@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"xworkmate/go_core/internal/dispatch"
|
||||
"xworkmate/go_core/internal/shared"
|
||||
)
|
||||
|
||||
@ -266,6 +267,8 @@ func (s *Server) handleRequest(
|
||||
}
|
||||
closed := s.closeSession(sessionID)
|
||||
return map[string]any{"accepted": true, "closed": closed}, nil
|
||||
case "xworkmate.dispatch.resolve":
|
||||
return handleDispatchResolve(request.Params), nil
|
||||
default:
|
||||
return nil, &shared.RPCError{
|
||||
Code: -32601,
|
||||
@ -274,6 +277,107 @@ func (s *Server) handleRequest(
|
||||
}
|
||||
}
|
||||
|
||||
func handleDispatchResolve(params map[string]any) map[string]any {
|
||||
providers := parseDispatchProviders(params["providers"])
|
||||
requiredCapabilities := parseStringSlice(params["requiredCapabilities"])
|
||||
preferredProviderID := strings.TrimSpace(
|
||||
shared.StringArg(params, "preferredProviderId", ""),
|
||||
)
|
||||
request := dispatch.Request{
|
||||
Providers: providers,
|
||||
PreferredProviderID: preferredProviderID,
|
||||
RequiredCapabilities: requiredCapabilities,
|
||||
}
|
||||
if nodeState := parseDispatchNodeState(params["nodeState"]); nodeState != nil {
|
||||
request.NodeState = nodeState
|
||||
}
|
||||
if nodeInfo := parseDispatchNodeInfo(params["nodeInfo"]); nodeInfo != nil {
|
||||
request.NodeInfo = nodeInfo
|
||||
}
|
||||
return dispatch.ResultMap(dispatch.Resolve(request))
|
||||
}
|
||||
|
||||
func parseDispatchProviders(raw any) []dispatch.Provider {
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
providers := make([]dispatch.Provider, 0, len(list))
|
||||
for _, item := range list {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(shared.StringArg(entry, "id", ""))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
providers = append(providers, dispatch.Provider{
|
||||
ID: id,
|
||||
Name: strings.TrimSpace(shared.StringArg(entry, "name", "")),
|
||||
DefaultArgs: parseStringSlice(entry["defaultArgs"]),
|
||||
Capabilities: parseStringSlice(entry["capabilities"]),
|
||||
})
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func parseDispatchNodeState(raw any) *dispatch.NodeState {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &dispatch.NodeState{
|
||||
SelectedAgentID: strings.TrimSpace(
|
||||
shared.StringArg(entry, "selectedAgentId", ""),
|
||||
),
|
||||
GatewayConnected: shared.BoolArg(
|
||||
fmt.Sprint(entry["gatewayConnected"]),
|
||||
false,
|
||||
),
|
||||
ExecutionTarget: strings.TrimSpace(
|
||||
shared.StringArg(entry, "executionTarget", ""),
|
||||
),
|
||||
RuntimeMode: strings.TrimSpace(shared.StringArg(entry, "runtimeMode", "")),
|
||||
BridgeEnabled: shared.BoolArg(fmt.Sprint(entry["bridgeEnabled"]), false),
|
||||
BridgeState: strings.TrimSpace(shared.StringArg(entry, "bridgeState", "")),
|
||||
ResolvedCodexCLIPath: strings.TrimSpace(
|
||||
shared.StringArg(entry, "resolvedCodexCliPath", ""),
|
||||
),
|
||||
ConfiguredCodexCLIPath: strings.TrimSpace(
|
||||
shared.StringArg(entry, "configuredCodexCliPath", ""),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func parseDispatchNodeInfo(raw any) *dispatch.NodeInfo {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &dispatch.NodeInfo{
|
||||
ID: strings.TrimSpace(shared.StringArg(entry, "id", "")),
|
||||
Name: strings.TrimSpace(shared.StringArg(entry, "name", "")),
|
||||
Version: strings.TrimSpace(shared.StringArg(entry, "version", "")),
|
||||
}
|
||||
}
|
||||
|
||||
func parseStringSlice(raw any) []string {
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
value := strings.TrimSpace(fmt.Sprint(item))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (s *Server) enqueue(threadID string, task task) (map[string]any, *shared.RPCError) {
|
||||
queue := s.ensureQueue(threadID)
|
||||
queue <- task
|
||||
|
||||
203
go/go_core/internal/dispatch/resolve.go
Normal file
203
go/go_core/internal/dispatch/resolve.go
Normal file
@ -0,0 +1,203 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
ID string
|
||||
Name string
|
||||
DefaultArgs []string
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
type NodeState struct {
|
||||
SelectedAgentID string
|
||||
GatewayConnected bool
|
||||
ExecutionTarget string
|
||||
RuntimeMode string
|
||||
BridgeEnabled bool
|
||||
BridgeState string
|
||||
ResolvedCodexCLIPath string
|
||||
ConfiguredCodexCLIPath string
|
||||
}
|
||||
|
||||
type NodeInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Providers []Provider
|
||||
PreferredProviderID string
|
||||
RequiredCapabilities []string
|
||||
NodeState *NodeState
|
||||
NodeInfo *NodeInfo
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Provider *Provider
|
||||
AgentID string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
func Resolve(request Request) Result {
|
||||
provider := selectProvider(
|
||||
request.Providers,
|
||||
request.PreferredProviderID,
|
||||
request.RequiredCapabilities,
|
||||
)
|
||||
if request.NodeState == nil {
|
||||
return Result{Provider: provider, Metadata: map[string]any{}}
|
||||
}
|
||||
|
||||
state := request.NodeState
|
||||
nodeInfo := request.NodeInfo
|
||||
nodeID := "xworkmate-app"
|
||||
nodeName := "XWorkmate"
|
||||
nodeVersion := ""
|
||||
if nodeInfo != nil {
|
||||
if strings.TrimSpace(nodeInfo.ID) != "" {
|
||||
nodeID = strings.TrimSpace(nodeInfo.ID)
|
||||
}
|
||||
if strings.TrimSpace(nodeInfo.Name) != "" {
|
||||
nodeName = strings.TrimSpace(nodeInfo.Name)
|
||||
}
|
||||
nodeVersion = strings.TrimSpace(nodeInfo.Version)
|
||||
}
|
||||
|
||||
configuredPath := strings.TrimSpace(state.ConfiguredCodexCLIPath)
|
||||
if strings.TrimSpace(state.ResolvedCodexCLIPath) != "" {
|
||||
configuredPath = strings.TrimSpace(state.ResolvedCodexCLIPath)
|
||||
}
|
||||
localTransport := "stdio-jsonrpc"
|
||||
if strings.TrimSpace(state.RuntimeMode) == "builtIn" {
|
||||
localTransport = "ffi-runtime"
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"node": map[string]any{
|
||||
"id": nodeID,
|
||||
"name": nodeName,
|
||||
"version": nodeVersion,
|
||||
"kind": "app-mediated-cooperative-node",
|
||||
"gatewayTransport": "websocket-rpc",
|
||||
},
|
||||
"dispatch": map[string]any{
|
||||
"mode": dispatchMode(state.BridgeEnabled),
|
||||
"executionTarget": strings.TrimSpace(state.ExecutionTarget),
|
||||
},
|
||||
"bridge": map[string]any{
|
||||
"enabled": state.BridgeEnabled,
|
||||
"state": strings.TrimSpace(state.BridgeState),
|
||||
"gatewayConnected": state.GatewayConnected,
|
||||
"runtimeMode": strings.TrimSpace(state.RuntimeMode),
|
||||
"localTransport": localTransport,
|
||||
},
|
||||
}
|
||||
if configuredPath != "" {
|
||||
bridge := metadata["bridge"].(map[string]any)
|
||||
bridge["binaryConfigured"] = true
|
||||
}
|
||||
if provider != nil {
|
||||
metadata["provider"] = map[string]any{
|
||||
"id": provider.ID,
|
||||
"name": provider.Name,
|
||||
"defaultArgs": provider.DefaultArgs,
|
||||
"capabilities": provider.Capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
return Result{
|
||||
Provider: provider,
|
||||
AgentID: strings.TrimSpace(state.SelectedAgentID),
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchMode(bridgeEnabled bool) string {
|
||||
if bridgeEnabled {
|
||||
return "cooperative"
|
||||
}
|
||||
return "gateway-only"
|
||||
}
|
||||
|
||||
func selectProvider(
|
||||
providers []Provider,
|
||||
preferredProviderID string,
|
||||
requiredCapabilities []string,
|
||||
) *Provider {
|
||||
required := normalizeCapabilities(requiredCapabilities)
|
||||
preferredID := strings.TrimSpace(preferredProviderID)
|
||||
if preferredID != "" {
|
||||
for _, provider := range providers {
|
||||
if provider.ID == preferredID && supportsProvider(provider, required) {
|
||||
candidate := provider
|
||||
return &candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]Provider, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if supportsProvider(provider, required) {
|
||||
filtered = append(filtered, provider)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
slices.SortFunc(filtered, func(a, b Provider) int {
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
})
|
||||
candidate := filtered[0]
|
||||
return &candidate
|
||||
}
|
||||
|
||||
func supportsProvider(provider Provider, required map[string]struct{}) bool {
|
||||
if len(required) == 0 {
|
||||
return true
|
||||
}
|
||||
provided := normalizeCapabilities(provider.Capabilities)
|
||||
for capability := range required {
|
||||
if _, ok := provided[capability]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeCapabilities(values []string) map[string]struct{} {
|
||||
normalized := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
item := strings.TrimSpace(strings.ToLower(value))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
normalized[item] = struct{}{}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func ResultMap(result Result) map[string]any {
|
||||
response := map[string]any{
|
||||
"metadata": result.Metadata,
|
||||
}
|
||||
if result.Provider != nil {
|
||||
provider := *result.Provider
|
||||
response["providerId"] = provider.ID
|
||||
response["provider"] = map[string]any{
|
||||
"id": provider.ID,
|
||||
"name": provider.Name,
|
||||
"defaultArgs": slices.Clone(provider.DefaultArgs),
|
||||
"capabilities": slices.Clone(provider.Capabilities),
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(result.AgentID) != "" {
|
||||
response["agentId"] = strings.TrimSpace(result.AgentID)
|
||||
}
|
||||
return maps.Clone(response)
|
||||
}
|
||||
96
go/go_core/internal/dispatch/resolve_test.go
Normal file
96
go/go_core/internal/dispatch/resolve_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package dispatch
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolvePrefersRequestedProviderWhenCapabilitiesMatch(t *testing.T) {
|
||||
result := Resolve(Request{
|
||||
Providers: []Provider{
|
||||
{
|
||||
ID: "codex",
|
||||
Name: "Codex",
|
||||
Capabilities: []string{"chat", "gateway-bridge"},
|
||||
},
|
||||
{
|
||||
ID: "qwen",
|
||||
Name: "Qwen",
|
||||
Capabilities: []string{"chat"},
|
||||
},
|
||||
},
|
||||
PreferredProviderID: "codex",
|
||||
RequiredCapabilities: []string{"gateway-bridge"},
|
||||
})
|
||||
|
||||
if result.Provider == nil || result.Provider.ID != "codex" {
|
||||
t.Fatalf("expected codex provider, got %#v", result.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFallsBackDeterministicallyByID(t *testing.T) {
|
||||
result := Resolve(Request{
|
||||
Providers: []Provider{
|
||||
{
|
||||
ID: "qwen",
|
||||
Name: "Qwen",
|
||||
Capabilities: []string{"chat"},
|
||||
},
|
||||
{
|
||||
ID: "codex",
|
||||
Name: "Codex",
|
||||
Capabilities: []string{"chat"},
|
||||
},
|
||||
},
|
||||
RequiredCapabilities: []string{"chat"},
|
||||
})
|
||||
|
||||
if result.Provider == nil || result.Provider.ID != "codex" {
|
||||
t.Fatalf("expected deterministic codex fallback, got %#v", result.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBuildsGatewayDispatchMetadata(t *testing.T) {
|
||||
result := Resolve(Request{
|
||||
Providers: []Provider{
|
||||
{
|
||||
ID: "codex",
|
||||
Name: "Codex CLI",
|
||||
DefaultArgs: []string{"app-server"},
|
||||
Capabilities: []string{"chat", "gateway-bridge"},
|
||||
},
|
||||
},
|
||||
PreferredProviderID: "codex",
|
||||
RequiredCapabilities: []string{"gateway-bridge"},
|
||||
NodeState: &NodeState{
|
||||
SelectedAgentID: "main",
|
||||
GatewayConnected: true,
|
||||
ExecutionTarget: "local",
|
||||
RuntimeMode: "externalCli",
|
||||
BridgeEnabled: true,
|
||||
BridgeState: "registered",
|
||||
ResolvedCodexCLIPath: "/opt/homebrew/bin/codex",
|
||||
},
|
||||
NodeInfo: &NodeInfo{
|
||||
ID: "xworkmate-app",
|
||||
Name: "XWorkmate",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
})
|
||||
|
||||
if result.Provider == nil || result.Provider.ID != "codex" {
|
||||
t.Fatalf("expected codex provider, got %#v", result.Provider)
|
||||
}
|
||||
if result.AgentID != "main" {
|
||||
t.Fatalf("expected agent id main, got %q", result.AgentID)
|
||||
}
|
||||
dispatch, ok := result.Metadata["dispatch"].(map[string]any)
|
||||
if !ok || dispatch["mode"] != "cooperative" {
|
||||
t.Fatalf("expected cooperative dispatch, got %#v", result.Metadata["dispatch"])
|
||||
}
|
||||
bridge, ok := result.Metadata["bridge"].(map[string]any)
|
||||
if !ok || bridge["localTransport"] != "stdio-jsonrpc" {
|
||||
t.Fatalf("expected stdio-jsonrpc bridge transport, got %#v", result.Metadata["bridge"])
|
||||
}
|
||||
provider, ok := result.Metadata["provider"].(map[string]any)
|
||||
if !ok || provider["id"] != "codex" {
|
||||
t.Fatalf("expected provider metadata for codex, got %#v", result.Metadata["provider"])
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,7 @@ import '../runtime/assistant_artifacts.dart';
|
||||
import '../runtime/desktop_thread_artifact_service.dart';
|
||||
import '../runtime/go_agent_core_client.dart';
|
||||
import '../runtime/go_agent_core_desktop_transport.dart';
|
||||
import '../runtime/go_runtime_dispatch_desktop_client.dart';
|
||||
import '../runtime/mode_switcher.dart';
|
||||
import '../runtime/agent_registry.dart';
|
||||
import '../runtime/multi_agent_orchestrator.dart';
|
||||
@ -191,6 +192,12 @@ class AppController extends ChangeNotifier {
|
||||
arisBundleRepositoryInternal =
|
||||
arisBundleRepository ?? ArisBundleRepository();
|
||||
goCoreLocatorInternal = GoCoreLocator();
|
||||
runtimeCoordinatorInternal.attachDispatchResolver(
|
||||
GoRuntimeDispatchDesktopClient(
|
||||
acpClient: gatewayAcpClientInternal,
|
||||
goCoreLocator: goCoreLocatorInternal,
|
||||
),
|
||||
);
|
||||
goAgentCoreClientInternal =
|
||||
goAgentCoreClient ??
|
||||
GoAgentCoreDesktopTransport(
|
||||
|
||||
@ -296,6 +296,7 @@ Future<void> ensureCodexGatewayRegistrationRuntimeInternal(
|
||||
.buildGatewayDispatch(
|
||||
buildCodeAgentNodeStateRuntimeInternal(controller),
|
||||
);
|
||||
final resolvedDispatch = await dispatch;
|
||||
await controller.codeAgentBridgeRegistryInternal.register(
|
||||
agentType: 'code-agent-bridge',
|
||||
name: 'XWorkmate Codex Bridge',
|
||||
@ -316,7 +317,7 @@ Future<void> ensureCodexGatewayRegistrationRuntimeInternal(
|
||||
),
|
||||
],
|
||||
metadata: <String, dynamic>{
|
||||
...dispatch.metadata,
|
||||
...resolvedDispatch.metadata,
|
||||
'providerId': 'codex',
|
||||
'runtimeMode': controller.effectiveCodeAgentRuntimeMode.name,
|
||||
'gatewayMode': bridgeGatewayModeRuntimeInternal(controller).name,
|
||||
|
||||
@ -282,7 +282,9 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
final sessionKey = normalizedAssistantSessionKeyInternal(
|
||||
currentSessionKey,
|
||||
);
|
||||
final userText = message.trim().isEmpty ? 'See attached.' : message.trim();
|
||||
final userText = message.trim().isEmpty
|
||||
? 'See attached.'
|
||||
: message.trim();
|
||||
appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
@ -302,17 +304,17 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
recomputeTasksInternal();
|
||||
notifyIfActiveInternal();
|
||||
try {
|
||||
final dispatch = codeAgentNodeOrchestratorInternal.buildGatewayDispatch(
|
||||
buildCodeAgentNodeStateInternal(),
|
||||
);
|
||||
final dispatch = await codeAgentNodeOrchestratorInternal
|
||||
.buildGatewayDispatch(buildCodeAgentNodeStateInternal());
|
||||
final result = await goAgentCoreClientInternal.executeSession(
|
||||
GoAgentCoreSessionRequest(
|
||||
sessionId: sessionKey,
|
||||
threadId: sessionKey,
|
||||
target: assistantExecutionTargetForSession(sessionKey),
|
||||
prompt: message,
|
||||
workingDirectory:
|
||||
assistantWorkspacePathForSession(sessionKey).trim(),
|
||||
workingDirectory: assistantWorkspacePathForSession(
|
||||
sessionKey,
|
||||
).trim(),
|
||||
model: assistantModelForSession(sessionKey),
|
||||
thinking: thinking,
|
||||
selectedSkills: selectedSkillLabels,
|
||||
|
||||
@ -42,9 +42,41 @@ class CodeAgentNodeOrchestrator {
|
||||
|
||||
final RuntimeCoordinator _runtimeCoordinator;
|
||||
|
||||
CodeAgentGatewayDispatch buildGatewayDispatch(CodeAgentNodeState state) {
|
||||
Future<CodeAgentGatewayDispatch> buildGatewayDispatch(
|
||||
CodeAgentNodeState state,
|
||||
) async {
|
||||
final resolver = _runtimeCoordinator.dispatchResolver;
|
||||
if (resolver != null) {
|
||||
final resolution = await resolver.resolveGatewayDispatch(
|
||||
providers: _runtimeCoordinator.externalCodeAgents,
|
||||
preferredProviderId: state.preferredProviderId,
|
||||
requiredCapabilities: const <String>['gateway-bridge'],
|
||||
nodeState: <String, dynamic>{
|
||||
'selectedAgentId': state.selectedAgentId,
|
||||
'gatewayConnected': state.gatewayConnected,
|
||||
'executionTarget': state.executionTarget.promptValue,
|
||||
'runtimeMode': state.runtimeMode.name,
|
||||
'bridgeEnabled': state.bridgeEnabled,
|
||||
'bridgeState': state.bridgeState,
|
||||
'resolvedCodexCliPath': state.resolvedCodexCliPath?.trim() ?? '',
|
||||
'configuredCodexCliPath': state.configuredCodexCliPath.trim(),
|
||||
},
|
||||
nodeInfo: const <String, dynamic>{
|
||||
'id': 'xworkmate-app',
|
||||
'name': kSystemAppName,
|
||||
'version': kAppVersion,
|
||||
},
|
||||
);
|
||||
if (resolution.metadata.isNotEmpty) {
|
||||
return CodeAgentGatewayDispatch(
|
||||
agentId: resolution.agentId,
|
||||
metadata: resolution.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final provider = state.bridgeEnabled
|
||||
? _runtimeCoordinator.selectExternalCodeAgent(
|
||||
? await _runtimeCoordinator.selectExternalCodeAgent(
|
||||
preferredProviderId: state.preferredProviderId,
|
||||
requiredCapabilities: const <String>['gateway-bridge'],
|
||||
)
|
||||
|
||||
217
lib/runtime/go_runtime_dispatch_desktop_client.dart
Normal file
217
lib/runtime/go_runtime_dispatch_desktop_client.dart
Normal file
@ -0,0 +1,217 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'embedded_agent_launch_policy.dart';
|
||||
import 'gateway_acp_client.dart';
|
||||
import 'go_core.dart';
|
||||
import 'runtime_dispatch_resolver.dart';
|
||||
import 'runtime_external_code_agents.dart';
|
||||
|
||||
typedef GoRuntimeDispatchProcessStarter =
|
||||
Future<Process> Function(
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
Map<String, String>? environment,
|
||||
String? workingDirectory,
|
||||
});
|
||||
|
||||
class GoRuntimeDispatchDesktopClient implements RuntimeDispatchResolver {
|
||||
GoRuntimeDispatchDesktopClient({
|
||||
GatewayAcpClient? acpClient,
|
||||
GoCoreLocator? goCoreLocator,
|
||||
GoRuntimeDispatchProcessStarter? processStarter,
|
||||
}) : _acpClient = acpClient ?? GatewayAcpClient(endpointResolver: () => null),
|
||||
_goCoreLocator = goCoreLocator ?? GoCoreLocator(),
|
||||
_processStarter =
|
||||
processStarter ??
|
||||
((executable, arguments, {environment, workingDirectory}) {
|
||||
return Process.start(
|
||||
executable,
|
||||
arguments,
|
||||
environment: environment,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
});
|
||||
|
||||
final GatewayAcpClient _acpClient;
|
||||
final GoCoreLocator _goCoreLocator;
|
||||
final GoRuntimeDispatchProcessStarter _processStarter;
|
||||
|
||||
Process? _localProcess;
|
||||
Uri? _localEndpoint;
|
||||
Future<Uri?>? _localEndpointFuture;
|
||||
|
||||
@override
|
||||
Future<String?> selectProviderId({
|
||||
required List<ExternalCodeAgentProvider> providers,
|
||||
String preferredProviderId = '',
|
||||
Iterable<String> requiredCapabilities = const <String>[],
|
||||
}) async {
|
||||
final endpoint = await _ensureLocalEndpoint();
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
final response = await _acpClient.request(
|
||||
method: 'xworkmate.dispatch.resolve',
|
||||
params: <String, dynamic>{
|
||||
'preferredProviderId': preferredProviderId.trim(),
|
||||
'requiredCapabilities': requiredCapabilities
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false),
|
||||
'providers': providers.map(_providerToJson).toList(growable: false),
|
||||
},
|
||||
endpointOverride: endpoint,
|
||||
);
|
||||
final result = _castMap(response['result']);
|
||||
return result['providerId']?.toString().trim().isNotEmpty == true
|
||||
? result['providerId'].toString().trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
@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 endpoint = await _ensureLocalEndpoint();
|
||||
if (endpoint == null) {
|
||||
return const RuntimeDispatchResolution(metadata: <String, dynamic>{});
|
||||
}
|
||||
final response = await _acpClient.request(
|
||||
method: 'xworkmate.dispatch.resolve',
|
||||
params: <String, dynamic>{
|
||||
'preferredProviderId': preferredProviderId.trim(),
|
||||
'requiredCapabilities': requiredCapabilities
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false),
|
||||
'providers': providers.map(_providerToJson).toList(growable: false),
|
||||
'nodeState': nodeState,
|
||||
'nodeInfo': nodeInfo,
|
||||
},
|
||||
endpointOverride: endpoint,
|
||||
);
|
||||
final result = _castMap(response['result']);
|
||||
return RuntimeDispatchResolution(
|
||||
agentId: result['agentId']?.toString().trim().isNotEmpty == true
|
||||
? result['agentId'].toString().trim()
|
||||
: null,
|
||||
providerId: result['providerId']?.toString().trim().isNotEmpty == true
|
||||
? result['providerId'].toString().trim()
|
||||
: null,
|
||||
metadata: _castMap(result['metadata']),
|
||||
raw: result,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
final process = _localProcess;
|
||||
_localProcess = null;
|
||||
_localEndpoint = null;
|
||||
_localEndpointFuture = null;
|
||||
if (process != null) {
|
||||
try {
|
||||
process.kill();
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
await _acpClient.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _providerToJson(ExternalCodeAgentProvider provider) {
|
||||
return <String, dynamic>{
|
||||
'id': provider.id,
|
||||
'name': provider.name,
|
||||
'defaultArgs': provider.defaultArgs,
|
||||
'capabilities': provider.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Uri?> _ensureLocalEndpoint() async {
|
||||
if (_localEndpoint != null) {
|
||||
return _localEndpoint;
|
||||
}
|
||||
final inFlight = _localEndpointFuture;
|
||||
if (inFlight != null) {
|
||||
return inFlight;
|
||||
}
|
||||
final next = _startLocalProcess();
|
||||
_localEndpointFuture = next;
|
||||
try {
|
||||
_localEndpoint = await next;
|
||||
return _localEndpoint;
|
||||
} finally {
|
||||
_localEndpointFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uri?> _startLocalProcess() async {
|
||||
if (shouldBlockEmbeddedAgentLaunch(
|
||||
isAppleHost: Platform.isIOS || Platform.isMacOS,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
final launch = await _goCoreLocator.locate();
|
||||
if (launch == null) {
|
||||
return null;
|
||||
}
|
||||
final reservedSocket = await ServerSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
final port = reservedSocket.port;
|
||||
await reservedSocket.close();
|
||||
final listenAddress = '127.0.0.1:$port';
|
||||
final process = await _processStarter(
|
||||
launch.executable,
|
||||
<String>[...launch.arguments, 'serve', '--listen', listenAddress],
|
||||
environment: Platform.environment,
|
||||
workingDirectory: launch.workingDirectory,
|
||||
);
|
||||
_localProcess = process;
|
||||
unawaited(process.stdout.drain<void>());
|
||||
unawaited(process.stderr.drain<void>());
|
||||
final endpoint = Uri(scheme: 'http', host: '127.0.0.1', port: port);
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 8));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (_localProcess != process) {
|
||||
break;
|
||||
}
|
||||
final exitCode = await process.exitCode.timeout(
|
||||
const Duration(milliseconds: 20),
|
||||
onTimeout: () => -1,
|
||||
);
|
||||
if (exitCode != -1) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await _acpClient.request(
|
||||
method: 'acp.capabilities',
|
||||
params: const <String, dynamic>{},
|
||||
endpointOverride: endpoint,
|
||||
);
|
||||
return endpoint;
|
||||
} catch (_) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
}
|
||||
}
|
||||
await dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _castMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.cast<String, dynamic>();
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
@ -7,43 +7,15 @@ import 'codex_config_bridge.dart';
|
||||
import 'codex_runtime.dart';
|
||||
import 'gateway_runtime.dart';
|
||||
import 'mode_switcher.dart';
|
||||
import 'runtime_dispatch_resolver.dart';
|
||||
import 'runtime_external_code_agents.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
export 'runtime_external_code_agents.dart';
|
||||
|
||||
/// Coordination state for the runtime.
|
||||
enum CoordinatorState { disconnected, connecting, connected, ready, error }
|
||||
|
||||
/// Descriptor for additional external Code Agent CLI integrations.
|
||||
enum ExternalAgentTransport { subprocess, websocketJsonRpc }
|
||||
|
||||
extension ExternalAgentTransportCopy on ExternalAgentTransport {
|
||||
static ExternalAgentTransport fromJsonValue(String? value) {
|
||||
return ExternalAgentTransport.values.firstWhere(
|
||||
(item) => item.name == value,
|
||||
orElse: () => ExternalAgentTransport.subprocess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalCodeAgentProvider {
|
||||
const ExternalCodeAgentProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.command,
|
||||
this.transport = ExternalAgentTransport.subprocess,
|
||||
this.endpoint = '',
|
||||
this.defaultArgs = const <String>[],
|
||||
this.capabilities = const <String>[],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String command;
|
||||
final ExternalAgentTransport transport;
|
||||
final String endpoint;
|
||||
final List<String> defaultArgs;
|
||||
final List<String> capabilities;
|
||||
}
|
||||
|
||||
/// Unified runtime coordinator for managing Gateway and Code Agent runtime.
|
||||
///
|
||||
/// This class coordinates:
|
||||
@ -59,6 +31,7 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
|
||||
final Map<String, ExternalCodeAgentProvider> _externalCodeAgents =
|
||||
<String, ExternalCodeAgentProvider>{};
|
||||
RuntimeDispatchResolver? _dispatchResolver;
|
||||
|
||||
CoordinatorState _state = CoordinatorState.disconnected;
|
||||
String? _lastError;
|
||||
@ -95,8 +68,10 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
required this.codex,
|
||||
CodexConfigBridge? configBridge,
|
||||
ModeSwitcher? modeSwitcher,
|
||||
RuntimeDispatchResolver? dispatchResolver,
|
||||
}) : configBridge = configBridge ?? CodexConfigBridge(),
|
||||
modeSwitcher = modeSwitcher ?? ModeSwitcher(gateway);
|
||||
modeSwitcher = modeSwitcher ?? ModeSwitcher(gateway),
|
||||
_dispatchResolver = dispatchResolver;
|
||||
|
||||
/// Register an external Code Agent CLI provider descriptor.
|
||||
///
|
||||
@ -166,7 +141,38 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
/// Scheduling policy is intentionally simple for phase 1:
|
||||
/// - honor preferred provider when it satisfies capability requirements
|
||||
/// - otherwise pick the first discovered provider in deterministic id order
|
||||
ExternalCodeAgentProvider? selectExternalCodeAgent({
|
||||
Future<ExternalCodeAgentProvider?> selectExternalCodeAgent({
|
||||
String? preferredProviderId,
|
||||
Iterable<String> requiredCapabilities = const <String>[],
|
||||
}) async {
|
||||
final available = externalCodeAgents;
|
||||
final resolver = _dispatchResolver;
|
||||
if (resolver != null && available.isNotEmpty) {
|
||||
final selectedProviderId = await resolver.selectProviderId(
|
||||
providers: available,
|
||||
preferredProviderId: preferredProviderId?.trim() ?? '',
|
||||
requiredCapabilities: requiredCapabilities,
|
||||
);
|
||||
if (selectedProviderId != null) {
|
||||
final selected = _externalCodeAgents[selectedProviderId];
|
||||
if (selected != null) {
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
return _selectExternalCodeAgentLocally(
|
||||
preferredProviderId: preferredProviderId,
|
||||
requiredCapabilities: requiredCapabilities,
|
||||
);
|
||||
}
|
||||
|
||||
RuntimeDispatchResolver? get dispatchResolver => _dispatchResolver;
|
||||
|
||||
void attachDispatchResolver(RuntimeDispatchResolver resolver) {
|
||||
_dispatchResolver = resolver;
|
||||
}
|
||||
|
||||
ExternalCodeAgentProvider? _selectExternalCodeAgentLocally({
|
||||
String? preferredProviderId,
|
||||
Iterable<String> requiredCapabilities = const <String>[],
|
||||
}) {
|
||||
@ -393,6 +399,11 @@ class RuntimeCoordinator extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final resolver = _dispatchResolver;
|
||||
_dispatchResolver = null;
|
||||
if (resolver != null) {
|
||||
unawaited(resolver.dispose());
|
||||
}
|
||||
shutdown();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
33
lib/runtime/runtime_dispatch_resolver.dart
Normal file
33
lib/runtime/runtime_dispatch_resolver.dart
Normal file
@ -0,0 +1,33 @@
|
||||
import 'runtime_external_code_agents.dart';
|
||||
|
||||
class RuntimeDispatchResolution {
|
||||
const RuntimeDispatchResolution({
|
||||
required this.metadata,
|
||||
this.agentId,
|
||||
this.providerId,
|
||||
this.raw = const <String, dynamic>{},
|
||||
});
|
||||
|
||||
final String? agentId;
|
||||
final String? providerId;
|
||||
final Map<String, dynamic> metadata;
|
||||
final Map<String, dynamic> raw;
|
||||
}
|
||||
|
||||
abstract class RuntimeDispatchResolver {
|
||||
Future<String?> selectProviderId({
|
||||
required List<ExternalCodeAgentProvider> providers,
|
||||
String preferredProviderId = '',
|
||||
Iterable<String> requiredCapabilities = const <String>[],
|
||||
});
|
||||
|
||||
Future<RuntimeDispatchResolution> resolveGatewayDispatch({
|
||||
required List<ExternalCodeAgentProvider> providers,
|
||||
required String preferredProviderId,
|
||||
required Iterable<String> requiredCapabilities,
|
||||
required Map<String, dynamic> nodeState,
|
||||
required Map<String, dynamic> nodeInfo,
|
||||
});
|
||||
|
||||
Future<void> dispose();
|
||||
}
|
||||
30
lib/runtime/runtime_external_code_agents.dart
Normal file
30
lib/runtime/runtime_external_code_agents.dart
Normal file
@ -0,0 +1,30 @@
|
||||
enum ExternalAgentTransport { subprocess, websocketJsonRpc }
|
||||
|
||||
extension ExternalAgentTransportCopy on ExternalAgentTransport {
|
||||
static ExternalAgentTransport fromJsonValue(String? value) {
|
||||
return ExternalAgentTransport.values.firstWhere(
|
||||
(item) => item.name == value,
|
||||
orElse: () => ExternalAgentTransport.subprocess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalCodeAgentProvider {
|
||||
const ExternalCodeAgentProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.command,
|
||||
this.transport = ExternalAgentTransport.subprocess,
|
||||
this.endpoint = '',
|
||||
this.defaultArgs = const <String>[],
|
||||
this.capabilities = const <String>[],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String command;
|
||||
final ExternalAgentTransport transport;
|
||||
final String endpoint;
|
||||
final List<String> defaultArgs;
|
||||
final List<String> capabilities;
|
||||
}
|
||||
@ -6,6 +6,7 @@ import 'package:xworkmate/runtime/code_agent_node_orchestrator.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/runtime_dispatch_resolver.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
@ -43,6 +44,35 @@ class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
|
||||
class _FakeCodexRuntime extends CodexRuntime {}
|
||||
|
||||
class _FakeDispatchResolver implements RuntimeDispatchResolver {
|
||||
_FakeDispatchResolver(this.resolution);
|
||||
|
||||
final RuntimeDispatchResolution resolution;
|
||||
|
||||
@override
|
||||
Future<String?> selectProviderId({
|
||||
required List<ExternalCodeAgentProvider> providers,
|
||||
String preferredProviderId = '',
|
||||
Iterable<String> requiredCapabilities = const <String>[],
|
||||
}) async {
|
||||
return resolution.providerId;
|
||||
}
|
||||
|
||||
@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 {
|
||||
return resolution;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('CodeAgentNodeOrchestrator', () {
|
||||
late RuntimeCoordinator coordinator;
|
||||
@ -56,7 +86,7 @@ void main() {
|
||||
orchestrator = CodeAgentNodeOrchestrator(coordinator);
|
||||
});
|
||||
|
||||
test('builds cooperative node metadata for an external provider', () {
|
||||
test('builds cooperative node metadata for an external provider', () async {
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'codex',
|
||||
@ -67,7 +97,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final dispatch = orchestrator.buildGatewayDispatch(
|
||||
final dispatch = await orchestrator.buildGatewayDispatch(
|
||||
const CodeAgentNodeState(
|
||||
selectedAgentId: 'main',
|
||||
gatewayConnected: true,
|
||||
@ -102,7 +132,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('omits provider metadata when bridge is disabled', () {
|
||||
test('omits provider metadata when bridge is disabled', () async {
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'codex',
|
||||
@ -112,7 +142,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final dispatch = orchestrator.buildGatewayDispatch(
|
||||
final dispatch = await orchestrator.buildGatewayDispatch(
|
||||
const CodeAgentNodeState(
|
||||
selectedAgentId: '',
|
||||
gatewayConnected: true,
|
||||
@ -131,5 +161,42 @@ void main() {
|
||||
);
|
||||
expect(dispatch.metadata.containsKey('provider'), isFalse);
|
||||
});
|
||||
|
||||
test('uses dispatch resolver metadata when available', () async {
|
||||
coordinator.attachDispatchResolver(
|
||||
_FakeDispatchResolver(
|
||||
const RuntimeDispatchResolution(
|
||||
agentId: 'main',
|
||||
providerId: 'codex',
|
||||
metadata: <String, dynamic>{
|
||||
'dispatch': <String, dynamic>{
|
||||
'mode': 'cooperative',
|
||||
'executionTarget': 'local',
|
||||
},
|
||||
'provider': <String, dynamic>{'id': 'codex'},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final dispatch = await orchestrator.buildGatewayDispatch(
|
||||
const CodeAgentNodeState(
|
||||
selectedAgentId: 'main',
|
||||
gatewayConnected: true,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
runtimeMode: CodeAgentRuntimeMode.externalCli,
|
||||
bridgeEnabled: true,
|
||||
bridgeState: 'registered',
|
||||
preferredProviderId: 'codex',
|
||||
),
|
||||
);
|
||||
|
||||
expect(dispatch.agentId, 'main');
|
||||
expect(
|
||||
dispatch.metadata['dispatch'],
|
||||
containsPair('mode', 'cooperative'),
|
||||
);
|
||||
expect(dispatch.metadata['provider'], containsPair('id', 'codex'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -221,7 +221,7 @@ void main() {
|
||||
|
||||
test(
|
||||
'registerExternalCodeAgent supports capability-filtered discovery',
|
||||
() {
|
||||
() async {
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'opencode',
|
||||
@ -248,12 +248,10 @@ void main() {
|
||||
containsAll(<String>['gemini', 'opencode']),
|
||||
);
|
||||
expect(
|
||||
coordinator
|
||||
.selectExternalCodeAgent(
|
||||
preferredProviderId: 'opencode',
|
||||
requiredCapabilities: const <String>['review'],
|
||||
)
|
||||
?.id,
|
||||
(await coordinator.selectExternalCodeAgent(
|
||||
preferredProviderId: 'opencode',
|
||||
requiredCapabilities: const <String>['review'],
|
||||
))?.id,
|
||||
equals('opencode'),
|
||||
);
|
||||
},
|
||||
|
||||
@ -8,6 +8,7 @@ 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/mode_switcher.dart';
|
||||
import 'package:xworkmate/runtime/runtime_dispatch_resolver.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
@ -155,6 +156,38 @@ class _FakeModeSwitcher extends ModeSwitcher {
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeDispatchResolver implements RuntimeDispatchResolver {
|
||||
_FakeDispatchResolver({this.selectedProviderId});
|
||||
|
||||
final String? selectedProviderId;
|
||||
|
||||
int selectCalls = 0;
|
||||
|
||||
@override
|
||||
Future<String?> selectProviderId({
|
||||
required List<ExternalCodeAgentProvider> providers,
|
||||
String preferredProviderId = '',
|
||||
Iterable<String> requiredCapabilities = const <String>[],
|
||||
}) async {
|
||||
selectCalls += 1;
|
||||
return selectedProviderId;
|
||||
}
|
||||
|
||||
@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 {
|
||||
return const RuntimeDispatchResolution(metadata: <String, dynamic>{});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('RuntimeCoordinator runtime modes', () {
|
||||
late _FakeGatewayRuntime gateway;
|
||||
@ -316,7 +349,7 @@ void main() {
|
||||
|
||||
test(
|
||||
'selects provider by preferred id then falls back deterministically',
|
||||
() {
|
||||
() async {
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'codex',
|
||||
@ -334,13 +367,13 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final preferred = coordinator.selectExternalCodeAgent(
|
||||
final preferred = await coordinator.selectExternalCodeAgent(
|
||||
preferredProviderId: 'qwen-cli',
|
||||
requiredCapabilities: const <String>['chat'],
|
||||
);
|
||||
expect(preferred?.id, 'qwen-cli');
|
||||
|
||||
final fallback = coordinator.selectExternalCodeAgent(
|
||||
final fallback = await coordinator.selectExternalCodeAgent(
|
||||
preferredProviderId: 'qwen-cli',
|
||||
requiredCapabilities: const <String>['code-edit'],
|
||||
);
|
||||
@ -348,20 +381,55 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('returns null when no provider satisfies required capabilities', () {
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'qwen-cli',
|
||||
name: 'Qwen CLI',
|
||||
command: 'qwen',
|
||||
capabilities: <String>['chat'],
|
||||
),
|
||||
);
|
||||
test(
|
||||
'returns null when no provider satisfies required capabilities',
|
||||
() async {
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'qwen-cli',
|
||||
name: 'Qwen CLI',
|
||||
command: 'qwen',
|
||||
capabilities: <String>['chat'],
|
||||
),
|
||||
);
|
||||
|
||||
final selected = coordinator.selectExternalCodeAgent(
|
||||
requiredCapabilities: const <String>['memory-sync'],
|
||||
);
|
||||
expect(selected, isNull);
|
||||
});
|
||||
final selected = await coordinator.selectExternalCodeAgent(
|
||||
requiredCapabilities: const <String>['memory-sync'],
|
||||
);
|
||||
expect(selected, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'uses dispatch resolver when attached for provider selection',
|
||||
() async {
|
||||
final resolver = _FakeDispatchResolver(selectedProviderId: 'qwen-cli');
|
||||
coordinator.attachDispatchResolver(resolver);
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'codex',
|
||||
name: 'Codex CLI',
|
||||
command: 'codex',
|
||||
capabilities: <String>['chat', 'gateway-bridge'],
|
||||
),
|
||||
);
|
||||
coordinator.registerExternalCodeAgent(
|
||||
const ExternalCodeAgentProvider(
|
||||
id: 'qwen-cli',
|
||||
name: 'Qwen CLI',
|
||||
command: 'qwen',
|
||||
capabilities: <String>['chat', 'gateway-bridge'],
|
||||
),
|
||||
);
|
||||
|
||||
final selected = await coordinator.selectExternalCodeAgent(
|
||||
preferredProviderId: 'codex',
|
||||
requiredCapabilities: const <String>['gateway-bridge'],
|
||||
);
|
||||
|
||||
expect(resolver.selectCalls, 1);
|
||||
expect(selected?.id, 'qwen-cli');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user