using System.Text.Json; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.FileRW; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.FileRW; /// /// Batch reading (#663) and the hint about the other workspace (#662). /// /// Occasion from test operation: the daily close read 24 hourly files individually and died /// at the step limit, and the same agent repeatedly fetched files in the wrong workspace, /// because the error message did not say where they lie. /// public sealed class BatchReadTests : IDisposable { private readonly string _basis; private readonly string _personal; private readonly string _shared; private readonly FileRWTool _tool = new(); public BatchReadTests() { _basis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _personal = Path.Combine(_basis, "Workspace"); _shared = Path.Combine(_basis, "SharedWorkspace"); Directory.CreateDirectory(_personal); Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden")); } public void Dispose() { try { Directory.Delete(_basis, recursive: true); } catch { /* Cleaning up is secondary */ } } private AgentToolContext Context(string sharedLevel = "ReadWrite") => new( "hausmeister", "inst", new Dictionary { ["sharedAccessLevel"] = sharedLevel }, new NullStateStore(), NullLogger.Instance, CancellationToken.None, WorkspacePath: _personal, SharedWorkspacePath: _shared); private Task Run(string json, string sharedLevel = "ReadWrite") => _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(sharedLevel), CancellationToken.None); private void HourlyFiles(int count, string tag = "2026-09-20") { for (var hour = 0; hour < count; hour++) { File.WriteAllText( Path.Combine(_shared, "wetter", "stunden", $"berlin_{tag}_{hour:00}-00.txt"), $"{tag} {hour:00}:00 Temperatur {10 + hour} C"); } } // ═══════════════════ #663: one call instead of 24 ═══════════════════ [Fact] public async Task A_directory_with_pattern_comes_in_one_call() { HourlyFiles(24); HourlyFiles(3, "2026-09-19"); var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden","pattern":"berlin_2026-09-20_*.txt"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("24 of 24 file(s) read"); result.Content.ShouldContain("Temperatur 10 C"); result.Content.ShouldContain("Temperatur 33 C"); result.Content.ShouldNotContain("2026-09-19", Case.Sensitive); } [Fact] public async Task The_files_come_named_and_in_fixed_order() { HourlyFiles(3); var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}"""); var sequence = new[] { "00-00.txt", "01-00.txt", "02-00.txt" } .Select(n => result.Content.IndexOf(n, StringComparison.Ordinal)).ToList(); sequence.ShouldAllBe(i => i > 0); sequence.ShouldBe(sequence.OrderBy(i => i).ToList(), "sortiert, damit der Agent den Tag zusammensetzen kann"); result.Content.ShouldContain("--- shared:/wetter/stunden/berlin_2026-09-20_01-00.txt ---"); } [Fact] public async Task An_explicit_list_is_read_and_missing_ones_named() { HourlyFiles(2); var result = await Run(""" {"action":"read_many","workspace":"shared","paths":[ "wetter/stunden/berlin_2026-09-20_00-00.txt", "wetter/stunden/berlin_2026-09-20_09-00.txt"]} """); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("1 of 2 file(s) read"); result.Content.ShouldContain("Not found: wetter/stunden/berlin_2026-09-20_09-00.txt"); } [Fact] public async Task Too_many_files_are_capped_and_the_rest_named() { HourlyFiles(60); var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain($"{FileRWTool.MaxFilesPerCall} of 60 file(s) read"); result.Content.ShouldContain("Skipped reading"); result.Content.ShouldContain("fetch with an additional call"); } [Fact] public async Task An_empty_directory_reports_as_an_error() { var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}"""); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("No matching file"); } [Fact] public async Task Without_read_right_on_the_shared_area_nothing_comes_out() { HourlyFiles(2); var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""", sharedLevel: "Denied"); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("Access denied"); } [Fact] public async Task Escaping_the_workspace_is_rejected_here_too() { var result = await Run("""{"action":"read_many","workspace":"shared","paths":["../../geheim.txt"]}"""); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("Access denied"); } [Fact] public async Task A_forbidden_file_extension_is_skipped_instead_of_toppling_the_run() { HourlyFiles(2); File.WriteAllText(Path.Combine(_shared, "wetter", "stunden", "skript.exe"), "MZ"); var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("2 of 2 file(s) read"); result.Content.ShouldNotContain("skript.exe"); } // ═══════════════════ #662: hint about the other workspace ═══════════════════ [Fact] public async Task If_the_file_is_in_the_other_workspace_the_message_says_so() { HourlyFiles(1); var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}"""); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("exists in the shared workspace"); result.ErrorMessage.ShouldContain("workspace: \"shared\"", Case.Sensitive); } [Fact] public async Task A_directory_gets_the_hint_too() { var result = await Run("""{"action":"list","path":"wetter/stunden"}"""); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("exists in the shared workspace"); } [Fact] public async Task Without_right_on_the_other_workspace_there_is_no_hint() { // A hint about something the agent may not do anyway sends it into nothing. HourlyFiles(1); var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""", sharedLevel: "Denied"); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldNotContain("exists in"); } [Fact] public async Task If_the_path_exists_nowhere_the_message_stays_plain() { var result = await Run("""{"action":"read","workspace":"shared","path":"wetter/stunden/fehlt.txt"}"""); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("File not found"); result.ErrorMessage.ShouldNotContain("exists in"); } }