349 lines
12 KiB
C#
349 lines
12 KiB
C#
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();
|
|
}
|
|
}
|