using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Audit; /// /// A3 on the engine: every tool call is logged, every run gets a receipt. /// What matters is the provenance — the engine stamps agent, model and origin from /// its own knowledge, not from the tool result. /// /// Since #554 an executed call produces TWO entries: first /// before the effect, then the outcome. The tests /// here therefore filter on the completion where the outcome is concerned — that the /// start is recorded too is checked by the WiederaufnahmeTests. /// /// A call that does not even run (refused, unknown, presented) /// still has exactly one entry: the start is only written after the permission check and /// staging. /// public sealed class EngineAuditTests { [Fact] public async Task Jeder_Tool_Aufruf_landet_im_Audit_Log() { var audit = new InMemoryAuditRepository(); var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-a", "TestTool"); // Two tool calls, then text. fixture.Client .RespondsWithToolCall("TestTool") .RespondsWithToolCall("TestTool") .RespondsWithText("fertig"); await fixture.Engine.RunAsync(agent, "los", "test-instance", default); var entries = audit.Entries.ToList(); // Two calls, one start and one completion each. entries.Count.ShouldBe(4); entries.ShouldAllBe(e => e.Tool == "TestTool"); entries.Count(e => e.Status == AuditStatus.Started).ShouldBe(2); entries.Count(e => e.Status == AuditStatus.Ok).ShouldBe(2); // Every start belongs to exactly one completion. 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] public async Task Die_Herkunft_wird_von_der_Engine_gestempelt() { var audit = new InMemoryAuditRepository(); var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-a", "TestTool"); fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-7"); var entry = audit.Entries.Single(e => e.Status == AuditStatus.Ok); entry.AgentId.ShouldBe("agent-a"); entry.Model.ShouldBe("test/model"); entry.Source.ShouldBe("task"); entry.RunId.ShouldNotBeNullOrWhiteSpace(); } [Fact] public async Task Ein_verweigerter_Aufruf_wird_als_denied_protokolliert() { var audit = new InMemoryAuditRepository(); // Tool is registered but NOT assigned to the agent → PermissionGate applies. var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-a" /* no tool assigned */); fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); await fixture.Engine.RunAsync(agent, "los", "test-instance", default); audit.Entries.Single().Status.ShouldBe(AuditStatus.Denied); } [Fact] public async Task Ein_Tool_Fehler_wird_als_error_protokolliert() { var audit = new InMemoryAuditRepository(); var fixture = new EngineFixture(audit) .WithTool(FakeTool.Throwing(new InvalidOperationException("kaputt"))); var agent = fixture.AddAgent("agent-a", "TestTool"); fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); await fixture.Engine.RunAsync(agent, "los", "test-instance", default); var entry = audit.Entries.Single(e => e.Status != AuditStatus.Started); entry.Status.ShouldBe(AuditStatus.Error); entry.Summary.ShouldContain("kaputt"); } [Fact] public async Task Ein_Lauf_bekommt_einen_Receipt_mit_Task_Verknuepfung() { var audit = new InMemoryAuditRepository(); var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-a", "TestTool"); // Since #577 task runs only end with a result line. fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("ERGEBNIS: ok - erledigt"); await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-42"); var receipt = audit.Receipts.Single(); receipt.TaskId.ShouldBe("t-42"); receipt.Source.ShouldBe("task"); receipt.Status.ShouldBe("Completed"); receipt.ResultRef.ShouldBe("ERGEBNIS: ok - erledigt"); receipt.StepCount.ShouldBeGreaterThan(0); } [Fact] public async Task Receipt_und_Audit_teilen_dieselbe_RunId() { var audit = new InMemoryAuditRepository(); var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-a", "TestTool"); fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); await fixture.Engine.RunAsync(agent, "los", "test-instance", default); var runId = audit.Receipts.Single().RunId; audit.Entries.ShouldAllBe(e => e.RunId == runId); } [Fact] public async Task Ohne_Audit_Repository_laeuft_alles_normal_weiter() { // Counter-check: A3 is optional — the engine must not run differently without an audit repo. var fixture = new EngineFixture().WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-a", "TestTool"); fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); var result = await fixture.Engine.RunAsync(agent, "los", "test-instance", default); result.Status.ToString().ShouldBe("Completed"); } }