Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
404 lines
16 KiB
C#
404 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The hardening of the harness against silent loss: #547 to #551.
|
|
///
|
|
/// What connects these five findings is not the place in the code, but the
|
|
/// failure mode — none of them produces an error message. The run finishes,
|
|
/// the result looks plausible, and only the quality is worse. Exactly
|
|
/// that's why each needs a test: by hand such a thing is not noticed.
|
|
/// </summary>
|
|
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);
|
|
|
|
// The second request carries the history the first one produced.
|
|
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()
|
|
{
|
|
// Counter-check: an empty text must not go along as "". Some providers
|
|
// reject an assistant message with empty content and tool_calls.
|
|
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);
|
|
|
|
// The partial result goes along anyway — throwing it away would be worse than
|
|
// passing it on marked.
|
|
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);
|
|
|
|
// Signature and order must be preserved — by them the provider recognizes
|
|
// its own block.
|
|
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()
|
|
{
|
|
// The history is written to disk and loaded again at startup.
|
|
// If the reasoning does not survive that, it is gone after a restart.
|
|
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<ChatMessage>(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"); // the default is "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()
|
|
{
|
|
// Important: not 0. A run booked as free would be worse than
|
|
// no information at all, because it silently falsifies the budget calculation.
|
|
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);
|
|
|
|
// The reliable proof: calls overlapped.
|
|
//
|
|
// Here there was additionally a wall-clock check (below 750 ms, serially it would be 900).
|
|
// It failed on 2026-09-24 with 927 ms when the test projects ran in parallel —
|
|
// the time measures the load of the machine, not the behavior of the engine. The
|
|
// peak of simultaneous calls proves the same without wobbling.
|
|
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()
|
|
{
|
|
// The slowest call comes first. If the engine sorted by completion,
|
|
// it would be at the end — and every tool_call_id would be assigned wrongly.
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tool that returns a marker after a time given in the call.
|
|
/// This allows checking whether the order of the results follows the calls
|
|
/// and not the completion.
|
|
/// </summary>
|
|
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<ClawdDotNet.Core.Tools.ToolResult> 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);
|
|
}
|
|
}
|