feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// A3: das append-only Audit-Log und die Receipts. Gegen echte SQLite, weil Schema und
|
||||
/// Persistenz der Punkt sind.
|
||||
/// </summary>
|
||||
public sealed class AuditRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteAuditRepository _repo;
|
||||
|
||||
public AuditRepositoryTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repo = new SqliteAuditRepository(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private AuditEntry Entry(string runId, string tool, AuditStatus status = AuditStatus.Ok) => new()
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = "agent-a",
|
||||
Model = "test/model",
|
||||
Source = "task",
|
||||
Tool = tool,
|
||||
Arguments = "{\"x\":1}",
|
||||
Status = status,
|
||||
Summary = "",
|
||||
DurationMs = 5,
|
||||
OccurredAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Eintrag_wird_geschrieben_und_wiedergefunden()
|
||||
{
|
||||
await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
|
||||
|
||||
var recent = await _repo.ListRecentAsync(10, default);
|
||||
recent.Count.ShouldBe(1);
|
||||
recent[0].Tool.ShouldBe("FileRW");
|
||||
recent[0].RunId.ShouldBe("run-1");
|
||||
(await _repo.CountAsync(default)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Aufrufe_eines_Laufs_kommen_in_Reihenfolge()
|
||||
{
|
||||
await _repo.AppendAsync(Entry("run-1", "Memory"), default);
|
||||
await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
|
||||
await _repo.AppendAsync(Entry("run-2", "Mail"), default);
|
||||
|
||||
var forRun = await _repo.ListForRunAsync("run-1", default);
|
||||
forRun.Select(e => e.Tool).ShouldBe(["Memory", "FileRW"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_juengsten_Eintraege_kommen_zuerst()
|
||||
{
|
||||
await _repo.AppendAsync(Entry("run-1", "erst"), default);
|
||||
await _repo.AppendAsync(Entry("run-1", "zuletzt"), default);
|
||||
|
||||
(await _repo.ListRecentAsync(10, default))[0].Tool.ShouldBe("zuletzt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Status_ueberlebt_den_Rundlauf()
|
||||
{
|
||||
await _repo.AppendAsync(Entry("r", "X", AuditStatus.Denied), default);
|
||||
(await _repo.ListRecentAsync(1, default))[0].Status.ShouldBe(AuditStatus.Denied);
|
||||
}
|
||||
|
||||
// ─── Receipts ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Receipt_verknuepft_Lauf_und_Task()
|
||||
{
|
||||
await _repo.RecordReceiptAsync(new RunReceipt
|
||||
{
|
||||
RunId = "run-9",
|
||||
AgentId = "agent-a",
|
||||
Model = "test/model",
|
||||
Source = "task",
|
||||
TaskId = "t-42",
|
||||
Status = "Completed",
|
||||
StepCount = 3,
|
||||
PromptTokens = 100,
|
||||
CompletionTokens = 20,
|
||||
CachedTokens = 10,
|
||||
CostUsd = 0.0123m,
|
||||
CostIsKnown = true,
|
||||
DurationMs = 1200,
|
||||
ResultRef = "fertig",
|
||||
OccurredAt = DateTime.UtcNow
|
||||
}, default);
|
||||
|
||||
var byRun = await _repo.GetReceiptForRunAsync("run-9", default);
|
||||
byRun.ShouldNotBeNull();
|
||||
byRun!.TaskId.ShouldBe("t-42");
|
||||
byRun.CostUsd.ShouldBe(0.0123m);
|
||||
byRun.CostIsKnown.ShouldBeTrue();
|
||||
|
||||
var byTask = await _repo.ListReceiptsForTaskAsync("t-42", default);
|
||||
byTask.Count.ShouldBe(1);
|
||||
byTask[0].RunId.ShouldBe("run-9");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eintraege_ueberdauern_das_Schliessen_der_Datenbank()
|
||||
{
|
||||
await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
|
||||
|
||||
var reopened = new SqliteAuditRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
|
||||
(await reopened.CountAsync(default)).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
// Zwei Tool-Aufrufe, dann Text.
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool")
|
||||
.RespondsWithToolCall("TestTool")
|
||||
.RespondsWithText("fertig");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
var entries = audit.Entries.ToList();
|
||||
entries.Count.ShouldBe(2);
|
||||
entries.ShouldAllBe(e => e.Tool == "TestTool");
|
||||
entries.ShouldAllBe(e => e.Status == AuditStatus.Ok);
|
||||
}
|
||||
|
||||
[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();
|
||||
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 ist registriert, aber dem Agenten NICHT zugewiesen → PermissionGate greift.
|
||||
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
|
||||
var agent = fixture.AddAgent("agent-a" /* kein Tool zugewiesen */);
|
||||
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();
|
||||
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");
|
||||
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("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("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.Single().RunId.ShouldBe(runId, "alle Aufrufe eines Laufs tragen dieselbe RunId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Audit_Repository_laeuft_alles_normal_weiter()
|
||||
{
|
||||
// Gegenprobe: A3 ist optional — die Engine darf ohne Audit-Repo nicht anders laufen.
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user