diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 693dbf1..9e7fdfd 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -1,6 +1,6 @@ using System; +using System.Collections.Frozen; using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -18,6 +18,7 @@ public class WindowsNodeClient : WebSocketClientBase // Node capabilities registry private readonly List _capabilities = new(); + private FrozenDictionary _commandMap = FrozenDictionary.Empty; private readonly NodeRegistration _registration; // Connection state @@ -100,9 +101,26 @@ public void RegisterCapability(INodeCapability capability) } } + // Rebuild the O(1) command dispatch map so node.invoke lookups stay fast + // regardless of how many capabilities or commands are registered. + _commandMap = BuildCommandMap(); + _logger.Info($"Registered capability: {capability.Category} ({capability.Commands.Count} commands)"); } + /// + /// Builds a FrozenDictionary mapping each command name to the capability that owns it. + /// First-registered capability wins on collision (matching the former FirstOrDefault semantics). + /// + private FrozenDictionary BuildCommandMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cap in _capabilities) + foreach (var cmd in cap.Commands) + map.TryAdd(cmd, cap); + return map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + /// /// Set a permission for the node /// @@ -351,7 +369,7 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root) }; // Find capability that can handle this command - var capability = _capabilities.FirstOrDefault(c => c.CanHandle(command)); + var capability = _commandMap.GetValueOrDefault(command); if (capability == null) { @@ -762,7 +780,7 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId) }; // Find capability that can handle this command - var capability = _capabilities.FirstOrDefault(c => c.CanHandle(command)); + var capability = _commandMap.GetValueOrDefault(command); if (capability == null) { diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index b2ed5b5..77ac93c 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -1021,4 +1021,194 @@ private static async Task InvokeHandleEventAsync(WindowsNodeClient client, strin Assert.NotNull(task); await task!; } + + // ─── Command dispatch map tests ──────────────────────────────────────────── + + private sealed class MockCapability : INodeCapability + { + private readonly string _category; + private readonly string[] _commands; + public int ExecuteCount { get; private set; } + public string? LastCommand { get; private set; } + + public MockCapability(string category, params string[] commands) + { + _category = category; + _commands = commands; + } + + public string Category => _category; + public IReadOnlyList Commands => _commands; + public bool CanHandle(string command) => Array.IndexOf(_commands, command) >= 0; + + public Task ExecuteAsync(NodeInvokeRequest request) + { + ExecuteCount++; + LastCommand = request.Command; + return Task.FromResult(new NodeInvokeResponse { Id = request.Id, Ok = true, Payload = new { dispatched = true } }); + } + } + + [Fact] + public async Task CommandDispatch_RoutesToRegisteredCapability() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var cap = new MockCapability("mock", "mock.ping", "mock.echo"); + client.RegisterCapability(cap); + + var json = """ + { + "type": "req", + "id": "req-1", + "method": "node.invoke", + "params": { + "requestId": "inv-1", + "command": "mock.ping", + "args": {} + } + } + """; + + await InvokeProcessMessageAsync(client, json); + + Assert.Equal(1, cap.ExecuteCount); + Assert.Equal("mock.ping", cap.LastCommand); + } + finally + { + if (Directory.Exists(dataPath)) + Directory.Delete(dataPath, true); + } + } + + [Fact] + public async Task CommandDispatch_UnknownCommand_DoesNotInvokeAnyCapability() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var cap = new MockCapability("mock", "mock.ping"); + client.RegisterCapability(cap); + + var json = """ + { + "type": "req", + "id": "req-2", + "method": "node.invoke", + "params": { + "requestId": "inv-2", + "command": "unknown.command", + "args": {} + } + } + """; + + await InvokeProcessMessageAsync(client, json); + + Assert.Equal(0, cap.ExecuteCount); + } + finally + { + if (Directory.Exists(dataPath)) + Directory.Delete(dataPath, true); + } + } + + [Fact] + public async Task CommandDispatch_FirstRegisteredCapabilityWins_ForDuplicateCommand() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var first = new MockCapability("cat-a", "shared.command"); + var second = new MockCapability("cat-b", "shared.command"); + client.RegisterCapability(first); + client.RegisterCapability(second); + + var json = """ + { + "type": "req", + "id": "req-3", + "method": "node.invoke", + "params": { + "requestId": "inv-3", + "command": "shared.command", + "args": {} + } + } + """; + + await InvokeProcessMessageAsync(client, json); + + Assert.Equal(1, first.ExecuteCount); + Assert.Equal(0, second.ExecuteCount); + } + finally + { + if (Directory.Exists(dataPath)) + Directory.Delete(dataPath, true); + } + } + + [Fact] + public async Task CommandDispatch_EventPath_RoutesToRegisteredCapability() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath); + + var cap = new MockCapability("mock", "mock.ping"); + client.RegisterCapability(cap); + + // Use "type": "event" wire format (HandleNodeInvokeEventAsync path) + var json = """ + { + "type": "event", + "event": "node.invoke.request", + "payload": { + "requestId": "inv-evt-1", + "command": "mock.ping", + "args": {} + } + } + """; + + await InvokeProcessMessageAsync(client, json); + + Assert.Equal(1, cap.ExecuteCount); + Assert.Equal("mock.ping", cap.LastCommand); + } + finally + { + if (Directory.Exists(dataPath)) + Directory.Delete(dataPath, true); + } + } + + private static async Task InvokeProcessMessageAsync(WindowsNodeClient client, string json) + { + var processMethod = typeof(WindowsNodeClient).GetMethod( + "ProcessMessageAsync", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(processMethod); + var task = (Task)processMethod!.Invoke(client, [json])!; + await task; + } }