Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
5.9 KiB
C#
144 lines
5.9 KiB
C#
using ClawdDotNet.Core.Audit;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Audit;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>Since #554 an executed call produces TWO entries: first
|
|
/// <see cref="AuditStatus.Started"/> 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.</para>
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
public sealed class EngineAuditTests
|
|
{
|
|
[Fact]
|
|
public async Task Every_tool_call_ends_up_in_the_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 The_origin_is_stamped_by_the_engine()
|
|
{
|
|
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 A_refused_call_is_logged_as_denied()
|
|
{
|
|
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 A_tool_error_is_logged_as_error()
|
|
{
|
|
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 A_run_gets_a_receipt_linked_to_the_task()
|
|
{
|
|
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_and_audit_share_the_same_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 Without_audit_repository_everything_keeps_running_normally()
|
|
{
|
|
// 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");
|
|
}
|
|
}
|