#663: Neue Aktion read_many liest mehrere Dateien in einem Aufruf - entweder paths (Liste) oder path (Verzeichnis) mit pattern. Gedeckelt auf 50 Dateien und 200.000 Zeichen; was nicht mehr hineinpasst, wird namentlich genannt, damit der Agent gezielt nachholen kann. Anlass: Der Tagesabschluss im Testbetrieb las 24 Stundendateien einzeln und starb zweimal an der Schrittgrenze - die soll Endlosschleifen abfangen, nicht ehrliche Arbeit. #662: Schlaegt read oder list fehl, weil der Pfad im anderen Workspace liegt, sagt die Fehlermeldung das - aber nur, wenn der Agent diesen Workspace auch benutzen darf. Guenstige Modelle vergassen workspace "shared" regelmaessig und verbrauchten mit jedem Rateversuch einen Schritt. Dateipruefung, Endungen und Workspace-Grenze gelten in read_many unveraendert; eine verbotene Endung wird uebergangen statt den Lauf zu kippen. Version 0.1.14. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Sammel-Lesen (#663) und der Hinweis auf den anderen Workspace (#662).
|
||||
///
|
||||
/// Anlass aus dem Testbetrieb: Der Tagesabschluss las 24 Stundendateien einzeln und starb
|
||||
/// an der Schrittgrenze, und derselbe Agent rief Dateien wiederholt im falschen Workspace
|
||||
/// ab, weil die Fehlermeldung nicht sagte, wo sie liegen.
|
||||
/// </summary>
|
||||
public sealed class SammelLesenTests : IDisposable
|
||||
{
|
||||
private readonly string _basis;
|
||||
private readonly string _personal;
|
||||
private readonly string _shared;
|
||||
private readonly FileRWTool _tool = new();
|
||||
|
||||
public SammelLesenTests()
|
||||
{
|
||||
_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 { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private AgentToolContext Context(string sharedLevel = "ReadWrite") => new(
|
||||
"hausmeister", "inst",
|
||||
new Dictionary<string, object?> { ["sharedAccessLevel"] = sharedLevel },
|
||||
new NullStateStore(),
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None,
|
||||
WorkspacePath: _personal,
|
||||
SharedWorkspacePath: _shared);
|
||||
|
||||
private Task<ToolResult> Run(string json, string sharedLevel = "ReadWrite")
|
||||
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(sharedLevel), CancellationToken.None);
|
||||
|
||||
private void Stundendateien(int anzahl, string tag = "2026-09-20")
|
||||
{
|
||||
for (var stunde = 0; stunde < anzahl; stunde++)
|
||||
{
|
||||
File.WriteAllText(
|
||||
Path.Combine(_shared, "wetter", "stunden", $"berlin_{tag}_{stunde:00}-00.txt"),
|
||||
$"{tag} {stunde:00}:00 Temperatur {10 + stunde} C");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════ #663: ein Aufruf statt 24 ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Verzeichnis_mit_Muster_kommt_in_einem_Aufruf()
|
||||
{
|
||||
Stundendateien(24);
|
||||
Stundendateien(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 von 24 Datei(en) gelesen");
|
||||
result.Content.ShouldContain("Temperatur 10 C");
|
||||
result.Content.ShouldContain("Temperatur 33 C");
|
||||
result.Content.ShouldNotContain("2026-09-19", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Dateien_kommen_benannt_und_in_fester_Reihenfolge()
|
||||
{
|
||||
Stundendateien(3);
|
||||
|
||||
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
|
||||
|
||||
var reihenfolge = new[] { "00-00.txt", "01-00.txt", "02-00.txt" }
|
||||
.Select(n => result.Content.IndexOf(n, StringComparison.Ordinal)).ToList();
|
||||
reihenfolge.ShouldAllBe(i => i > 0);
|
||||
reihenfolge.ShouldBe(reihenfolge.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 Eine_ausdrueckliche_Liste_wird_gelesen_und_Fehlendes_benannt()
|
||||
{
|
||||
Stundendateien(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 von 2 Datei(en) gelesen");
|
||||
result.Content.ShouldContain("Nicht gefunden: wetter/stunden/berlin_2026-09-20_09-00.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Zu_viele_Dateien_werden_gedeckelt_und_der_Rest_genannt()
|
||||
{
|
||||
Stundendateien(60);
|
||||
|
||||
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
|
||||
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
result.Content.ShouldContain($"{FileRWTool.MaxDateienJeAufruf} von 60 Datei(en) gelesen");
|
||||
result.Content.ShouldContain("Nicht mehr gelesen");
|
||||
result.Content.ShouldContain("hole sie mit einem weiteren Aufruf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_leeres_Verzeichnis_meldet_sich_als_Fehler()
|
||||
{
|
||||
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Keine passende Datei");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Leserecht_am_gemeinsamen_Bereich_kommt_nichts_heraus()
|
||||
{
|
||||
Stundendateien(2);
|
||||
|
||||
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""", sharedLevel: "Denied");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Zugriff verweigert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Ausbruch_aus_dem_Workspace_wird_auch_hier_abgewiesen()
|
||||
{
|
||||
var result = await Run("""{"action":"read_many","workspace":"shared","paths":["../../geheim.txt"]}""");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Zugriff verweigert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_verbotene_Dateiendung_wird_uebergangen_statt_den_Lauf_zu_kippen()
|
||||
{
|
||||
Stundendateien(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 von 2 Datei(en) gelesen");
|
||||
result.Content.ShouldNotContain("skript.exe");
|
||||
}
|
||||
|
||||
// ═══════════════════ #662: Hinweis auf den anderen Workspace ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Liegt_die_Datei_im_anderen_Workspace_sagt_die_Meldung_das()
|
||||
{
|
||||
Stundendateien(1);
|
||||
|
||||
var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage.ShouldNotBeNull().ShouldContain("existiert im shared Workspace");
|
||||
result.ErrorMessage.ShouldContain("workspace: \"shared\"", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auch_ein_Verzeichnis_bekommt_den_Hinweis()
|
||||
{
|
||||
var result = await Run("""{"action":"list","path":"wetter/stunden"}""");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage.ShouldNotBeNull().ShouldContain("existiert im shared Workspace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Recht_am_anderen_Workspace_gibt_es_keinen_Hinweis()
|
||||
{
|
||||
// Ein Hinweis auf etwas, das der Agent ohnehin nicht darf, schickt ihn ins Leere.
|
||||
Stundendateien(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("existiert im");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gibt_es_den_Pfad_nirgends_bleibt_die_Meldung_schlicht()
|
||||
{
|
||||
var result = await Run("""{"action":"read","workspace":"shared","path":"wetter/stunden/fehlt.txt"}""");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Datei nicht gefunden");
|
||||
result.ErrorMessage.ShouldNotContain("existiert im");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user