feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class OpenClawEventProjectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Project_OrdersEvents_RedactsSecrets_AndReportsSequenceGap()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var connector = new EventConnector
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new GatewayEventEnvelope(
|
||||
"chat",
|
||||
new JsonObject
|
||||
{
|
||||
["runId"] = "run-1",
|
||||
["state"] = "delta",
|
||||
["seq"] = 2,
|
||||
["apiToken"] = "do-not-leak",
|
||||
["inputTokens"] = 42
|
||||
},
|
||||
12,
|
||||
4,
|
||||
now.AddMilliseconds(20)),
|
||||
new GatewayEventEnvelope(
|
||||
"sessions.changed",
|
||||
new JsonObject { ["sessionKey"] = "agent:iris:main" },
|
||||
10,
|
||||
3,
|
||||
now)
|
||||
]
|
||||
};
|
||||
var service = new OpenClawEventProjectionService(connector);
|
||||
|
||||
var batch = service.Project(null);
|
||||
|
||||
Assert.Equal(2, batch.Events.Count);
|
||||
Assert.Equal("sessions.changed", batch.Events[0].EventName);
|
||||
Assert.Equal("chat", batch.Events[1].EventName);
|
||||
Assert.True(batch.Events[1].SequenceGapDetected);
|
||||
Assert.Equal(11, batch.Events[1].MissingSequenceFrom);
|
||||
Assert.Equal(11, batch.Events[1].MissingSequenceTo);
|
||||
Assert.Equal("[redacted]", batch.Events[1].Payload?["apiToken"]?.GetValue<string>());
|
||||
Assert.Equal(42, batch.Events[1].Payload?["inputTokens"]?.GetValue<int>());
|
||||
Assert.StartsWith("gw-", batch.Events[0].Id);
|
||||
Assert.Equal(batch.Events[^1].Id, batch.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_ReplaysAfterKnownCursor_AndSignalsExpiredCursor()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var connector = new EventConnector
|
||||
{
|
||||
Events =
|
||||
[
|
||||
Event("chat", 3, now.AddSeconds(2)),
|
||||
Event("session.tool", 2, now.AddSeconds(1)),
|
||||
Event("sessions.changed", 1, now)
|
||||
]
|
||||
};
|
||||
var service = new OpenClawEventProjectionService(connector);
|
||||
var initial = service.Project(null);
|
||||
|
||||
var replay = service.Project(initial.Events[0].Id);
|
||||
var expired = service.Project("gw-expired");
|
||||
|
||||
Assert.Equal(2, replay.Events.Count);
|
||||
Assert.False(replay.ReplayBoundaryMissed);
|
||||
Assert.True(expired.ReplayBoundaryMissed);
|
||||
Assert.Equal(3, expired.Events.Count);
|
||||
Assert.Equal(initial.Events[^1].Id, expired.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_SignalsExpiredCursorAfterBackendRestartWithEmptyBuffer()
|
||||
{
|
||||
var service = new OpenClawEventProjectionService(new EventConnector());
|
||||
|
||||
var batch = service.Project("gw-from-previous-process");
|
||||
|
||||
Assert.True(batch.ReplayBoundaryMissed);
|
||||
Assert.Empty(batch.Events);
|
||||
Assert.Equal("origin", batch.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_ReportsOuterSequenceResetWithoutCallingItAMissingRange()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var connector = new EventConnector
|
||||
{
|
||||
Events =
|
||||
[
|
||||
Event("chat", 1, now.AddSeconds(1)),
|
||||
Event("chat", 80, now)
|
||||
]
|
||||
};
|
||||
var service = new OpenClawEventProjectionService(connector);
|
||||
|
||||
var batch = service.Project(null);
|
||||
|
||||
Assert.True(batch.Events[1].SequenceResetDetected);
|
||||
Assert.False(batch.Events[1].SequenceGapDetected);
|
||||
Assert.Equal(80, batch.Events[1].PreviousSequence);
|
||||
Assert.Equal(1, batch.Events[1].Sequence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sessions.changed", "session")]
|
||||
[InlineData("session.tool", "tool")]
|
||||
[InlineData("exec.approval.requested", "approval")]
|
||||
[InlineData("artifact.created", "artifact")]
|
||||
[InlineData("chat", "run")]
|
||||
public void Project_ClassifiesOperationalEventFamilies(string eventName, string category)
|
||||
{
|
||||
var connector = new EventConnector
|
||||
{
|
||||
Events = [Event(eventName, 1, DateTimeOffset.UtcNow)]
|
||||
};
|
||||
|
||||
var item = Assert.Single(new OpenClawEventProjectionService(connector).Project(null).Events);
|
||||
|
||||
Assert.Equal(category, item.Category);
|
||||
Assert.Equal($"openclaw.{category}", item.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_UsesLastEventId_AndCanReturnFiniteReplay()
|
||||
{
|
||||
var connector = new EventConnector
|
||||
{
|
||||
Events = [Event("chat", 7, DateTimeOffset.UtcNow)]
|
||||
};
|
||||
var projector = new OpenClawEventProjectionService(connector);
|
||||
var controller = new OpenClawEventsController(projector);
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers["Last-Event-ID"] = "gw-expired";
|
||||
context.Response.Body = new MemoryStream();
|
||||
controller.ControllerContext = new ControllerContext { HttpContext = context };
|
||||
|
||||
await controller.Stream(follow: false);
|
||||
|
||||
context.Response.Body.Position = 0;
|
||||
using var reader = new StreamReader(context.Response.Body, Encoding.UTF8);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
Assert.Equal("text/event-stream", context.Response.ContentType);
|
||||
Assert.Contains("event: openclaw.connection", body, StringComparison.Ordinal);
|
||||
Assert.Contains("event: openclaw.gap", body, StringComparison.Ordinal);
|
||||
Assert.Contains("event: openclaw.run", body, StringComparison.Ordinal);
|
||||
Assert.Contains("event: openclaw.heartbeat", body, StringComparison.Ordinal);
|
||||
Assert.Contains("last-event-id-outside-buffer", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Controller_IsAuthenticated()
|
||||
{
|
||||
var authorize = typeof(OpenClawEventsController)
|
||||
.GetCustomAttributes<AuthorizeAttribute>()
|
||||
.SingleOrDefault();
|
||||
|
||||
Assert.NotNull(authorize);
|
||||
}
|
||||
|
||||
private static GatewayEventEnvelope Event(
|
||||
string name,
|
||||
long sequence,
|
||||
DateTimeOffset receivedAt)
|
||||
=> new(
|
||||
name,
|
||||
new JsonObject
|
||||
{
|
||||
["runId"] = "run-1",
|
||||
["state"] = "delta",
|
||||
["seq"] = sequence
|
||||
},
|
||||
sequence,
|
||||
sequence,
|
||||
receivedAt);
|
||||
|
||||
private sealed class EventConnector : IGatewayConnector
|
||||
{
|
||||
public IReadOnlyList<GatewayEventEnvelope> Events { get; init; } = [];
|
||||
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-test";
|
||||
public bool DeviceTokenConfigured => true;
|
||||
public bool PairingRequired => false;
|
||||
public string? PairingRequestId => null;
|
||||
public int? ProtocolVersion => 4;
|
||||
public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>();
|
||||
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>();
|
||||
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>();
|
||||
public DateTimeOffset? LastEventAt => Events.FirstOrDefault()?.ReceivedAt;
|
||||
|
||||
public bool Supports(string method) => AdvertisedMethods.Contains(method);
|
||||
|
||||
public Task<JsonNode?> InvokeAsync(
|
||||
string method,
|
||||
object? parameters = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
OpenClawInvocationContext? invocationContext = null)
|
||||
=> Task.FromResult<JsonNode?>(null);
|
||||
|
||||
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
|
||||
=> Events.Take(limit).ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user