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()); 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(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 AgentFiles { get; set; } = []; public string AgentFileContent { get; set; } = string.Empty; public ConcurrentDictionary< string, IReadOnlyList> Listings { get; } = new(StringComparer.Ordinal); public ConcurrentDictionary Files { get; } = new(StringComparer.Ordinal); public TimeSpan ReadDelay { get; set; } public bool MissingMemoryDirectory { get; set; } public int MaxConcurrentReads => Volatile.Read(ref maxConcurrentReads); public Task GetAgentFilesAsync( string agentId, CancellationToken cancellationToken = default) => Task.FromResult(new OpenClawAgentFileCollectionDto( agentId, AgentFiles, DateTimeOffset.UtcNow)); public Task 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 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 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 SetAgentFileAsync( string agentId, string fileName, UpdateOpenClawAgentFileRequest request, OpenClawInvocationContext invocationContext, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetConfigSchemaAsync( string path, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetConfigAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task PatchConfigAsync( PatchOpenClawConfigRequest request, OpenClawInvocationContext invocationContext, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class UnavailableMemoryService : IMemoryService { public Task> GetAllAsync( string agentId = "iris", CancellationToken cancellationToken = default) => throw new OpenClawAgentConfigurationUnavailableException( "disconnected", "agents.files.list", "operator.read", "Gateway unavailable."); public Task> SearchAsync( string query, string agentId = "iris", CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetFileAsync( string name, string agentId = "iris", CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }