#631: Derselbe Vorschlag (gleicher Agent, gleiches Werkzeug, gleiche Argumente unabhaengig von Schluesselreihenfolge) wird nicht erneut angelegt - der Agent erfaehrt die bestehende Nummer. Offene Vorschlaege verfallen nach 24 h (Status Expired). #609: Werkzeuge koennen den Freigabeschluessel aus dem Inhalt bestimmen (IAgentTool.StagingAktion). Database: SELECT/SHOW/DESCRIBE -> query, INSERT/UPDATE/DELETE -> write, DDL -> schema; write und schema sind freigabepflichtig. Ausgefuehrt wird nach Statement-Art, schreibende Statements melden die Zeilenzahl. #579: Database-Vorpruefung (Zugriffsebene, Tabellenfreigabe, SqlGuard) - was das Werkzeug ablehnen wuerde, kommt nicht mehr in die Freigabeliste. #610: SqlGuard ueberspringt IF [NOT] EXISTS und erkennt DESCRIBE/DESC <tabelle>. #632: WebFetch fetch mit filter liefert aus JSON/JSONP nur passende Eintraege; beim Blaettern durch grosses JSON empfiehlt der Hinweis den filter. Version 0.1.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,4 +49,27 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
|
||||
|
||||
public Task<int> CountPendingAsync(CancellationToken ct)
|
||||
=> Task.FromResult(_calls.Values.Count(c => c.Status == StagingStatus.Pending));
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListPendingForAgentAsync(string agentId, string tool, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StagedCall>>(
|
||||
_calls.Values.Where(c => c.Status == StagingStatus.Pending && c.AgentId == agentId && c.Tool == tool)
|
||||
.OrderBy(c => c.Id).ToList());
|
||||
|
||||
public Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var alt = _calls.Values.Where(c => c.Status == StagingStatus.Pending && c.CreatedAt < createdBefore).ToList();
|
||||
foreach (var c in alt)
|
||||
_calls[c.Id] = c with { Status = StagingStatus.Expired, DecidedAt = now, DecidedBy = "system" };
|
||||
return Task.FromResult(alt.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary>
|
||||
public void Altern(long id, TimeSpan um)
|
||||
{
|
||||
if (_calls.TryGetValue(id, out var call))
|
||||
_calls[id] = call with { CreatedAt = call.CreatedAt - um };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// Ordnung in der Freigabeliste (#631) und der Freigabeschluessel nach Inhalt (#609).
|
||||
///
|
||||
/// Anlass: Nach knapp drei Tagen Testbetrieb standen 110 Freigaben an. Derselbe
|
||||
/// Loeschvorschlag kam jede Nacht erneut, niemand entschied, und am Ende wurde in 98 Sekunden
|
||||
/// alles freigegeben - 39 Loeschungen scheiterten, weil die Datei schon weg war.
|
||||
/// </summary>
|
||||
public sealed class FreigabeHygieneTests
|
||||
{
|
||||
private static StagingGate Gate(InMemoryStagingRepository repo, string schluessel = "FileRW.delete")
|
||||
=> new(new StagingPolicy(new Dictionary<string, StagingDecision> { [schluessel] = StagingDecision.Approve }), repo);
|
||||
|
||||
private static Task<StagingInterception> Vorlegen(StagingGate gate, string args, string agent = "hausmeister")
|
||||
=> gate.InterceptAsync(agent, "inst", "run", "FileRW", args, CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public async Task Derselbe_Vorschlag_wird_nicht_zweimal_angelegt_auch_bei_anderer_Schluesselreihenfolge()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
var erster = await Vorlegen(gate, """{"action":"delete","workspace":"shared","path":"wetter/a.txt"}""");
|
||||
var zweiter = await Vorlegen(gate, """{ "action": "delete", "path": "wetter/a.txt", "workspace": "shared" }""");
|
||||
|
||||
zweiter.Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
zweiter.StagedId.ShouldBe(erster.StagedId);
|
||||
zweiter.Message.ShouldContain($"bereits als #{erster.StagedId}");
|
||||
(await repo.CountPendingAsync(default)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Andere_Argumente_oder_ein_anderer_Agent_sind_ein_neuer_Vorschlag()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
await Vorlegen(gate, """{"action":"delete","path":"wetter/b.txt"}""");
|
||||
await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""", agent: "redaktion");
|
||||
|
||||
(await repo.CountPendingAsync(default)).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_entschiedener_Vorschlag_darf_erneut_vorgelegt_werden()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
var erster = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
await repo.TryTransitionAsync(erster.StagedId, StagingStatus.Pending, StagingStatus.Rejected, "mensch", "nein", DateTime.UtcNow, default);
|
||||
var zweiter = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
|
||||
zweiter.StagedId.ShouldNotBe(erster.StagedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Offene_Vorschlaege_verfallen_nach_der_Frist()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
var alt = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
repo.Altern(alt.StagedId, StagingGate.StandardVerfall + TimeSpan.FromMinutes(1));
|
||||
var neu = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
|
||||
(await repo.GetAsync(alt.StagedId, default))!.Status.ShouldBe(StagingStatus.Expired);
|
||||
neu.StagedId.ShouldNotBe(alt.StagedId, "der verfallene Vorschlag zaehlt nicht mehr als Dublette");
|
||||
(await repo.CountPendingAsync(default)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kanonische_Form_ignoriert_Reihenfolge_und_Leerraum_aber_nicht_Werte()
|
||||
{
|
||||
StagingGate.Kanonisch("""{"b":1,"a":{"y":2,"x":[1,2]}}""")
|
||||
.ShouldBe(StagingGate.Kanonisch("""{ "a": { "x": [1, 2], "y": 2 }, "b": 1 }"""));
|
||||
StagingGate.Kanonisch("""{"a":1}""").ShouldNotBe(StagingGate.Kanonisch("""{"a":2}"""));
|
||||
}
|
||||
|
||||
/// <summary>Werkzeug, das seinen Freigabeschluessel aus dem Inhalt bestimmt - wie Database.</summary>
|
||||
private sealed class InhaltsTool : IAgentTool
|
||||
{
|
||||
public string Name => "Database";
|
||||
public string Description => "Test";
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
|
||||
public int Aufrufe { get; private set; }
|
||||
|
||||
public string? StagingAktion(JsonElement input)
|
||||
=> input.TryGetProperty("sql", out var sql) && sql.GetString()!.StartsWith("INSERT") ? "write" : "query";
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
Aufrufe++;
|
||||
return Task.FromResult(ToolResult.Ok("1 Zeilen betroffen."));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Freigabeschluessel_des_Werkzeugs_schlaegt_das_action_Argument()
|
||||
{
|
||||
// #609: Dasselbe INSERT lief als action "query" eine Sekunde spaeter ohne Freigabe durch.
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var tool = new InhaltsTool();
|
||||
var fixture = new EngineFixture(staging: Gate(repo, "Database.write")).WithTool(tool);
|
||||
var agent = fixture.AddAgent("datenwart", "Database");
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("Database", """{"action":"query","sql":"INSERT INTO protokoll VALUES (1)"}""")
|
||||
.RespondsWithToolCall("Database", """{"action":"query","sql":"SELECT * FROM protokoll"}""")
|
||||
.RespondsWithText("ERGEBNIS: ok - fertig");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "inst", default);
|
||||
|
||||
tool.Aufrufe.ShouldBe(1, "nur das SELECT laeuft ohne Freigabe");
|
||||
var offen = await repo.ListPendingAsync(default);
|
||||
offen.ShouldHaveSingleItem().Action.ShouldBe("write");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user