Files
nexus/backend-tests/OpenClawRunServiceTests.cs
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

585 lines
22 KiB
C#

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<OpenClawRunValidationException>(
() => 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<BadRequestObjectResult>(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<CreatedAtRouteResult>(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<BadRequestObjectResult>(invalidTrace.Result);
context.Request.Headers.Remove("traceparent");
service.StartOk = false;
var durablyBlocked = await controller.Start(Request(), CancellationToken.None);
Assert.IsType<AcceptedAtRouteResult>(durablyBlocked.Result);
}
[Fact]
public void Controller_MutationsRequireOwnerRole()
{
var classAuthorization = typeof(OpenClawRunsController)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.Single(attribute => string.IsNullOrWhiteSpace(attribute.Roles));
var startAuthorization = typeof(OpenClawRunsController)
.GetMethod(nameof(OpenClawRunsController.Start))!
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.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<RunHarness> CreateAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.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<OpenClawRunGatewayResult> StartResults { get; } = new();
public Queue<OpenClawRunGatewayResult> StopResults { get; } = new();
public List<(Guid RunId, OpenClawInvocationMetadata Invocation)> StartCalls { get; } = [];
public List<(Guid RunId, OpenClawInvocationMetadata Invocation)> StopCalls { get; } = [];
public Task<OpenClawRunGatewayResult> 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<OpenClawRunGatewayResult> 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<OpenClawRunGatewayResult> 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<OpenClawRunOperationDto> 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<OpenClawRunCollectionDto> GetAsync(
OpenClawRunQuery query,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> StopAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> ResumeAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> RetryAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunHistoryResponse?> GetHistoryAsync(
Guid id,
int gatewayLimit = 200,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task ReconcileAsync(
GatewayEventEnvelope gatewayEvent,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}