feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -15,6 +16,31 @@ namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class TaskWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TaskMutation_PublishesContentMinimizedLegacyInvalidation()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
using var subscriptionLifetime = new CancellationTokenSource();
|
||||
var subscription = await fixture.LiveUpdateService.SubscribeAsync(
|
||||
ct: subscriptionLifetime.Token);
|
||||
|
||||
var task = await fixture.TaskService.CreateAsync(
|
||||
new Nexus.Api.DTOs.CreateTaskRequest("Mutation delta", "Normal", null),
|
||||
CancellationToken.None);
|
||||
|
||||
var update = await subscription.Reader.ReadAsync(CancellationToken.None);
|
||||
if (update.Type != "tasks.board.snapshot")
|
||||
update = await subscription.Reader.ReadAsync(CancellationToken.None);
|
||||
subscriptionLifetime.Cancel();
|
||||
|
||||
Assert.Equal("tasks.board.snapshot", update.Type);
|
||||
Assert.Equal("board", update.Channel);
|
||||
Assert.IsNotType<BoardResponse>(update.Payload);
|
||||
var payload = JsonSerializer.Serialize(update.Payload);
|
||||
Assert.Contains(task.Id.ToString(), payload, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("Mutation delta", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
|
||||
{
|
||||
@@ -98,7 +124,7 @@ public sealed class TaskWorkflowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
|
||||
public async Task GatewayBridgeController_GetBoard_RejectsAgentHeaderWithoutAuthentication()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
@@ -117,6 +143,31 @@ public sealed class TaskWorkflowTests
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AcceptsAgentHintAfterServiceAuthentication()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "programmer-fast",
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
@@ -174,7 +225,7 @@ public sealed class TaskWorkflowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
|
||||
public async Task TasksController_GetBoard_RejectsAgentHeaderWithoutAuthentication()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
@@ -191,7 +242,7 @@ public sealed class TaskWorkflowTests
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -213,7 +264,7 @@ public sealed class TaskWorkflowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
|
||||
public async Task TasksController_ResetStale_UnknownAgentHeaderWithoutAuthentication_IsUnauthorized()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
@@ -230,7 +281,7 @@ public sealed class TaskWorkflowTests
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status403Forbidden);
|
||||
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -251,6 +302,26 @@ public sealed class TaskWorkflowTests
|
||||
AssertStatusCode(result, StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_OrdinaryJwtCannotEscalateWithIrisHeader()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(
|
||||
agentId: "iris",
|
||||
user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
|
||||
{
|
||||
@@ -273,7 +344,7 @@ public sealed class TaskWorkflowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
|
||||
public async Task TasksController_ResetStale_IrisHeaderWithoutAuthentication_IsUnauthorized()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
@@ -287,11 +358,33 @@ public sealed class TaskWorkflowTests
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_IrisHintWithServiceKey_IsAllowed()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "iris",
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
|
||||
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsForbidden()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
@@ -308,7 +401,32 @@ public sealed class TaskWorkflowTests
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||
var forbidden = Assert.IsType<ObjectResult>(result.Result);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtCannotEscalateWithAgentHeader()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(
|
||||
agentId: "iris",
|
||||
user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
var forbidden = Assert.IsType<ObjectResult>(result.Result);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -422,21 +540,24 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
var db = new NexusDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = configPath,
|
||||
["NexusApiKey"] = "test-service-key"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var agentService = new AgentService(configuration, new FakeRuntime());
|
||||
var agentService = new AgentService(new StubOpenClawControlService());
|
||||
var liveUpdateService = new LiveUpdateService();
|
||||
var activityRepository = new ActivityRepository(db, liveUpdateService);
|
||||
var taskRepository = new TaskRepository(db);
|
||||
var notificationService = new NotificationService(db, liveUpdateService);
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
|
||||
var httpContextAccessor = new HttpContextAccessor
|
||||
{
|
||||
HttpContext = CreateHttpContext(
|
||||
agentId: "iris",
|
||||
user: CreateUser("service", "Service"))
|
||||
};
|
||||
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
@@ -504,7 +625,9 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
|
||||
public void SetCallerAgent(string agentId)
|
||||
{
|
||||
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
|
||||
HttpContextAccessor.HttpContext = CreateHttpContext(
|
||||
agentId: agentId,
|
||||
user: CreateUser("service", "Service"));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -512,32 +635,6 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
await _db.DisposeAsync();
|
||||
}
|
||||
|
||||
private static string CreateAgentConfigFile()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path,
|
||||
"""
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/workspace/default",
|
||||
"model": {
|
||||
"primary": "deepseek/deepseek-v4-flash"
|
||||
}
|
||||
},
|
||||
"list": [
|
||||
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
|
||||
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
|
||||
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
|
||||
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
|
||||
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeDashboardService : IDashboardService
|
||||
@@ -554,5 +651,6 @@ file sealed class FakeDashboardService : IDashboardService
|
||||
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
|
||||
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
|
||||
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
|
||||
public List<ModelOption> GetAvailableModels() => [];
|
||||
public Task<List<ModelOption>> GetAvailableModelsAsync(CancellationToken ct)
|
||||
=> Task.FromResult(new List<ModelOption>());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user