using System.Text.Json; using System.Text.Json.Nodes; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class OpenClawRunGatewayTests { [Fact] public async Task Start_UsesOfficialChatSendShape_AndForwardsInvocationContext() { var connector = new CapturingConnector( ["chat.send"], new JsonObject { ["runId"] = "oc-run-42", ["status"] = "started" }); var gateway = new OpenClawRunGateway( connector, new StubOpenClawWriteGate()); var invocation = Invocation(); var result = await gateway.StartAsync(Run(), invocation); Assert.True(result.Ok); Assert.Equal("oc-run-42", result.OpenClawRunId); var call = Assert.Single(connector.Calls); Assert.Equal("chat.send", call.Method); Assert.Equal("agent:iris:main", call.Parameters?["sessionKey"]?.GetValue()); Assert.Equal("iris", call.Parameters?["agentId"]?.GetValue()); Assert.Equal("Do the work", call.Parameters?["message"]?.GetValue()); Assert.False(call.Parameters?["deliver"]?.GetValue()); Assert.Equal("run-idempotency", call.Parameters?["idempotencyKey"]?.GetValue()); Assert.Equal("run-idempotency", call.Invocation?.IdempotencyKey); Assert.Equal("run-correlation", call.Invocation?.CorrelationId); Assert.Equal("owner-subject", call.Invocation?.Actor); Assert.True(call.Invocation?.IncludeIdempotencyParameter); } [Fact] public async Task Start_DoesNotInvokeGateway_WhenWriteBoundaryIsBlocked() { var connector = new CapturingConnector( ["chat.send"], new JsonObject { ["runId"] = "must-not-be-used", ["status"] = "started" }); var gate = new StubOpenClawWriteGate( OpenClawWriteGateDecision.Block( "endpoint_trust_mismatch", "The active Gateway endpoint no longer matches the adopted profile.", "Verify and adopt the connection again.")); var gateway = new OpenClawRunGateway(connector, gate); var result = await gateway.StartAsync(Run(), Invocation()); Assert.False(result.Ok); Assert.Equal(OpenClawRunStates.Blocked, result.State); Assert.Empty(connector.Calls); Assert.Equal(("chat.send", "operator.write"), Assert.Single(gate.Evaluations)); } [Fact] public async Task Stop_RefusesSessionWideAbortWithoutExactOpenClawRunId() { var connector = new CapturingConnector(["chat.abort"], new JsonObject()); var gateway = new OpenClawRunGateway( connector, new StubOpenClawWriteGate()); var run = Run(); run.OpenClawRunId = null; var result = await gateway.StopAsync(run, Invocation()); Assert.False(result.Ok); Assert.Equal(OpenClawRunStates.Blocked, result.State); Assert.Empty(connector.Calls); } [Fact] public async Task Stop_UsesExactChatAbort_WithoutUnsupportedWireFields() { var connector = new CapturingConnector(["chat.abort"], new JsonObject { ["ok"] = true }); var gateway = new OpenClawRunGateway( connector, new StubOpenClawWriteGate()); var result = await gateway.StopAsync(Run(), Invocation()); Assert.True(result.Ok); var call = Assert.Single(connector.Calls); Assert.Equal("chat.abort", call.Method); Assert.Equal("oc-run-1", call.Parameters?["runId"]?.GetValue()); Assert.Null(call.Parameters?["idempotencyKey"]); Assert.False(call.Invocation?.IncludeIdempotencyParameter); } [Fact] public async Task History_IsDisplayNormalizedGatewayData_WithSecretsRedacted() { var connector = new CapturingConnector( ["chat.history"], new JsonObject { ["messages"] = new JsonArray(new JsonObject { ["text"] = "done" }), ["accessToken"] = "secret-value", ["inputTokens"] = 12 }); var gateway = new OpenClawRunGateway( connector, new StubOpenClawWriteGate()); var result = await gateway.GetHistoryAsync(Run(), 50); Assert.True(result.Ok); Assert.Equal("[redacted]", result.Data?["accessToken"]?.GetValue()); Assert.Equal(12, result.Data?["inputTokens"]?.GetValue()); var call = Assert.Single(connector.Calls); Assert.Equal("chat.history", call.Method); Assert.Equal(50, call.Parameters?["limit"]?.GetValue()); Assert.Null(call.Invocation); } private static OpenClawRun Run() => new() { Title = "Run", Prompt = "Do the work", AgentId = "iris", SessionKey = "agent:iris:main", Status = OpenClawRunStates.Running, OpenClawRunId = "oc-run-1", StartIdempotencyKey = "run-idempotency", CorrelationId = "run-correlation", Actor = "owner-subject" }; private static OpenClawInvocationMetadata Invocation() => new( "run-idempotency", "run-correlation", "owner-subject", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); private sealed class CapturingConnector : IGatewayConnector { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly JsonNode? _response; public CapturingConnector(IEnumerable methods, JsonNode? response) { AdvertisedMethods = new HashSet(methods, StringComparer.Ordinal); _response = response; } public List Calls { get; } = []; public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected; public string? GatewayVersion => "2026.7.0"; public string? RequiredVersion => "2026.7.0"; public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow; public int ReconnectAttempts => 0; public string? StatusMessage => "Connected"; public string? DeviceId => "nexus-tests"; public bool DeviceTokenConfigured => true; public bool PairingRequired => false; public string? PairingRequestId => null; public int? ProtocolVersion => 4; public IReadOnlySet AdvertisedMethods { get; } public IReadOnlySet AdvertisedEvents { get; } = new HashSet(); public IReadOnlySet GrantedScopes { get; } = new HashSet(); public DateTimeOffset? LastEventAt => null; public bool Supports(string method) => AdvertisedMethods.Contains(method); public Task InvokeAsync( string method, object? parameters = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var node = parameters switch { null => null, JsonNode jsonNode => jsonNode.DeepClone(), _ => JsonSerializer.SerializeToNode(parameters, JsonOptions) }; Calls.Add(new InvocationCall(method, node, invocationContext)); return Task.FromResult(_response?.DeepClone()); } public IReadOnlyList GetRecentEvents(int limit = 100) => []; } private sealed record InvocationCall( string Method, JsonNode? Parameters, OpenClawInvocationContext? Invocation); }