feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class OpenClawContentServicesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Memory_uses_agent_files_and_workspace_rpc_with_source_metadata()
|
||||
{
|
||||
var gateway = new ContentConfigurationStub();
|
||||
gateway.AgentFiles =
|
||||
[
|
||||
new OpenClawAgentFileSummaryDto(
|
||||
"MEMORY.md",
|
||||
false,
|
||||
15,
|
||||
DateTimeOffset.UtcNow,
|
||||
"hash")
|
||||
];
|
||||
gateway.Listings["memory"] =
|
||||
[
|
||||
Entry("memory/2026-07-31.md", 24)
|
||||
];
|
||||
gateway.AgentFileContent = "Long term memory";
|
||||
gateway.Files["memory/2026-07-31.md"] =
|
||||
File("bao-agent", "memory/2026-07-31.md", "Daily memory needle");
|
||||
var service = new MemoryService(gateway);
|
||||
|
||||
var listed = await service.GetAllAsync("bao-agent");
|
||||
var found = await service.SearchAsync("needle", "bao-agent");
|
||||
var longTerm = await service.GetFileAsync("MEMORY.md", "bao-agent");
|
||||
|
||||
Assert.Collection(
|
||||
listed,
|
||||
item =>
|
||||
{
|
||||
Assert.Equal("MEMORY.md", item.WorkspacePath);
|
||||
Assert.Equal("bao-agent", item.SourceAgentId);
|
||||
},
|
||||
item =>
|
||||
{
|
||||
Assert.Equal("memory/2026-07-31.md", item.WorkspacePath);
|
||||
Assert.Equal("2026-07-31.md", item.Path);
|
||||
});
|
||||
Assert.Equal(
|
||||
"memory/2026-07-31.md",
|
||||
Assert.Single(found).WorkspacePath);
|
||||
Assert.Equal("MEMORY.md", longTerm?.WorkspacePath);
|
||||
Assert.DoesNotContain(
|
||||
listed.Select(item => item.WorkspacePath),
|
||||
path => path.StartsWith('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Memory_search_limits_live_file_reads_to_four()
|
||||
{
|
||||
var gateway = new ContentConfigurationStub
|
||||
{
|
||||
ReadDelay = TimeSpan.FromMilliseconds(20)
|
||||
};
|
||||
gateway.Listings["memory"] = Enumerable.Range(1, 12)
|
||||
.Select(index => Entry($"memory/{index:00}.md", 20))
|
||||
.ToArray();
|
||||
foreach (var entry in gateway.Listings["memory"])
|
||||
{
|
||||
gateway.Files[entry.Path] =
|
||||
File("iris", entry.Path, $"needle {entry.Name}");
|
||||
}
|
||||
|
||||
var results = await new MemoryService(gateway)
|
||||
.SearchAsync("needle", "iris");
|
||||
|
||||
Assert.Equal(12, results.Count);
|
||||
Assert.InRange(gateway.MaxConcurrentReads, 1, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_daily_memory_directory_preserves_memory_file()
|
||||
{
|
||||
var gateway = new ContentConfigurationStub
|
||||
{
|
||||
MissingMemoryDirectory = true,
|
||||
AgentFileContent = "Long term needle"
|
||||
};
|
||||
gateway.AgentFiles =
|
||||
[
|
||||
new OpenClawAgentFileSummaryDto(
|
||||
"MEMORY.md",
|
||||
false,
|
||||
16,
|
||||
DateTimeOffset.UtcNow,
|
||||
"hash")
|
||||
];
|
||||
var service = new MemoryService(gateway);
|
||||
|
||||
var listed = await service.GetAllAsync("iris");
|
||||
var searched = await service.SearchAsync("needle", "iris");
|
||||
|
||||
Assert.Equal("MEMORY.md", Assert.Single(listed).WorkspacePath);
|
||||
Assert.Equal("MEMORY.md", Assert.Single(searched).WorkspacePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Docs_and_incidents_use_workspace_relative_rpc_paths()
|
||||
{
|
||||
var gateway = new ContentConfigurationStub();
|
||||
gateway.Listings[""] = [Entry("README.md", 30)];
|
||||
gateway.Listings["nexus-phases"] =
|
||||
[Entry("nexus-phases/phase-1.md", 30)];
|
||||
gateway.Listings["skills"] = [];
|
||||
gateway.Listings["nexus"] = [];
|
||||
gateway.Listings["nexus/phases"] = [];
|
||||
gateway.Listings["memory/incidents"] =
|
||||
[Entry("memory/incidents/2026-07-31-gateway.md", 90)];
|
||||
gateway.Files["README.md"] =
|
||||
File("iris", "README.md", "# Nexus");
|
||||
gateway.Files["memory/incidents/2026-07-31-gateway.md"] =
|
||||
File(
|
||||
"iris",
|
||||
"memory/incidents/2026-07-31-gateway.md",
|
||||
"# Gateway outage\n**Severity:** high\n\nRecovered.");
|
||||
|
||||
var docs = await new DocService(gateway).GetAllAsync("iris");
|
||||
var incidents = await new IncidentService(gateway).GetAllAsync("iris");
|
||||
|
||||
Assert.Contains(
|
||||
docs,
|
||||
item => item.WorkspacePath == "README.md"
|
||||
&& item.SourceAgentId == "iris");
|
||||
var incident = Assert.Single(incidents);
|
||||
Assert.Equal("high", incident.Severity);
|
||||
Assert.Equal(
|
||||
"memory/incidents/2026-07-31-gateway.md",
|
||||
incident.WorkspacePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sensitive_content_controllers_are_owner_only()
|
||||
{
|
||||
Type[] controllers =
|
||||
[
|
||||
typeof(MemoryController),
|
||||
typeof(DocsController),
|
||||
typeof(IncidentsController)
|
||||
];
|
||||
|
||||
foreach (var controller in controllers)
|
||||
{
|
||||
var authorize = Assert.Single(
|
||||
controller.GetCustomAttributes<AuthorizeAttribute>());
|
||||
Assert.Equal("owner", authorize.Roles);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Adapter_reports_gateway_unavailable_instead_of_empty_success()
|
||||
{
|
||||
var controller = new MemoryController(new UnavailableMemoryService());
|
||||
var result = await controller.GetAll();
|
||||
|
||||
Assert.Equal(
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
Assert.IsAssignableFrom<IStatusCodeHttpResult>(result)
|
||||
.StatusCode);
|
||||
}
|
||||
|
||||
private static OpenClawWorkspaceEntryDto Entry(string path, long size)
|
||||
=> new(
|
||||
path,
|
||||
Path.GetFileName(path),
|
||||
"file",
|
||||
size,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
private static OpenClawWorkspaceFileDto File(
|
||||
string agentId,
|
||||
string path,
|
||||
string content)
|
||||
=> new(
|
||||
agentId,
|
||||
path,
|
||||
Path.GetFileName(path),
|
||||
System.Text.Encoding.UTF8.GetByteCount(content),
|
||||
DateTimeOffset.UtcNow,
|
||||
"text/markdown",
|
||||
"utf8",
|
||||
content,
|
||||
"hash",
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
private sealed class ContentConfigurationStub
|
||||
: IOpenClawAgentConfigurationService
|
||||
{
|
||||
private int activeReads;
|
||||
private int maxConcurrentReads;
|
||||
|
||||
public IReadOnlyList<OpenClawAgentFileSummaryDto> AgentFiles { get; set; }
|
||||
= [];
|
||||
public string AgentFileContent { get; set; } = string.Empty;
|
||||
public ConcurrentDictionary<
|
||||
string,
|
||||
IReadOnlyList<OpenClawWorkspaceEntryDto>> Listings { get; } =
|
||||
new(StringComparer.Ordinal);
|
||||
public ConcurrentDictionary<string, OpenClawWorkspaceFileDto> Files { get; }
|
||||
= new(StringComparer.Ordinal);
|
||||
public TimeSpan ReadDelay { get; set; }
|
||||
public bool MissingMemoryDirectory { get; set; }
|
||||
public int MaxConcurrentReads => Volatile.Read(ref maxConcurrentReads);
|
||||
|
||||
public Task<OpenClawAgentFileCollectionDto> GetAgentFilesAsync(
|
||||
string agentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new OpenClawAgentFileCollectionDto(
|
||||
agentId,
|
||||
AgentFiles,
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
public Task<OpenClawAgentFileDto> GetAgentFileAsync(
|
||||
string agentId,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new OpenClawAgentFileDto(
|
||||
agentId,
|
||||
fileName,
|
||||
false,
|
||||
System.Text.Encoding.UTF8.GetByteCount(AgentFileContent),
|
||||
DateTimeOffset.UtcNow,
|
||||
AgentFileContent,
|
||||
"hash",
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
public Task<OpenClawWorkspaceCollectionDto> GetWorkspaceAsync(
|
||||
string agentId,
|
||||
string? path,
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = path ?? string.Empty;
|
||||
if (MissingMemoryDirectory
|
||||
&& string.Equals(normalized, "memory", StringComparison.Ordinal))
|
||||
{
|
||||
throw new OpenClawGatewayRpcException(
|
||||
"PATH_NOT_FOUND",
|
||||
"Optional memory directory is absent.");
|
||||
}
|
||||
Listings.TryGetValue(normalized, out var entries);
|
||||
entries ??= [];
|
||||
return Task.FromResult(new OpenClawWorkspaceCollectionDto(
|
||||
agentId,
|
||||
normalized,
|
||||
null,
|
||||
entries.Take(limit).ToArray(),
|
||||
entries.Count,
|
||||
offset,
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public async Task<OpenClawWorkspaceFileDto> GetWorkspaceFileAsync(
|
||||
string agentId,
|
||||
string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var active = Interlocked.Increment(ref activeReads);
|
||||
UpdateMaximum(active);
|
||||
try
|
||||
{
|
||||
if (ReadDelay > TimeSpan.Zero)
|
||||
await Task.Delay(ReadDelay, cancellationToken);
|
||||
return Files[path];
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref activeReads);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMaximum(int value)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = Volatile.Read(ref maxConcurrentReads);
|
||||
if (value <= current
|
||||
|| Interlocked.CompareExchange(
|
||||
ref maxConcurrentReads,
|
||||
value,
|
||||
current) == current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task<OpenClawAgentFileWriteDto> SetAgentFileAsync(
|
||||
string agentId,
|
||||
string fileName,
|
||||
UpdateOpenClawAgentFileRequest request,
|
||||
OpenClawInvocationContext invocationContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<OpenClawConfigSchemaLookupDto> GetConfigSchemaAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<OpenClawConfigSnapshotDto> GetConfigAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<OpenClawConfigPatchDto> PatchConfigAsync(
|
||||
PatchOpenClawConfigRequest request,
|
||||
OpenClawInvocationContext invocationContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class UnavailableMemoryService : IMemoryService
|
||||
{
|
||||
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync(
|
||||
string agentId = "iris",
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new OpenClawAgentConfigurationUnavailableException(
|
||||
"disconnected",
|
||||
"agents.files.list",
|
||||
"operator.read",
|
||||
"Gateway unavailable.");
|
||||
|
||||
public Task<IReadOnlyList<MemorySearchResult>> SearchAsync(
|
||||
string query,
|
||||
string agentId = "iris",
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<MemoryFileContent?> GetFileAsync(
|
||||
string name,
|
||||
string agentId = "iris",
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user