using System.Security.Claims; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Nexus.Api.Controllers; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Repositories; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class OpenClawRunServiceTests { [Fact] public async Task ListCursor_DoesNotSkipOrDuplicateRunsWithTheSameCreatedAt() { await using var harness = await RunHarness.CreateAsync(); var timestamp = new DateTimeOffset( 2026, 7, 31, 12, 0, 0, TimeSpan.Zero); var runs = Enumerable.Range(0, 3) .Select(index => new OpenClawRun { Id = Guid.NewGuid(), Title = $"Run {index}", Prompt = $"Inspect {index}", AgentId = "iris", SessionKey = "agent:iris:main", Status = OpenClawRunStates.Completed, StartIdempotencyKey = $"list-cursor-{index}", CorrelationId = $"correlation-{index}", Actor = "owner", CreatedAt = timestamp, UpdatedAt = timestamp }) .ToList(); harness.Db.OpenClawRuns.AddRange(runs); await harness.Db.SaveChangesAsync(); harness.Db.ChangeTracker.Clear(); var first = await harness.Service.GetAsync(new OpenClawRunQuery(Limit: 2)); var second = await harness.Service.GetAsync( new OpenClawRunQuery(Limit: 2, Cursor: first.NextCursor)); Assert.Equal(2, first.Items.Count); Assert.NotNull(first.NextCursor); Assert.Matches("^[A-Za-z0-9_-]+$", first.NextCursor); Assert.True( OpenClawRunCursorCodec.TryDecode( first.NextCursor, out var cursorPosition)); Assert.Equal(first.Items[^1].CreatedAt, cursorPosition.CreatedAt); Assert.Equal(first.Items[^1].Id, cursorPosition.Id); Assert.Single(second.Items); Assert.Null(second.NextCursor); Assert.Equal( 3, first.Items .Concat(second.Items) .Select(item => item.Id) .Distinct() .Count()); } [Fact] public async Task ListCursor_AcceptsLegacyUtcTicksCursor() { await using var harness = await RunHarness.CreateAsync(); var boundary = new DateTimeOffset( 2026, 7, 31, 12, 0, 0, TimeSpan.Zero); harness.Db.OpenClawRuns.AddRange( CreateListedRun("newer", boundary.AddMinutes(1)), CreateListedRun("older", boundary.AddMinutes(-1))); await harness.Db.SaveChangesAsync(); harness.Db.ChangeTracker.Clear(); var page = await harness.Service.GetAsync( new OpenClawRunQuery( Limit: 10, Cursor: boundary.UtcTicks.ToString( System.Globalization.CultureInfo.InvariantCulture))); var run = Assert.Single(page.Items); Assert.Equal("older", run.Title); } [Fact] public async Task Start_PersistsCorrelations_AndReplaysIdempotently() { await using var harness = await RunHarness.CreateAsync(); var project = new Project { Name = "Nexus" }; var task = new WorkTask { Title = "Connect OpenClaw", ProjectId = project.Id }; harness.Db.Projects.Add(project); harness.Db.Tasks.Add(task); await harness.Db.SaveChangesAsync(); harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Running, "started", "oc-run-1")); var request = new StartOpenClawRunRequest( "Inspect the deployment", "iris", "agent:iris:main", "Deployment inspection", task.Id, project.Id); var invocation = Invocation("start-key", "corr-1", "owner-1"); var first = await harness.Service.StartAsync(request, invocation); var replay = await harness.Service.StartAsync(request, invocation); Assert.True(first.Ok); Assert.Equal("oc-run-1", first.Run.OpenClawRunId); Assert.Equal(task.Id, first.Run.TaskId); Assert.Equal(project.Id, first.Run.ProjectId); Assert.Equal("corr-1", first.Run.CorrelationId); Assert.Equal("owner-1", first.Run.Actor); Assert.Equal("idempotent_replay", replay.State); Assert.Single(harness.Gateway.StartCalls); var persisted = await harness.Db.OpenClawRuns.SingleAsync(); Assert.Equal(OpenClawRunStates.Running, persisted.Status); Assert.Equal(2, await harness.Db.OpenClawRunHistory.CountAsync()); Assert.All( await harness.Db.OpenClawRunHistory.ToListAsync(), item => Assert.Equal("corr-1", item.CorrelationId)); } [Fact] public async Task Stop_UsesExactRunAndDoesNotRepeatGatewayMutation() { await using var harness = await RunHarness.CreateAsync(); harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Running, "started", "oc-run-stop")); var started = await harness.Service.StartAsync( Request(), Invocation("start-stop", "corr-stop", "owner")); harness.Gateway.StopResults.Enqueue(new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Stopped, "stopped", "oc-run-stop")); var stopInvocation = Invocation("stop-key", "corr-stop-2", "owner"); var stopped = await harness.Service.StopAsync( started.Run.Id, "operator request", stopInvocation); var replay = await harness.Service.StopAsync( started.Run.Id, "operator request", stopInvocation); Assert.NotNull(stopped); Assert.True(stopped!.Ok); Assert.Equal(OpenClawRunStates.Stopped, stopped.Run.Status); Assert.Equal("idempotent_replay", replay?.State); Assert.Single(harness.Gateway.StopCalls); Assert.Contains( await harness.Db.OpenClawRunHistory.ToListAsync(), item => item.Action == "stop_requested" && item.IdempotencyKey == "stop-key" && item.Actor == "owner"); } [Fact] public async Task Retry_CreatesCorrelatedChild_WhileResumeIsTruthfullyUnsupported() { await using var harness = await RunHarness.CreateAsync(); harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult( true, false, OpenClawRunStates.Failed, "provider failed")); var source = await harness.Service.StartAsync( Request(), Invocation("start-failed", "corr-source", "owner")); harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Running, "retry started", "oc-run-retry")); var resume = await harness.Service.ResumeAsync( source.Run.Id, null, Invocation("resume-key", "corr-resume", "owner")); var retry = await harness.Service.RetryAsync( source.Run.Id, "retry after provider recovery", Invocation("retry-key", "corr-retry", "owner")); Assert.NotNull(resume); Assert.False(resume!.Ok); Assert.Equal(OpenClawRunStates.Unsupported, resume.State); Assert.False(resume.Run.CanResume); Assert.NotNull(retry?.ResultRun); Assert.True(retry!.Ok); Assert.Equal(source.Run.Id, retry.ResultRun!.RetriedFromRunId); Assert.Equal(source.Run.SessionKey, retry.ResultRun.SessionKey); Assert.Equal("corr-retry", retry.ResultRun.CorrelationId); Assert.Equal(2, harness.Gateway.StartCalls.Count); } [Fact] public async Task Reconcile_ProjectsTerminalState_AndPerRunSequenceGap() { await using var harness = await RunHarness.CreateAsync(); harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Running, "started", "oc-run-event")); var started = await harness.Service.StartAsync( Request(), Invocation("start-event", "corr-event", "owner")); var now = DateTimeOffset.UtcNow; await harness.Service.ReconcileAsync(ChatEvent("oc-run-event", "delta", 1, now)); await harness.Service.ReconcileAsync(ChatEvent("oc-run-event", "final", 3, now.AddSeconds(1))); await harness.Service.ReconcileAsync(ChatEvent("oc-run-event", "final", 3, now.AddSeconds(1))); harness.Db.ChangeTracker.Clear(); var run = await harness.Db.OpenClawRuns.SingleAsync(item => item.Id == started.Run.Id); Assert.Equal(OpenClawRunStates.Completed, run.Status); Assert.Equal(3, run.LastGatewaySequence); Assert.True(run.SequenceGapDetected); Assert.NotNull(run.FinishedAt); var gatewayTransitions = await harness.Db.OpenClawRunHistory .Where(item => item.Action == "gateway_event") .ToListAsync(); var gatewayTransition = Assert.Single(gatewayTransitions); Assert.True(gatewayTransition.SequenceGapDetected); Assert.Equal(OpenClawRunStates.Completed, gatewayTransition.ToStatus); } [Fact] public async Task Start_RejectsUnknownTaskCorrelationBeforeGatewayCall() { await using var harness = await RunHarness.CreateAsync(); var request = Request() with { TaskId = Guid.NewGuid() }; var exception = await Assert.ThrowsAsync( () => harness.Service.StartAsync( request, Invocation("unknown-task", "corr", "owner"))); Assert.Equal("taskId", exception.Field); Assert.Empty(harness.Gateway.StartCalls); Assert.Empty(await harness.Db.OpenClawRuns.ToListAsync()); } [Fact] public async Task Start_ReplaysUnacknowledgedDispatchWithTheSameIdempotencyKey() { await using var harness = await RunHarness.CreateAsync(); var request = Request(); var pending = new OpenClawRun { Title = request.Title!, Prompt = request.Prompt, AgentId = request.AgentId, SessionKey = request.SessionKey, Status = OpenClawRunStates.Dispatching, StartIdempotencyKey = "recover-key", CorrelationId = "original-correlation", Actor = "owner" }; harness.Db.OpenClawRuns.Add(pending); await harness.Db.SaveChangesAsync(); harness.Db.ChangeTracker.Clear(); harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Running, "in flight", "oc-recovered")); var result = await harness.Service.StartAsync( request, Invocation("recover-key", "recovery-correlation", "owner")); Assert.True(result.Ok); Assert.Equal("oc-recovered", result.Run.OpenClawRunId); Assert.Single(harness.Gateway.StartCalls); var recovered = await harness.Db.OpenClawRuns.SingleAsync(); Assert.Equal(OpenClawRunStates.Running, recovered.Status); Assert.Contains( await harness.Db.OpenClawRunHistory.ToListAsync(), item => item.Action == "start_recovery" && item.CorrelationId == "recovery-correlation"); } [Fact] public async Task Controller_RequiresIdempotencyHeader_AndDerivesActorFromPrincipal() { var service = new CapturingRunService(); var controller = new OpenClawRunsController(service); var context = new DefaultHttpContext(); context.TraceIdentifier = "trace-http"; context.User = new ClaimsPrincipal( new ClaimsIdentity( [new Claim("sub", "owner-subject"), new Claim(ClaimTypes.Role, "owner")], "test")); controller.ControllerContext = new ControllerContext { HttpContext = context }; var missingHeader = await controller.Start(Request(), CancellationToken.None); Assert.IsType(missingHeader.Result); context.Request.Headers["Idempotency-Key"] = "controller-key"; context.Request.Headers["X-Correlation-ID"] = "controller-correlation"; var accepted = await controller.Start(Request(), CancellationToken.None); Assert.IsType(accepted.Result); Assert.Equal("owner-subject", service.Invocation?.Actor); Assert.Equal("controller-key", service.Invocation?.IdempotencyKey); Assert.Equal("controller-correlation", service.Invocation?.CorrelationId); context.Request.Headers["traceparent"] = "not-a-w3c-traceparent"; var invalidTrace = await controller.Start(Request(), CancellationToken.None); Assert.IsType(invalidTrace.Result); context.Request.Headers.Remove("traceparent"); service.StartOk = false; var durablyBlocked = await controller.Start(Request(), CancellationToken.None); Assert.IsType(durablyBlocked.Result); } [Fact] public void Controller_MutationsRequireOwnerRole() { var classAuthorization = typeof(OpenClawRunsController) .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Cast() .Single(attribute => string.IsNullOrWhiteSpace(attribute.Roles)); var startAuthorization = typeof(OpenClawRunsController) .GetMethod(nameof(OpenClawRunsController.Start))! .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Cast() .Single(); Assert.NotNull(classAuthorization); Assert.Equal("owner", startAuthorization.Roles); } private static StartOpenClawRunRequest Request() => new( "Inspect and report the current state.", "iris", "agent:iris:main", "Inspect state"); private static OpenClawRun CreateListedRun( string title, DateTimeOffset createdAt) => new() { Title = title, Prompt = $"Inspect {title}", AgentId = "iris", SessionKey = "agent:iris:main", Status = OpenClawRunStates.Completed, StartIdempotencyKey = $"list-{title}", CorrelationId = $"correlation-{title}", Actor = "owner", CreatedAt = createdAt, UpdatedAt = createdAt }; private static OpenClawInvocationMetadata Invocation( string idempotencyKey, string correlationId, string actor) => new( idempotencyKey, correlationId, actor, "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); private static GatewayEventEnvelope ChatEvent( string runId, string state, long sequence, DateTimeOffset receivedAt) => new( "chat", new JsonObject { ["runId"] = runId, ["sessionKey"] = "agent:iris:main", ["state"] = state, ["seq"] = sequence }, sequence, sequence, receivedAt); private sealed class RunHarness : IAsyncDisposable { private RunHarness( NexusDbContext db, FakeRunGateway gateway, OpenClawRunService service) { Db = db; Gateway = gateway; Service = service; } public NexusDbContext Db { get; } public FakeRunGateway Gateway { get; } public OpenClawRunService Service { get; } public static async Task CreateAsync() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"openclaw-runs-{Guid.NewGuid():N}") .Options; var db = new NexusDbContext(options); await db.Database.EnsureCreatedAsync(); var gateway = new FakeRunGateway(); var repository = new OpenClawRunRepository(db); return new RunHarness(db, gateway, new OpenClawRunService(repository, gateway)); } public ValueTask DisposeAsync() => Db.DisposeAsync(); } private sealed class FakeRunGateway : IOpenClawRunGateway { public Queue StartResults { get; } = new(); public Queue StopResults { get; } = new(); public List<(Guid RunId, OpenClawInvocationMetadata Invocation)> StartCalls { get; } = []; public List<(Guid RunId, OpenClawInvocationMetadata Invocation)> StopCalls { get; } = []; public Task StartAsync( OpenClawRun run, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { StartCalls.Add((run.Id, invocation)); return Task.FromResult(StartResults.Count > 0 ? StartResults.Dequeue() : new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Running, "started", $"oc-{run.Id:N}")); } public Task StopAsync( OpenClawRun run, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { StopCalls.Add((run.Id, invocation)); return Task.FromResult(StopResults.Count > 0 ? StopResults.Dequeue() : new OpenClawRunGatewayResult( true, true, OpenClawRunStates.Stopped, "stopped", run.OpenClawRunId)); } public Task GetHistoryAsync( OpenClawRun run, int limit, CancellationToken cancellationToken = default) => Task.FromResult(new OpenClawRunGatewayResult( true, true, "available", "history", run.OpenClawRunId, new JsonObject { ["messages"] = new JsonArray() })); } private sealed class CapturingRunService : IOpenClawRunService { public OpenClawInvocationMetadata? Invocation { get; private set; } public bool StartOk { get; set; } = true; public Task StartAsync( StartOpenClawRunRequest request, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { Invocation = invocation; var now = DateTimeOffset.UtcNow; var run = new OpenClawRunDto( Guid.NewGuid(), request.Title ?? "Run", request.Prompt, request.AgentId, request.SessionKey, StartOk ? OpenClawRunStates.Running : OpenClawRunStates.Blocked, request.TaskId, request.ProjectId, "oc-run", null, invocation.CorrelationId, invocation.Actor, null, null, false, StartOk, !StartOk, false, "unsupported", now, now, now, null); return Task.FromResult(new OpenClawRunOperationDto( StartOk, StartOk ? OpenClawRunStates.Running : OpenClawRunStates.Blocked, StartOk ? "started" : "gateway disconnected", run, null, now)); } public Task GetAsync( OpenClawRunQuery query, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task StopAsync( Guid id, string? reason, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ResumeAsync( Guid id, string? reason, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RetryAsync( Guid id, string? reason, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetHistoryAsync( Guid id, int gatewayLimit = 200, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ReconcileAsync( GatewayEventEnvelope gatewayEvent, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }