feat: complete Nexus mission-control workflows
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class MissionControlPhaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void AgentConfigSave_IsBaoOwnerOnly()
|
||||
{
|
||||
var method = typeof(AgentsController).GetMethod(nameof(AgentsController.SaveConfigFile), BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var authorize = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
Assert.NotNull(authorize);
|
||||
Assert.Equal("owner", authorize!.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskApprovalEndpoints_AreOwnerOnly()
|
||||
{
|
||||
var pending = typeof(TasksController).GetMethod(nameof(TasksController.GetPendingApproval), BindingFlags.Instance | BindingFlags.Public);
|
||||
var approve = typeof(TasksController).GetMethod(nameof(TasksController.Approve), BindingFlags.Instance | BindingFlags.Public);
|
||||
var reject = typeof(TasksController).GetMethod(nameof(TasksController.Reject), BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.Equal("owner", pending!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
|
||||
Assert.Equal("owner", approve!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
|
||||
Assert.Equal("owner", reject!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatewayActivityRedaction_RemovesSensitiveLines()
|
||||
{
|
||||
var text = OpenClawGatewayClient.RedactSensitiveText("""
|
||||
Status: ok
|
||||
Authorization: Bearer abc.def.ghi
|
||||
Next step ready
|
||||
X-Nexus-Api-Key: secret
|
||||
""");
|
||||
|
||||
Assert.Contains("Status: ok", text);
|
||||
Assert.Contains("Next step ready", text);
|
||||
Assert.DoesNotContain("Bearer abc", text);
|
||||
Assert.DoesNotContain("secret", text);
|
||||
Assert.Equal(2, text.Split("[redacted sensitive line]").Length - 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentSummaryBuilder_ProducesStructuredNowAndTodaySummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activity = new[]
|
||||
{
|
||||
new ActivityEvent
|
||||
{
|
||||
Type = "agent_task",
|
||||
Message = "programmer completed repo scan",
|
||||
CreatedAt = now.AddHours(-3)
|
||||
}
|
||||
};
|
||||
|
||||
var gateway = new[]
|
||||
{
|
||||
new AgentActivityEntry("5m ago", "Authorization: Bearer hidden\nWorking on redaction", now.AddMinutes(-5)),
|
||||
new AgentActivityEntry("20m ago", "Checking task mapping", now.AddMinutes(-20))
|
||||
};
|
||||
|
||||
var summary = AgentSummaryBuilder.Build(activity, gateway, now);
|
||||
|
||||
Assert.Equal("gateway-session-history", summary.Now.Source);
|
||||
Assert.Equal(now.AddMinutes(-5), summary.Now.Timestamp);
|
||||
Assert.DoesNotContain("Bearer hidden", summary.Now.Text);
|
||||
Assert.Contains("Working on redaction", summary.Now.Text);
|
||||
Assert.Equal("derived-mixed", summary.Today.Source);
|
||||
Assert.Equal(now.AddMinutes(-5), summary.Today.Timestamp);
|
||||
Assert.Contains("Working on redaction", summary.Today.Text);
|
||||
Assert.Contains("Checking task mapping", summary.Today.Text);
|
||||
Assert.Contains("programmer completed repo scan", summary.Today.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivityRepository_RedactsBeforePersistenceAndPublishesAgentIds()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
await using var db = new NexusDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var liveUpdates = new LiveUpdateService();
|
||||
var subscription = await liveUpdates.SubscribeAsync();
|
||||
var repository = new ActivityRepository(db, liveUpdates);
|
||||
|
||||
await repository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "agent",
|
||||
Message = "Command sent to agent programmer: Authorization: Bearer secret-token"
|
||||
});
|
||||
|
||||
var stored = await repository.GetRecentAsync(1);
|
||||
Assert.Single(stored);
|
||||
Assert.DoesNotContain("secret-token", stored[0].Message);
|
||||
Assert.Contains("programmer", stored[0].Message);
|
||||
Assert.Contains("Authorization: Bearer [redacted]", stored[0].Message);
|
||||
|
||||
var envelope = await subscription.Reader.ReadAsync();
|
||||
Assert.Equal("activity.created", envelope.Type);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(envelope.Payload);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
Assert.Equal("agent", doc.RootElement.GetProperty("Type").GetString());
|
||||
Assert.DoesNotContain("secret-token", doc.RootElement.GetProperty("Message").GetString());
|
||||
var agentIds = doc.RootElement.GetProperty("agentIds").EnumerateArray().Select(x => x.GetString()).ToArray();
|
||||
Assert.Contains("programmer", agentIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivityRepository_GetByAgentAsync_UsesMappedAgentIds()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
await using var db = new NexusDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var repository = new ActivityRepository(db, new LiveUpdateService());
|
||||
await repository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "agent",
|
||||
Message = "Command sent to agent programmer: compile module"
|
||||
});
|
||||
await repository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "agent",
|
||||
Message = "Command sent to agent reviewer: inspect module"
|
||||
});
|
||||
|
||||
var programmerEvents = await repository.GetByAgentAsync("programmer", 10);
|
||||
|
||||
Assert.Single(programmerEvents);
|
||||
Assert.True(programmerEvents[0].Message.Contains("programmer", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayInfo_ReportsVersionDrift()
|
||||
{
|
||||
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json")
|
||||
}, requiredVersion: "2026.07.09");
|
||||
|
||||
var info = await client.GetGatewayInfoAsync();
|
||||
|
||||
Assert.True(info.Reachable);
|
||||
Assert.Equal("2026.07.08", info.Version);
|
||||
Assert.Equal("2026.07.09", info.RequiredVersion);
|
||||
Assert.Equal("drift", info.VersionStatus);
|
||||
Assert.False(info.VersionMatches);
|
||||
Assert.NotNull(info.Warning);
|
||||
Assert.Contains("2026.07.08", info.Warning!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayInfo_ReportsMissingVersionWhenPinned()
|
||||
{
|
||||
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
|
||||
}, requiredVersion: "2026.07.09");
|
||||
|
||||
var info = await client.GetGatewayInfoAsync();
|
||||
|
||||
Assert.True(info.Reachable);
|
||||
Assert.Null(info.Version);
|
||||
Assert.Equal("missing", info.VersionStatus);
|
||||
Assert.False(info.VersionMatches);
|
||||
Assert.NotNull(info.Warning);
|
||||
Assert.Contains("2026.07.09", info.Warning!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayInfo_ReportsMatchedPinnedVersion()
|
||||
{
|
||||
var client = CreateClient(request =>
|
||||
{
|
||||
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
|
||||
};
|
||||
response.Headers.Add("X-OpenClaw-Version", "2026.07.09");
|
||||
return response;
|
||||
}, requiredVersion: "2026.07.09");
|
||||
|
||||
var info = await client.GetGatewayInfoAsync();
|
||||
|
||||
Assert.True(info.Reachable);
|
||||
Assert.Equal("matched", info.VersionStatus);
|
||||
Assert.True(info.VersionMatches);
|
||||
Assert.Null(info.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus()
|
||||
{
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o");
|
||||
var client = CreateClient(request =>
|
||||
{
|
||||
if (request.RequestUri?.AbsolutePath == "/tools/invoke")
|
||||
{
|
||||
using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult());
|
||||
var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()!
|
||||
.Split(':', StringSplitOptions.RemoveEmptyEntries)[1];
|
||||
|
||||
object status = agentId switch
|
||||
{
|
||||
"iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" },
|
||||
"programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" },
|
||||
"reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" },
|
||||
"architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" },
|
||||
_ => new { status = "ready", model = "openai/gpt-5.5" }
|
||||
};
|
||||
|
||||
return ToolResult(status);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
|
||||
}, agentIds: ["iris", "programmer", "reviewer", "architekt"]);
|
||||
|
||||
var agents = await client.GetAgentsAsync();
|
||||
|
||||
Assert.Collection(agents.OrderBy(a => a.Id),
|
||||
architekt =>
|
||||
{
|
||||
Assert.Equal("architekt", architekt.Id);
|
||||
Assert.Equal("unsupported", architekt.StatusKind);
|
||||
Assert.Equal("Unsupported", architekt.StatusLabel);
|
||||
Assert.Equal("tool not available", architekt.StatusDetail);
|
||||
},
|
||||
iris =>
|
||||
{
|
||||
Assert.Equal("iris", iris.Id);
|
||||
Assert.Equal("connected", iris.StatusKind);
|
||||
Assert.Equal("Arbeitet", iris.StatusLabel);
|
||||
},
|
||||
programmer =>
|
||||
{
|
||||
Assert.Equal("programmer", programmer.Id);
|
||||
Assert.Equal("stale", programmer.StatusKind);
|
||||
Assert.Equal("Stale", programmer.StatusLabel);
|
||||
Assert.NotNull(programmer.StatusDetail);
|
||||
Assert.Contains("40m", programmer.StatusDetail!);
|
||||
},
|
||||
reviewer =>
|
||||
{
|
||||
Assert.Equal("reviewer", reviewer.Id);
|
||||
Assert.Equal("error", reviewer.StatusKind);
|
||||
Assert.Equal("Fehler", reviewer.StatusLabel);
|
||||
Assert.Equal("gateway timeout", reviewer.StatusDetail);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile()
|
||||
{
|
||||
var agentId = $"phase-p4-{Guid.NewGuid():N}";
|
||||
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
|
||||
Directory.CreateDirectory(workspacePath);
|
||||
var configPath = Path.Combine(workspacePath, "TOOLS.md");
|
||||
await File.WriteAllTextAsync(configPath, "original");
|
||||
|
||||
try
|
||||
{
|
||||
var service = new AgentConfigService();
|
||||
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content");
|
||||
|
||||
Assert.NotNull(attempt.Failure);
|
||||
Assert.Equal("validation_failed", attempt.Failure!.Code);
|
||||
Assert.Equal("failed", attempt.Failure.Validation.Status);
|
||||
Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal("original", await File.ReadAllTextAsync(configPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(workspacePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave()
|
||||
{
|
||||
var agentId = $"phase-p4-{Guid.NewGuid():N}";
|
||||
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
|
||||
Directory.CreateDirectory(workspacePath);
|
||||
var configPath = Path.Combine(workspacePath, "TOOLS.md");
|
||||
await File.WriteAllTextAsync(configPath, "before");
|
||||
|
||||
try
|
||||
{
|
||||
var service = new AgentConfigService();
|
||||
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after");
|
||||
|
||||
Assert.NotNull(attempt.SaveResult);
|
||||
var result = attempt.SaveResult!;
|
||||
Assert.Equal("passed", result.Validation.Status);
|
||||
Assert.Equal("markdown", result.Validation.FileKind);
|
||||
Assert.Equal("created", result.Backup.Status);
|
||||
Assert.True(result.Backup.BackupCreated);
|
||||
Assert.Equal("not_supported", result.ReloadCheck.Status);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message));
|
||||
Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak"));
|
||||
Assert.Equal("after", await File.ReadAllTextAsync(configPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(workspacePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent()
|
||||
{
|
||||
var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"validation_failed",
|
||||
new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]),
|
||||
new AgentConfigBackupResult("not_applicable", false),
|
||||
new AgentConfigReloadCheckResult("not_supported", "No hot reload available."))));
|
||||
var activityRepo = new CapturingActivityRepository();
|
||||
|
||||
var controller = new AgentsController(
|
||||
new FakeAgentService(),
|
||||
new FakeAgentRuntime(),
|
||||
activityRepo,
|
||||
configService,
|
||||
new FakeDashboardService(),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<AgentsController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, "bao"),
|
||||
new Claim(ClaimTypes.Role, "owner")
|
||||
], "TestAuth"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None);
|
||||
|
||||
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
|
||||
var audit = Assert.Single(activityRepo.Added);
|
||||
Assert.Equal("config_audit", audit.Type);
|
||||
Assert.Contains("validation=failed", audit.Message);
|
||||
Assert.DoesNotContain("secret", audit.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static OpenClawGatewayClient CreateClient(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> responder,
|
||||
string? requiredVersion = null,
|
||||
string[]? agentIds = null)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["Integrations:OpenClaw:RequiredVersion"] = requiredVersion
|
||||
};
|
||||
|
||||
if (agentIds is not null)
|
||||
{
|
||||
var configPath = Path.GetTempFileName();
|
||||
File.WriteAllText(configPath, JsonSerializer.Serialize(new
|
||||
{
|
||||
agents = new
|
||||
{
|
||||
list = agentIds.Select(id => new { id }).ToArray()
|
||||
}
|
||||
}));
|
||||
configValues["AgentConfigPath"] = configPath;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
var httpClient = new HttpClient(new StubHttpMessageHandler(responder))
|
||||
{
|
||||
BaseAddress = new Uri("http://gateway.local")
|
||||
};
|
||||
|
||||
return new OpenClawGatewayClient(httpClient, configuration);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage ToolResult(object payload)
|
||||
=> new(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(new { ok = true, result = payload }),
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
file sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(responder(request));
|
||||
}
|
||||
|
||||
file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService
|
||||
{
|
||||
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId) => [];
|
||||
|
||||
public Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
|
||||
=> Task.FromResult<AgentConfigFileContent?>(null);
|
||||
|
||||
public Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
=> Task.FromResult(attempt);
|
||||
}
|
||||
|
||||
file sealed class CapturingActivityRepository : IActivityRepository
|
||||
{
|
||||
public List<ActivityEvent> Added { get; } = [];
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
|
||||
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
|
||||
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||
=> Task.FromResult((new List<ActivityEvent>(), 0));
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
|
||||
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
Added.Add(activity);
|
||||
return Task.FromResult(activity);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeAgentService : IAgentService
|
||||
{
|
||||
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>([]);
|
||||
|
||||
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<AgentDetail?>(null);
|
||||
|
||||
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
|
||||
}
|
||||
|
||||
file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
|
||||
{
|
||||
public string Name => "fake";
|
||||
|
||||
public Task<Nexus.Api.Integrations.AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null));
|
||||
|
||||
public Task<Nexus.Api.Integrations.AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok"));
|
||||
}
|
||||
|
||||
file sealed class FakeDashboardService : IDashboardService
|
||||
{
|
||||
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
|
||||
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
|
||||
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
|
||||
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
|
||||
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
|
||||
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
|
||||
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
|
||||
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
|
||||
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
|
||||
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() => [];
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Server;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class NexusMcpToolsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NexusMcpTools_RegistersExpectedToolNames()
|
||||
{
|
||||
var toolNames = typeof(NexusMcpTools)
|
||||
.GetMethods()
|
||||
.Select(method => method.GetCustomAttributes(typeof(McpServerToolAttribute), inherit: false)
|
||||
.OfType<McpServerToolAttribute>()
|
||||
.FirstOrDefault())
|
||||
.Where(attribute => attribute is not null)
|
||||
.Select(attribute => attribute!.Name ?? string.Empty)
|
||||
.Order()
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"nexus_agent_overview",
|
||||
"nexus_append_activity",
|
||||
"nexus_create_child_task",
|
||||
"nexus_create_task",
|
||||
"nexus_get_activity",
|
||||
"nexus_get_board",
|
||||
"nexus_get_children",
|
||||
"nexus_get_task",
|
||||
"nexus_handoff",
|
||||
"nexus_update_status"
|
||||
], toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NexusMcpTaskState_OnlyContainsCanonicalStates()
|
||||
{
|
||||
Assert.Equal(
|
||||
[
|
||||
nameof(NexusMcpTaskState.Backlog),
|
||||
nameof(NexusMcpTaskState.InProgress),
|
||||
nameof(NexusMcpTaskState.Blocked),
|
||||
nameof(NexusMcpTaskState.Done),
|
||||
nameof(NexusMcpTaskState.Review)
|
||||
], Enum.GetNames<NexusMcpTaskState>());
|
||||
|
||||
Assert.DoesNotContain("Delegated", Enum.GetNames<NexusMcpTaskState>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpTools_ReadAndWrite_UseBridgeService()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
var tools = CreateTools(fixture);
|
||||
|
||||
var createResult = await tools.CreateTask(
|
||||
title: "MCP parent",
|
||||
detail: "Created through MCP tool facade",
|
||||
priority: "High",
|
||||
assignedTo: "iris",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.True(createResult.Ok);
|
||||
Assert.NotNull(createResult.Data);
|
||||
Assert.Equal("MCP parent", createResult.Data!.Title);
|
||||
|
||||
var activityResult = await tools.AppendActivity(
|
||||
createResult.Data.Id,
|
||||
"MCP checkpoint",
|
||||
"comment",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(activityResult.Ok);
|
||||
Assert.Equal("MCP checkpoint", activityResult.Data!.Message);
|
||||
|
||||
var statusResult = await tools.UpdateStatus(
|
||||
createResult.Data.Id,
|
||||
NexusMcpTaskState.InProgress,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(statusResult.Ok);
|
||||
Assert.Equal(TaskStateHelper.ToStateString(TaskState.InProgress), statusResult.Data!.State);
|
||||
|
||||
var board = await tools.GetBoard(CancellationToken.None);
|
||||
Assert.Contains(board.InProgress, task => task.Id == createResult.Data.Id);
|
||||
|
||||
var taskResult = await tools.GetTask(createResult.Data.Id, CancellationToken.None);
|
||||
Assert.True(taskResult.Ok);
|
||||
Assert.Equal("MCP parent", taskResult.Data!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpTools_UpdateStatus_RejectsUnauthorizedAgent()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var task = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Programmer cannot move",
|
||||
"State changes stay with Iris/Bao.",
|
||||
"iris",
|
||||
"Normal",
|
||||
"programmer",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
fixture.SetCallerAgent("programmer");
|
||||
var tools = CreateTools(fixture);
|
||||
|
||||
var result = await tools.UpdateStatus(task.Id, NexusMcpTaskState.Done, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal("nexus_update_status", result.Command);
|
||||
Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpTools_ServiceKey_ResolvesAsNexusSystem()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
fixture.HttpContextAccessor.HttpContext = TaskWorkflowFixture.CreateHttpContext(
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
});
|
||||
|
||||
var tools = CreateTools(fixture);
|
||||
var result = await tools.CreateTask(
|
||||
title: "System MCP task",
|
||||
assignedTo: "iris",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("bao", result.Data!.Source);
|
||||
}
|
||||
|
||||
private static NexusMcpTools CreateTools(TaskWorkflowFixture fixture)
|
||||
=> new(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.HttpContextAccessor,
|
||||
fixture.Configuration,
|
||||
NullLogger<NexusMcpTools>.Instance);
|
||||
}
|
||||
@@ -94,6 +94,8 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) :
|
||||
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
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 StaleTaskRecoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResetStaleInProgressTasksAsync_OnlyResetsStaleInProgressTasks_AndWritesActivity()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
|
||||
var staleInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Stale in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
await fixture.ActivityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "comment",
|
||||
Message = "Previous agent note",
|
||||
TaskId = staleInProgress.Id,
|
||||
CreatedAt = staleTimestamp.AddMinutes(15)
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleBlocked = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Blocked task",
|
||||
State = "Blocked",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleReview = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Review task",
|
||||
State = "Review",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleDone = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Done task",
|
||||
State = "Done",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleBacklog = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Backlog task",
|
||||
State = "Backlog",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var freshInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Fresh in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var resetCount = await fixture.StaleTaskRecoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, resetCount);
|
||||
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleInProgress.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Blocked", (await fixture.TaskService.GetByIdAsync(staleBlocked.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Review", (await fixture.TaskService.GetByIdAsync(staleReview.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Done", (await fixture.TaskService.GetByIdAsync(staleDone.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleBacklog.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(freshInProgress.Id, CancellationToken.None))!.State);
|
||||
|
||||
var activity = await fixture.TaskService.GetTaskActivityAsync(staleInProgress.Id, CancellationToken.None);
|
||||
var resetActivity = activity.FirstOrDefault(entry => entry.Message.Contains("stale recovery", StringComparison.Ordinal));
|
||||
|
||||
Assert.NotNull(resetActivity);
|
||||
Assert.Contains("reason=stale-recovery", resetActivity!.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("previous status In progress", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("stale reference", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("last activity", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("new status Backlog", resetActivity.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStaleInProgressTasksAsync_RevalidatesCurrentTaskBeforeReset()
|
||||
{
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
var taskId = Guid.NewGuid();
|
||||
var staleCandidate = new WorkTask
|
||||
{
|
||||
Id = taskId,
|
||||
Title = "Changed during recovery scan",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
};
|
||||
var currentTask = new WorkTask
|
||||
{
|
||||
Id = taskId,
|
||||
Title = "Changed during recovery scan",
|
||||
State = "Review",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
};
|
||||
var taskRepository = new FakeTaskRepository(staleCandidate, currentTask);
|
||||
var activityRepository = new FakeActivityRepository();
|
||||
var liveUpdateService = new FakeLiveUpdateService();
|
||||
var recoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService);
|
||||
|
||||
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, resetCount);
|
||||
Assert.Equal("Review", currentTask.State);
|
||||
Assert.Equal(0, taskRepository.ResetCount);
|
||||
Assert.Empty(activityRepository.Added);
|
||||
Assert.Equal(0, liveUpdateService.PublishCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var backgroundService = new StaleTaskRecoveryBackgroundService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StaleHours = 4,
|
||||
IntervalMinutes = 30
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, fakeRecoveryService.CallCount);
|
||||
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
|
||||
Assert.Equal(7, resetCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var backgroundService = new StaleTaskRecoveryBackgroundService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StaleHours = 2,
|
||||
IntervalMinutes = 30
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await backgroundService.StartAsync(cts.Token);
|
||||
await firstCall.Task.WaitAsync(cts.Token);
|
||||
await backgroundService.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(fakeRecoveryService.CallCount >= 1);
|
||||
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskRecoveryOptions_BindsStaleHoursFromEnvironmentOverride()
|
||||
{
|
||||
const string key = "TaskRecovery__StaleHours";
|
||||
var originalValue = Environment.GetEnvironmentVariable(key);
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, "5");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2",
|
||||
[$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30"
|
||||
})
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get<StaleTaskRecoveryOptions>();
|
||||
|
||||
Assert.NotNull(options);
|
||||
Assert.Equal(5, options!.StaleHours);
|
||||
Assert.Equal(30, options.IntervalMinutes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, originalValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTask) : ITaskRepository
|
||||
{
|
||||
public int ResetCount { get; private set; }
|
||||
|
||||
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<WorkTask> { staleCandidate });
|
||||
|
||||
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> ValueTask.FromResult<WorkTask?>(id == currentTask.Id ? currentTask : null);
|
||||
|
||||
public Task<bool> TryResetStaleInProgressToBacklogAsync(
|
||||
Guid id,
|
||||
DateTimeOffset staleBefore,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (id != currentTask.Id
|
||||
|| !string.Equals(currentTask.State, "In progress", StringComparison.OrdinalIgnoreCase)
|
||||
|| currentTask.UpdatedAt >= staleBefore)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
ResetCount++;
|
||||
currentTask.State = "Backlog";
|
||||
currentTask.UpdatedAt = updatedAt;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<WorkTask>());
|
||||
|
||||
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
|
||||
=> Task.FromResult(task);
|
||||
|
||||
public Task DeleteAsync(WorkTask task, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<WorkTask?>(null);
|
||||
}
|
||||
|
||||
file sealed class FakeActivityRepository : IActivityRepository
|
||||
{
|
||||
public List<ActivityEvent> Added { get; } = [];
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||
string? type,
|
||||
string? sort,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult((new List<ActivityEvent>(), 0));
|
||||
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
Added.Add(activity);
|
||||
return Task.FromResult(activity);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeLiveUpdateService : ILiveUpdateService
|
||||
{
|
||||
public int PublishCount { get; private set; }
|
||||
public long CurrentSequence => PublishCount;
|
||||
|
||||
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
|
||||
{
|
||||
PublishCount++;
|
||||
return new LiveUpdateEnvelope(type, DateTimeOffset.UtcNow, payload, PublishCount, channel);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public TimeSpan LastThreshold { get; private set; }
|
||||
public Action? OnCall { get; set; }
|
||||
|
||||
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
LastThreshold = staleThreshold;
|
||||
OnCall?.Invoke();
|
||||
return Task.FromResult(7);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
|
||||
{
|
||||
public T CurrentValue { get; private set; } = currentValue;
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
@@ -178,7 +178,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -199,7 +199,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -217,7 +217,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -238,7 +238,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -256,7 +256,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -277,7 +277,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -372,7 +372,7 @@ public sealed class TaskWorkflowTests
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NexusDbContext _db;
|
||||
|
||||
@@ -383,6 +383,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
IActivityRepository activityRepository,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService,
|
||||
IStaleTaskRecoveryService staleTaskRecoveryService,
|
||||
ITaskService taskService,
|
||||
ITaskBridgeService taskBridgeService,
|
||||
IAgentService agentService,
|
||||
@@ -394,6 +395,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
ActivityRepository = activityRepository;
|
||||
NotificationService = notificationService;
|
||||
LiveUpdateService = liveUpdateService;
|
||||
StaleTaskRecoveryService = staleTaskRecoveryService;
|
||||
TaskService = taskService;
|
||||
TaskBridgeService = taskBridgeService;
|
||||
AgentService = agentService;
|
||||
@@ -405,6 +407,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
public IActivityRepository ActivityRepository { get; }
|
||||
public INotificationService NotificationService { get; }
|
||||
public ILiveUpdateService LiveUpdateService { get; }
|
||||
public IStaleTaskRecoveryService StaleTaskRecoveryService { get; }
|
||||
public ITaskService TaskService { get; }
|
||||
public ITaskBridgeService TaskBridgeService { get; }
|
||||
public IAgentService AgentService { get; }
|
||||
@@ -430,10 +433,14 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
|
||||
var agentService = new AgentService(configuration, new FakeRuntime());
|
||||
var liveUpdateService = new LiveUpdateService();
|
||||
var activityRepository = new ActivityRepository(db);
|
||||
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 staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService);
|
||||
|
||||
var taskService = new TaskService(
|
||||
taskRepository,
|
||||
@@ -441,7 +448,8 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
notificationService,
|
||||
agentService,
|
||||
httpContextAccessor,
|
||||
liveUpdateService);
|
||||
liveUpdateService,
|
||||
staleTaskRecoveryService);
|
||||
|
||||
var taskBridgeService = new TaskBridgeService(
|
||||
taskService,
|
||||
@@ -457,6 +465,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
activityRepository,
|
||||
notificationService,
|
||||
liveUpdateService,
|
||||
staleTaskRecoveryService,
|
||||
taskService,
|
||||
taskBridgeService,
|
||||
agentService,
|
||||
@@ -539,6 +548,7 @@ file sealed class FakeDashboardService : IDashboardService
|
||||
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
|
||||
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
|
||||
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
|
||||
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
|
||||
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
|
||||
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
|
||||
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
|
||||
|
||||
Reference in New Issue
Block a user