Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/OpenClaw.Shared/WindowsNodeClient.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +18,7 @@ public class WindowsNodeClient : WebSocketClientBase

// Node capabilities registry
private readonly List<INodeCapability> _capabilities = new();
private FrozenDictionary<string, INodeCapability> _commandMap = FrozenDictionary<string, INodeCapability>.Empty;
private readonly NodeRegistration _registration;

// Connection state
Expand Down Expand Up @@ -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)");
}

/// <summary>
/// Builds a FrozenDictionary mapping each command name to the capability that owns it.
/// First-registered capability wins on collision (matching the former FirstOrDefault semantics).
/// </summary>
private FrozenDictionary<string, INodeCapability> BuildCommandMap()
{
var map = new Dictionary<string, INodeCapability>(StringComparer.OrdinalIgnoreCase);
foreach (var cap in _capabilities)
foreach (var cmd in cap.Commands)
map.TryAdd(cmd, cap);
return map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}

/// <summary>
/// Set a permission for the node
/// </summary>
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)
{
Expand Down
190 changes: 190 additions & 0 deletions tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Commands => _commands;
public bool CanHandle(string command) => Array.IndexOf(_commands, command) >= 0;

public Task<NodeInvokeResponse> 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;
}
}
Loading