using System.Diagnostics; using System.Text.Json; using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; namespace ClawdDotNet.Core.Tests.Engine; /// /// Die Härtung des Harness gegen stillen Verlust: #547 bis #551. /// /// Was diese fünf Befunde verbindet, ist nicht der Ort im Code, sondern der /// Fehlermodus — keiner von ihnen erzeugt eine Fehlermeldung. Der Lauf wird fertig, /// das Ergebnis sieht plausibel aus, und nur die Qualität ist schlechter. Genau /// deshalb braucht jeder einen Test: Von Hand fällt so etwas nicht auf. /// public class HarnessHaertungTests { // ─────────────────────────── #547 ─────────────────────────── [Fact] public async Task Der_Begleittext_zum_Werkzeugaufruf_bleibt_im_Verlauf() { var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis")); var agent = fixture.AddAgent("a1", "TestTool"); fixture.Client .RespondsWithToolCallAndContent("TestTool", "Ich sehe zuerst in der Konfiguration nach.") .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); // Die zweite Anfrage trägt den Verlauf, den die erste erzeugt hat. var verlauf = fixture.Client.ReceivedRequests[1].Messages; var mitAufruf = verlauf.Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }); Assert.Equal("Ich sehe zuerst in der Konfiguration nach.", mitAufruf.Content); } [Fact] public async Task Ohne_Begleittext_bleibt_die_Nachricht_ohne_Inhalt() { // Gegenprobe: Ein leerer Text darf nicht als "" mitgehen. Manche Anbieter // lehnen eine assistant-Nachricht mit leerem Inhalt und tool_calls ab. var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis")); var agent = fixture.AddAgent("a1", "TestTool"); fixture.Client .RespondsWithToolCall("TestTool") .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); var mitAufruf = fixture.Client.ReceivedRequests[1].Messages .Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }); Assert.Null(mitAufruf.Content); } // ─────────────────────────── #548 ─────────────────────────── [Theory] [InlineData("length")] [InlineData("max_tokens")] [InlineData("LENGTH")] public void Ein_Abbruch_an_der_Ausgabegrenze_wird_erkannt(string grund) => Assert.True(AgentEngine.IsTruncated(grund)); [Theory] [InlineData("stop")] [InlineData("tool_calls")] [InlineData("end_turn")] [InlineData(null)] public void Ein_regulaeres_Ende_gilt_nicht_als_Abbruch(string? grund) => Assert.False(AgentEngine.IsTruncated(grund)); [Fact] public async Task Eine_abgeschnittene_Antwort_wird_nicht_als_fertig_gemeldet() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); fixture.Client.RespondsWithTruncatedText("Der Bericht beginnt mit"); var result = await fixture.Engine.RunAsync(agent, "Bericht", "inst", CancellationToken.None); Assert.Equal(AgentRunStatus.Truncated, result.Status); // Das Teilergebnis geht trotzdem mit — es wegzuwerfen wäre schlimmer, als es // gekennzeichnet weiterzureichen. Assert.Equal("Der Bericht beginnt mit", result.FinalMessage); } [Fact] public async Task Eine_vollstaendige_Antwort_bleibt_Completed() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); fixture.Client.RespondsWithText("Fertiger Bericht."); var result = await fixture.Engine.RunAsync(agent, "Bericht", "inst", CancellationToken.None); Assert.Equal(AgentRunStatus.Completed, result.Status); } // ─────────────────────────── #549 ─────────────────────────── [Fact] public async Task Der_Denkverlauf_geht_unveraendert_zurueck() { const string details = """ [{"type":"reasoning.text","text":"Erst die Konfiguration prüfen.","id":"r1","format":"anthropic-claude-v1","index":0,"signature":"sha256:abc"}] """; var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis")); var agent = fixture.AddAgent("a1", "TestTool"); fixture.Client .RespondsWithToolCallAndContent("TestTool", "Ich prüfe.", reasoningDetailsJson: details) .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); var mitAufruf = fixture.Client.ReceivedRequests[1].Messages .Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }); Assert.NotNull(mitAufruf.ReasoningDetails); // Signatur und Reihenfolge müssen erhalten sein — daran erkennt der Anbieter // seinen eigenen Block wieder. var block = mitAufruf.ReasoningDetails!.Value[0]; Assert.Equal("reasoning.text", block.GetProperty("type").GetString()); Assert.Equal("sha256:abc", block.GetProperty("signature").GetString()); } [Fact] public void Der_Denkverlauf_ueberlebt_Serialisierung_und_Rueckweg() { // Der Verlauf wird auf die Platte geschrieben und beim Start wieder geladen. // Überlebt der Denkverlauf das nicht, ist er nach einem Neustart weg. const string details = """ [{"type":"reasoning.encrypted","data":"AAAA","id":"r1","index":0}] """; var original = ChatMessage.AssistantWithToolCalls( [new ToolCall { Id = "c1", Type = "function", Function = new ToolCallFunction { Name = "T", Arguments = "{}" } }], content: "Text", reasoningDetails: JsonDocument.Parse(details).RootElement.Clone()); var json = JsonSerializer.Serialize(original); var zurueck = JsonSerializer.Deserialize(json)!; Assert.Equal("Text", zurueck.Content); Assert.NotNull(zurueck.ReasoningDetails); Assert.Equal("reasoning.encrypted", zurueck.ReasoningDetails!.Value[0].GetProperty("type").GetString()); Assert.Equal("AAAA", zurueck.ReasoningDetails!.Value[0].GetProperty("data").GetString()); } [Fact] public void Ohne_Denkverlauf_steht_kein_leeres_Feld_in_der_Anfrage() { var ohne = ChatMessage.Assistant("Nur Text"); var json = JsonSerializer.Serialize(ohne); Assert.DoesNotContain("reasoning", json); } // ─────────────────────────── #550 ─────────────────────────── [Fact] public async Task Die_Anfrage_verlangt_einen_Anbieter_der_alles_beherrscht() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); fixture.Client.RespondsWithText("ok"); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); var request = fixture.Client.ReceivedRequests[0]; Assert.True(request.Provider?.RequireParameters); Assert.True(request.Usage?.Include); } [Fact] public async Task Die_Anbieterbindung_laesst_sich_je_Agent_abschalten() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); agent.RequireProviderParameters = false; fixture.Client.RespondsWithText("ok"); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); Assert.Null(fixture.Client.ReceivedRequests[0].Provider); } [Fact] public async Task Ohne_Denktiefe_geht_kein_reasoning_Feld_mit() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); // Vorgabe ist "off" fixture.Client.RespondsWithText("ok"); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); Assert.Null(fixture.Client.ReceivedRequests[0].Reasoning); } [Theory] [InlineData("high", "high", null)] [InlineData("MAX", "max", null)] [InlineData("4096", null, 4096)] public async Task Die_Denktiefe_wird_richtig_uebersetzt(string einstellung, string? effort, int? tokens) { var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); agent.ReasoningEffort = einstellung; fixture.Client.RespondsWithText("ok"); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); var reasoning = fixture.Client.ReceivedRequests[0].Reasoning; Assert.NotNull(reasoning); Assert.Equal(effort, reasoning!.Effort); Assert.Equal(tokens, reasoning.MaxTokens); } [Fact] public async Task Der_gemeldete_Betrag_wird_uebernommen_statt_geschaetzt() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); fixture.Client.RespondsWithText("ok", new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120, Cost = 0.0042m }); var result = await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); Assert.Equal(0.0042m, result.ReportedCostUsd); } [Fact] public async Task Ohne_gemeldeten_Betrag_bleibt_das_Feld_leer() { // Wichtig: nicht 0. Ein als kostenlos verbuchter Lauf wäre schlimmer als // gar keine Angabe, weil er die Budgetrechnung still verfälscht. var fixture = new EngineFixture(); var agent = fixture.AddAgent("a1"); fixture.Client.RespondsWithText("ok"); var result = await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); Assert.Null(result.ReportedCostUsd); } // ─────────────────────────── #551 ─────────────────────────── [Fact] public async Task Freigegebene_Werkzeuge_laufen_tatsaechlich_gleichzeitig() { var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(300)); var fixture = new EngineFixture().WithTool(tool); var agent = fixture.AddAgent("a1", "ParallelTool"); fixture.Client .RespondsWithToolCalls( ("ParallelTool", "{}", "c1"), ("ParallelTool", "{}", "c2"), ("ParallelTool", "{}", "c3")) .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); // Der belastbare Nachweis: Aufrufe haben sich überlappt. // // Hier stand zusätzlich eine Wanduhr-Prüfung (unter 750 ms, seriell wären es 900). // Sie schlug am 2026-09-24 mit 927 ms fehl, als die Testprojekte parallel liefen — // die Zeit misst die Last der Maschine, nicht das Verhalten der Engine. Der // Höchststand gleichzeitiger Aufrufe beweist dasselbe, ohne zu wackeln. Assert.True(tool.MaxConcurrent > 1, $"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}."); } [Fact] public async Task Nicht_freigegebene_Werkzeuge_bleiben_seriell() { var tool = FakeTool.SlowSerial(TimeSpan.FromMilliseconds(80)); var fixture = new EngineFixture().WithTool(tool); var agent = fixture.AddAgent("a1", "SerialTool"); fixture.Client .RespondsWithToolCalls( ("SerialTool", "{}", "c1"), ("SerialTool", "{}", "c2"), ("SerialTool", "{}", "c3")) .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); Assert.Equal(1, tool.MaxConcurrent); } [Fact] public async Task Die_Reihenfolge_der_Ergebnisse_folgt_den_Aufrufen() { // Der langsamste Aufruf kommt zuerst. Würde die Engine nach Fertigstellung // einsortieren, stünde er am Ende — und jede tool_call_id wäre falsch zugeordnet. var tool = new StaffelTool(); var fixture = new EngineFixture().WithTool(tool); var agent = fixture.AddAgent("a1", "StaffelTool"); fixture.Client .RespondsWithToolCalls( ("StaffelTool", """{"delay":250,"marke":"erste"}""", "c1"), ("StaffelTool", """{"delay":10,"marke":"zweite"}""", "c2"), ("StaffelTool", """{"delay":120,"marke":"dritte"}""", "c3")) .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); var antworten = fixture.Client.ReceivedRequests[1].Messages .Where(m => m.Role == "tool") .ToList(); Assert.Equal(["c1", "c2", "c3"], antworten.Select(m => m.ToolCallId)); Assert.Equal( ["erste", "zweite", "dritte"], antworten.Select(m => m.Content?.ToString()?.Split('\n')[1])); } [Fact] public async Task Die_Grenze_der_Nebenlaeufigkeit_wird_eingehalten() { var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(120)); var fixture = new EngineFixture().WithTool(tool); var agent = fixture.AddAgent("a1", "ParallelTool"); agent.MaxParallelToolCalls = 2; fixture.Client .RespondsWithToolCalls( ("ParallelTool", "{}", "c1"), ("ParallelTool", "{}", "c2"), ("ParallelTool", "{}", "c3"), ("ParallelTool", "{}", "c4")) .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); Assert.True(tool.MaxConcurrent <= 2, $"Grenze war 2, gleichzeitig liefen aber {tool.MaxConcurrent}."); } [Fact] public async Task Bei_Grenze_eins_bleibt_alles_seriell() { var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(60)); var fixture = new EngineFixture().WithTool(tool); var agent = fixture.AddAgent("a1", "ParallelTool"); agent.MaxParallelToolCalls = 1; fixture.Client .RespondsWithToolCalls( ("ParallelTool", "{}", "c1"), ("ParallelTool", "{}", "c2")) .RespondsWithText("Fertig."); await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); Assert.Equal(1, tool.MaxConcurrent); } } /// /// Werkzeug, das nach einer im Aufruf angegebenen Zeit eine Marke zurückgibt. /// Damit lässt sich prüfen, ob die Reihenfolge der Ergebnisse den Aufrufen folgt /// und nicht der Fertigstellung. /// internal sealed class StaffelTool : ClawdDotNet.Core.Tools.IAgentTool { public string Name => "StaffelTool"; public string Description => "Wartet und meldet eine Marke."; public bool IsParallelSafe => true; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "properties": { "delay": { "type": "integer" }, "marke": { "type": "string" } } } """).RootElement.Clone(); public async Task ExecuteAsync( JsonElement input, ClawdDotNet.Core.Tools.AgentToolContext context, CancellationToken ct) { var delay = input.GetProperty("delay").GetInt32(); var marke = input.GetProperty("marke").GetString()!; await Task.Delay(delay, ct); return ClawdDotNet.Core.Tools.ToolResult.Ok(marke); } }