Wiederaufnahme nach Absturz, Nachschau und Auswertung (#553-#556)
#553 FileRW schreibt atomar. Die Vorkehrung lag seit #241 im selben Repository und wurde ueberall benutzt - Einstellungen, Sicherungen, Chat-Zustand, Identity.md - nur der Weg, ueber den Agenten ihre Arbeitsergebnisse ablegen, ging daran vorbei. Damit stand das Verhaeltnis auf dem Kopf: geschuetzt war das Eigene, ungeschuetzt das Ergebnis. append bleibt bewusst nicht atomar; das ginge nur, indem die ganze Datei neu geschrieben wird, und bei einer grossen Protokolldatei waere das der falsche Handel. #554 Wiederaufnahme. Neuer Eintrag AuditStatus.Started, geschrieben VOR der Wirkung und als einziger nicht "best effort" - auf ihn stuetzt sich alles Weitere, also laeuft der Aufruf gar nicht erst, wenn er nicht durchkommt. Neue Kennzeichnung IsRepeatable, getrennt von IsParallelSafe: Das eine heisst "stoert keinen gleichzeitigen Aufruf", das andere "richtet zweimal keinen Schaden an". CrashRecovery sichtet beim Start, was offen blieb, und stuft ein - fuehrt aber nichts aus. Es sichtet und berichtet; entscheiden soll der Aufrufer. Dabei kippte eine alte Regel: Bisher wurde ein Abbruch bewusst NICHT protokolliert. Das war richtig, solange es keinen Eintrag vor dem Aufruf gab. Jetzt saehe sonst jeder Benutzerabbruch beim naechsten Start aus wie ein Absturz - bis niemand mehr hinsieht. Deshalb AuditStatus.Cancelled. #555 Optionale Zustandspruefung IToolStateProbe, umgesetzt in FileRW. Unknown ist eine vollwertige Antwort, kein Fehlschlag: Ob ein SMTP-Server angenommen hat, laesst sich nicht feststellen, und ein Werkzeug, das hier raet, waere schlimmer als eines, das schweigt. Partial gilt ausdruecklich nicht als geklaert - einen halb angehaengten Text sollte niemand automatisch geraderuecken. #556 Auswertung. ListForAgentAsync und SummarizeToolUsageAsync. Die Dauer wird nur ueber abgeschlossene Aufrufe gemittelt; zaehlten die Started-Eintraege mit ihrer Dauer 0 mit, saehe jedes Werkzeug doppelt so schnell aus, wie es ist. Nebenbei: SqliteStorage kann jetzt Spalten nachruesten. CREATE TABLE IF NOT EXISTS allein genuegte nicht - bei einer bestehenden Datenbank tut die Anweisung nichts, und erst das naechste INSERT waere gescheitert, im Betrieb beim Anwender. Vier Audit-Tests angepasst: Ein ausgefuehrter Aufruf erzeugt jetzt zwei Eintraege statt einem. Ein Aufruf, der gar nicht erst laeuft - verweigert, unbekannt, vorgelegt - hat weiterhin genau einen; der Beginn wird erst nach Rechtepruefung und Staging geschrieben. 23 neue Tests, 912 gesamt, keine Regression. Build ohne Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,15 @@ namespace ClawdDotNet.Core.Tests.Audit;
|
||||
/// A3 an der Engine: Jeder Tool-Aufruf wird protokolliert, jeder Lauf bekommt einen Beleg.
|
||||
/// Entscheidend ist die Provenienz — die Engine stempelt Agent, Modell und Herkunft aus
|
||||
/// ihrem eigenen Wissen, nicht aus dem Tool-Ergebnis.
|
||||
///
|
||||
/// <para>Seit #554 erzeugt ein ausgefuehrter Aufruf ZWEI Eintraege: erst
|
||||
/// <see cref="AuditStatus.Started"/> vor der Wirkung, dann den Ausgang. Die Tests
|
||||
/// hier filtern deshalb auf den Abschluss, wo es um den Ausgang geht — dass der
|
||||
/// Beginn mitgeschrieben wird, pruefen die WiederaufnahmeTests.</para>
|
||||
///
|
||||
/// <para>Ein Aufruf, der gar nicht erst laeuft (verweigert, unbekannt, vorgelegt),
|
||||
/// hat weiterhin genau einen Eintrag: Der Beginn wird erst nach Rechtepruefung und
|
||||
/// Staging geschrieben.</para>
|
||||
/// </summary>
|
||||
public sealed class EngineAuditTests
|
||||
{
|
||||
@@ -27,9 +36,17 @@ public sealed class EngineAuditTests
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
var entries = audit.Entries.ToList();
|
||||
entries.Count.ShouldBe(2);
|
||||
|
||||
// Zwei Aufrufe, je ein Beginn und ein Abschluss.
|
||||
entries.Count.ShouldBe(4);
|
||||
entries.ShouldAllBe(e => e.Tool == "TestTool");
|
||||
entries.ShouldAllBe(e => e.Status == AuditStatus.Ok);
|
||||
entries.Count(e => e.Status == AuditStatus.Started).ShouldBe(2);
|
||||
entries.Count(e => e.Status == AuditStatus.Ok).ShouldBe(2);
|
||||
|
||||
// Jeder Beginn gehoert zu genau einem Abschluss.
|
||||
entries.Where(e => e.Status == AuditStatus.Started).Select(e => e.CallId)
|
||||
.ShouldBe(entries.Where(e => e.Status == AuditStatus.Ok).Select(e => e.CallId),
|
||||
ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -42,7 +59,7 @@ public sealed class EngineAuditTests
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-7");
|
||||
|
||||
var entry = audit.Entries.Single();
|
||||
var entry = audit.Entries.Single(e => e.Status == AuditStatus.Ok);
|
||||
entry.AgentId.ShouldBe("agent-a");
|
||||
entry.Model.ShouldBe("test/model");
|
||||
entry.Source.ShouldBe("task");
|
||||
@@ -74,7 +91,7 @@ public sealed class EngineAuditTests
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
var entry = audit.Entries.Single();
|
||||
var entry = audit.Entries.Single(e => e.Status != AuditStatus.Started);
|
||||
entry.Status.ShouldBe(AuditStatus.Error);
|
||||
entry.Summary.ShouldContain("kaputt");
|
||||
}
|
||||
@@ -108,7 +125,7 @@ public sealed class EngineAuditTests
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
var runId = audit.Receipts.Single().RunId;
|
||||
audit.Entries.Single().RunId.ShouldBe(runId, "alle Aufrufe eines Laufs tragen dieselbe RunId");
|
||||
audit.Entries.ShouldAllBe(e => e.RunId == runId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Wiederaufnahme nach hartem Absturz (#554) und die Auswertung des Protokolls (#556).
|
||||
/// </summary>
|
||||
public class WiederaufnahmeTests
|
||||
{
|
||||
// ─────────────── Der "angefangen"-Eintrag ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Vor_dem_Werkzeug_steht_ein_Eintrag_im_Protokoll()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
|
||||
var agent = fixture.AddAgent("a1", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool", id: "c1")
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
var eintraege = audit.Entries.Where(e => e.CallId == "c1").ToList();
|
||||
|
||||
// Genau zwei: erst der Beginn, dann der Abschluss — in dieser Reihenfolge.
|
||||
Assert.Equal(2, eintraege.Count);
|
||||
Assert.Equal(AuditStatus.Started, eintraege[0].Status);
|
||||
Assert.Equal(AuditStatus.Ok, eintraege[1].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_abgeschlossener_Aufruf_gilt_nicht_als_offen()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
|
||||
var agent = fixture.AddAgent("a1", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool", id: "c1")
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Abbruch_laesst_den_Aufruf_nicht_offen_stehen()
|
||||
{
|
||||
// Sonst sähe jeder gewöhnliche Benutzerabbruch beim nächsten Start aus wie
|
||||
// ein Absturz — und niemand sähe mehr hin.
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var fixture = new EngineFixture(audit).WithTool(
|
||||
FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool"));
|
||||
var agent = fixture.AddAgent("a1", "LangsamTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("LangsamTool", id: "c1")
|
||||
.AlwaysRespondsWithText("Fertig.");
|
||||
|
||||
var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
fixture.Engine.AbortChat("a1");
|
||||
await lauf;
|
||||
|
||||
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
|
||||
|
||||
var letzter = audit.Entries.Last(e => e.CallId == "c1");
|
||||
Assert.Equal(AuditStatus.Cancelled, letzter.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Aufruf_ohne_Abschluss_wird_als_offen_gefunden()
|
||||
{
|
||||
// Simuliert den harten Absturz: Der Beginn steht im Protokoll, mehr nicht.
|
||||
var audit = new InMemoryAuditRepository();
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Mail",
|
||||
Arguments = """{"to":"x@example.com"}""",
|
||||
Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var offen = await audit.ListOpenCallsAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(offen);
|
||||
Assert.Equal("Mail", offen[0].Tool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Alteintraege_ohne_CallId_gelten_nicht_als_offen()
|
||||
{
|
||||
// Sonst legte der erste Start nach der Aktualisierung das ganze Altprotokoll
|
||||
// zur Prüfung vor.
|
||||
var audit = new InMemoryAuditRepository();
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "", AgentId = "a1", Tool = "Mail",
|
||||
Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
// ─────────────── Die Einstufung ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_wiederholbares_Werkzeug_braucht_keine_Entscheidung()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var registry = new ToolRegistry();
|
||||
registry.Register(new WiederholbaresTool());
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Abruf",
|
||||
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
|
||||
var faelle = await recovery.SichtenAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(faelle);
|
||||
Assert.Equal(RecoveryVerdict.Repeatable, faelle[0].Verdict);
|
||||
Assert.True(faelle[0].Erledigt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_nicht_wiederholbares_Werkzeug_wird_vorgelegt()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var registry = new ToolRegistry();
|
||||
registry.Register(FakeTool.Returning("ok", "Versand")); // Vorgabe: nicht wiederholbar
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Versand",
|
||||
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
|
||||
var faelle = await recovery.SichtenAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(RecoveryVerdict.NeedsReview, faelle[0].Verdict);
|
||||
Assert.False(faelle[0].Erledigt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_verschwundenes_Werkzeug_wird_als_solches_gemeldet()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "GibtsNichtMehr",
|
||||
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
|
||||
var recovery = new CrashRecovery(audit, new ToolRegistry(), TestLogging.Factory);
|
||||
var faelle = await recovery.SichtenAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(RecoveryVerdict.ToolUnknown, faelle[0].Verdict);
|
||||
Assert.False(faelle[0].Erledigt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_geordnetem_Ende_ist_nichts_zu_sichten()
|
||||
{
|
||||
var recovery = new CrashRecovery(
|
||||
new InMemoryAuditRepository(), new ToolRegistry(), TestLogging.Factory);
|
||||
|
||||
Assert.Empty(await recovery.SichtenAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
// ─────────────── Die Auswertung (#556) ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Kennzahlen_zaehlen_nur_abgeschlossene_Aufrufe()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var jetzt = DateTime.Now;
|
||||
|
||||
// Zwei vollständige Aufrufe à 100 ms, einer davon gescheitert,
|
||||
// plus ein dritter, der nie endete.
|
||||
foreach (var (call, status, dauer) in new[]
|
||||
{
|
||||
("c1", AuditStatus.Ok, 100L),
|
||||
("c2", AuditStatus.Error, 100L),
|
||||
})
|
||||
{
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = call, AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
|
||||
DurationMs = 0, OccurredAt = jetzt
|
||||
}, CancellationToken.None);
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = call, AgentId = "a1", Tool = "T", Status = status,
|
||||
DurationMs = dauer, OccurredAt = jetzt
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = "c3", AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
|
||||
DurationMs = 0, OccurredAt = jetzt
|
||||
}, CancellationToken.None);
|
||||
|
||||
var stats = await audit.SummarizeToolUsageAsync("a1", null, CancellationToken.None);
|
||||
var t = stats.Single(s => s.Tool == "T");
|
||||
|
||||
Assert.Equal(2, t.Calls);
|
||||
Assert.Equal(1, t.Failed);
|
||||
Assert.Equal(1, t.Unfinished);
|
||||
|
||||
// 200 ms auf zwei abgeschlossene Aufrufe. Zählten die drei Started-Einträge
|
||||
// mit ihrer Dauer 0 mit, stünde hier 40 — das Werkzeug sähe doppelt so
|
||||
// schnell aus, wie es ist.
|
||||
Assert.Equal(100d, t.AverageDurationMs);
|
||||
Assert.Equal(0.5d, t.FailureRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Abfrage_je_Agent_trennt_die_Agenten()
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
|
||||
foreach (var agent in new[] { "a1", "a1", "a2" })
|
||||
{
|
||||
await audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
CallId = Guid.NewGuid().ToString("N"), AgentId = agent, Tool = "T",
|
||||
Status = AuditStatus.Ok, OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
var fuerA1 = await audit.ListForAgentAsync("a1", null, 100, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, fuerA1.Count);
|
||||
Assert.All(fuerA1, e => Assert.Equal("a1", e.AgentId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ein Werkzeug, das sich als wiederholbar ausgibt.</summary>
|
||||
internal sealed class WiederholbaresTool : IAgentTool
|
||||
{
|
||||
public string Name => "Abruf";
|
||||
public string Description => "Liest nur.";
|
||||
public bool IsRepeatable => true;
|
||||
|
||||
public System.Text.Json.JsonElement InputSchema { get; } =
|
||||
System.Text.Json.JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(
|
||||
System.Text.Json.JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
=> Task.FromResult(ToolResult.Ok("ok"));
|
||||
}
|
||||
@@ -37,4 +37,58 @@ internal sealed class InMemoryAuditRepository : IAuditRepository
|
||||
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
|
||||
|
||||
/// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary>
|
||||
public Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
|
||||
{
|
||||
var alle = Entries.ToList();
|
||||
|
||||
var abgeschlossen = alle
|
||||
.Where(e => e.Status != AuditStatus.Started && e.CallId.Length > 0)
|
||||
.Select(e => e.CallId)
|
||||
.ToHashSet();
|
||||
|
||||
var offen = alle
|
||||
.Where(e => e.Status == AuditStatus.Started
|
||||
&& e.CallId.Length > 0
|
||||
&& !abgeschlossen.Contains(e.CallId))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEntry>>(offen);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
|
||||
string agentId, DateTime? seit, int limit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<AuditEntry>>(
|
||||
Entries.Where(e => e.AgentId == agentId)
|
||||
.Where(e => seit is null || e.OccurredAt >= seit)
|
||||
.Reverse()
|
||||
.Take(limit)
|
||||
.ToList());
|
||||
|
||||
public Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
|
||||
string? agentId, DateTime? seit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<ToolUsageStat>>(
|
||||
Entries.Where(e => agentId is null || e.AgentId == agentId)
|
||||
.Where(e => seit is null || e.OccurredAt >= seit)
|
||||
.GroupBy(e => (e.AgentId, e.Tool))
|
||||
.Select(g =>
|
||||
{
|
||||
var abgeschlossen = g.Where(e => e.Status != AuditStatus.Started).ToList();
|
||||
var begonnen = g.Count(e => e.Status == AuditStatus.Started);
|
||||
|
||||
return new ToolUsageStat
|
||||
{
|
||||
AgentId = g.Key.AgentId,
|
||||
Tool = g.Key.Tool,
|
||||
Calls = abgeschlossen.Count,
|
||||
Failed = abgeschlossen.Count(e => e.Status == AuditStatus.Error),
|
||||
Denied = abgeschlossen.Count(e => e.Status == AuditStatus.Denied),
|
||||
Unfinished = Math.Max(0, begonnen - abgeschlossen.Count),
|
||||
TotalDurationMs = abgeschlossen.Sum(e => e.DurationMs),
|
||||
MaxDurationMs = g.Max(e => e.DurationMs)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(s => s.Calls)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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>
|
||||
/// Die Nachschau nach einem Absturz (#555) und das atomare Schreiben (#553).
|
||||
///
|
||||
/// Das Dateisystem wird auch hier nicht abstrahiert — es geht gerade darum, was auf der
|
||||
/// Platte steht, nachdem ein Aufruf unterbrochen wurde.
|
||||
/// </summary>
|
||||
public sealed class NachschauTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly FileRWTool _tool = new();
|
||||
|
||||
public NachschauTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private AgentToolContext Context() => new(
|
||||
"a1", "inst",
|
||||
new Dictionary<string, object?>(),
|
||||
new NullStateStore(),
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None,
|
||||
WorkspacePath: _root);
|
||||
|
||||
private Task<ProbeFinding> Probe(string json)
|
||||
=> _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
||||
|
||||
private Task<ToolResult> Run(string json)
|
||||
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
||||
|
||||
// ═══════════════════ #553: atomar schreiben ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Schreiben_hinterlaesst_keine_Nebendateien()
|
||||
{
|
||||
var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}""");
|
||||
result.Success.ShouldBeTrue();
|
||||
|
||||
// Das atomare Schreiben geht über eine Nebendatei — die muss danach weg sein.
|
||||
Directory.GetFiles(_root).ShouldHaveSingleItem();
|
||||
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("Inhalt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ueberschreiben_ersetzt_den_Inhalt_vollstaendig()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}""");
|
||||
await Run("""{"action":"write","path":"a.txt","content":"kurz"}""");
|
||||
|
||||
// Beim Ersetzen darf kein Rest der alten, längeren Fassung stehenbleiben.
|
||||
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("kurz");
|
||||
}
|
||||
|
||||
// ═══════════════════ #555: Nachschau bei write ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Fehlende_Datei_heisst_nichts_geschehen()
|
||||
{
|
||||
var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"a.txt","content":"alt"}""");
|
||||
|
||||
// So sähe es aus, wenn der Absturz vor dem Ersetzen kam.
|
||||
var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
// ═══════════════════ #555: Nachschau bei append ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Vollstaendig_angehaengter_Text_wird_erkannt()
|
||||
{
|
||||
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
||||
await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt()
|
||||
{
|
||||
// Anhängen ist nicht atomar — genau dieser Zustand kann wirklich entstehen.
|
||||
// Hier wird er von Hand hergestellt, weil ein echter Riss nicht reproduzierbar ist.
|
||||
var pfad = Path.Combine(_root, "log.txt");
|
||||
await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang");
|
||||
|
||||
var befund = await Probe(
|
||||
"""{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Partial);
|
||||
befund.Evidence.ShouldContain("von");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen()
|
||||
{
|
||||
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
||||
|
||||
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
||||
}
|
||||
|
||||
// ═══════════════════ Die Grenzen der Nachschau ═══════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData("read")]
|
||||
[InlineData("delete")]
|
||||
[InlineData("list")]
|
||||
public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion)
|
||||
{
|
||||
var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme()
|
||||
{
|
||||
// Die Nachschau darf nie werfen: Ein Fehlschlag der Nachschau ist eine Aussage
|
||||
// über die Nachschau, nicht über die Welt.
|
||||
var befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}""");
|
||||
|
||||
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullStateStore : IStateStore
|
||||
{
|
||||
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
Reference in New Issue
Block a user