Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
da4ceac7d5
commit
837a634a22
@@ -4,12 +4,12 @@ using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Was ein Abbruch mitten im Zug mit dem Gesprächskontext macht.
|
||||
/// What an abort in the middle of a turn does to the conversation context.
|
||||
///
|
||||
/// Der Kontext ist eine geteilte, an Ort und Stelle veränderte Liste. Bricht ein Lauf
|
||||
/// zwischen dem Werkzeugaufruf und seiner Antwort ab, bleibt eine assistant-Nachricht
|
||||
/// mit tool_calls stehen, auf die keine Antwort folgt — eine Nachrichtenfolge, die die
|
||||
/// Schnittstelle nicht annimmt.
|
||||
/// The context is a shared list, changed in place. If a run aborts
|
||||
/// between the tool call and its answer, an assistant message
|
||||
/// with tool_calls remains that no answer follows — a message sequence the
|
||||
/// interface does not accept.
|
||||
/// </summary>
|
||||
public class AbbruchKontextTests
|
||||
{
|
||||
@@ -27,7 +27,7 @@ public class AbbruchKontextTests
|
||||
|
||||
var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
// Warten, bis der Aufruf sicher steht und das Werkzeug läuft.
|
||||
// Wait until the call is surely in place and the tool is running.
|
||||
await Task.Delay(300);
|
||||
fixture.Engine.AbortChat("a1");
|
||||
|
||||
@@ -48,8 +48,8 @@ public class AbbruchKontextTests
|
||||
"Eine solche Folge lehnt die Schnittstelle mit HTTP 400 ab — der Agent ist " +
|
||||
"danach dauerhaft blockiert.");
|
||||
|
||||
// Die Ersatzantwort sagt auch, was passiert ist. Eine leere Antwort wäre formal
|
||||
// gültig, ließe das Modell aber rätseln, warum sein Aufruf nichts ergab.
|
||||
// The substitute answer also says what happened. An empty answer would be formally
|
||||
// valid, but would leave the model puzzling why its call yielded nothing.
|
||||
var ersatz = kontext.Last(m => m.Role == "tool");
|
||||
Assert.Contains("abgebrochen", ersatz.Content, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -57,8 +57,8 @@ public class AbbruchKontextTests
|
||||
[Fact]
|
||||
public async Task Nach_einem_Abbruch_laesst_sich_der_Agent_wieder_ansprechen()
|
||||
{
|
||||
// Der eigentliche Punkt: Nicht die Form des Kontexts zählt, sondern ob der
|
||||
// Agent danach noch arbeitet. Ein zweiter Chat muss durchlaufen.
|
||||
// The actual point: not the form of the context counts, but whether the
|
||||
// agent still works afterwards. A second chat has to run through.
|
||||
var fixture = new EngineFixture().WithTool(
|
||||
FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool"));
|
||||
|
||||
@@ -78,7 +78,7 @@ public class AbbruchKontextTests
|
||||
Assert.Equal(AgentRunStatus.Completed, zweiter.Status);
|
||||
Assert.Equal("Zweite Antwort.", zweiter.FinalMessage);
|
||||
|
||||
// Und die Folge, die dabei gesendet wurde, war vollständig beantwortet.
|
||||
// And the sequence that was sent in doing so was completely answered.
|
||||
var gesendet = fixture.Client.ReceivedRequests[^1].Messages;
|
||||
var offen = gesendet
|
||||
.Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 })
|
||||
|
||||
@@ -5,19 +5,19 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Regressionstests für Bug B2: _chatContexts[agentId] ist eine geteilte
|
||||
/// List<ChatMessage>. Nur der Lookup läuft unter Lock — alle Add-Aufrufe im
|
||||
/// Schleifenkörper sind ungeschützt.
|
||||
/// Regression tests for bug B2: _chatContexts[agentId] is a shared
|
||||
/// List<ChatMessage>. Only the lookup runs under lock — all Add calls in the
|
||||
/// loop body are unprotected.
|
||||
///
|
||||
/// Derselbe Agent kann gleichzeitig von mehreren Seiten angestoßen werden:
|
||||
/// Telegram-/Mail-ToolJob, WebView-Nachricht des Benutzers, send_message eines
|
||||
/// anderen Agenten. Ergebnis: korrupte Liste, verschränkte Tool-Sequenzen und
|
||||
/// dadurch HTTP-400-Fehler.
|
||||
/// The same agent can be triggered from several sides at the same time:
|
||||
/// Telegram/mail tool job, the user's WebView message, send_message of
|
||||
/// another agent. Result: corrupt list, interleaved tool sequences and
|
||||
/// thus HTTP 400 errors.
|
||||
/// </summary>
|
||||
public sealed class AgentEngineConcurrencyTests
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// E1 — Zwei parallele Chats auf demselben Agenten
|
||||
// E1 — two parallel chats on the same agent
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Theory]
|
||||
@@ -30,8 +30,8 @@ public sealed class AgentEngineConcurrencyTests
|
||||
.WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(15)));
|
||||
var agent = fixture.AddAgent("agent-a", "TestTool");
|
||||
|
||||
// Jeder Lauf: ein Tool-Schritt, dann eine Textantwort — unabhängig davon,
|
||||
// wie die beiden Konversationen ineinander verschränkt werden.
|
||||
// Every run: one tool step, then a text answer — regardless of
|
||||
// how the two conversations get interleaved.
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
await Task.WhenAll(
|
||||
@@ -43,7 +43,7 @@ public sealed class AgentEngineConcurrencyTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// E2 — Stresstest
|
||||
// E2 — stress test
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -65,8 +65,8 @@ public sealed class AgentEngineConcurrencyTests
|
||||
var context = fixture.Engine.GetChatContext(agent.AgentId);
|
||||
ContextInvariants.AssertValid(context);
|
||||
|
||||
// Jede der 30 Anfragen muss genau einmal im Kontext stehen — nichts verloren,
|
||||
// nichts doppelt durch verlorene Schreibzugriffe auf die Liste.
|
||||
// Each of the 30 requests must be in the context exactly once — nothing lost,
|
||||
// nothing doubled through lost writes to the list.
|
||||
var userMessages = context.Count(m => m.Role == "user");
|
||||
userMessages.ShouldBe(30);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ public sealed class AgentEngineConcurrencyTests
|
||||
var first = fixture.Engine.ChatAsync(agent, "Erste", "test-instance", default);
|
||||
var second = fixture.Engine.ChatAsync(agent, "Zweite", "test-instance", default);
|
||||
|
||||
// Warten, bis beide tatsächlich angelaufen sind.
|
||||
// Wait until both have actually started.
|
||||
await WaitUntilAsync(() => fixture.Engine.IsRunning(agent.AgentId));
|
||||
await Task.Delay(50);
|
||||
|
||||
@@ -99,29 +99,29 @@ public sealed class AgentEngineConcurrencyTests
|
||||
|
||||
results.ShouldAllBe(r => r.Status == AgentRunStatus.Cancelled);
|
||||
|
||||
// Entscheidend: _runningChats[agentId] hielt bisher nur EINE CancellationTokenSource —
|
||||
// der zweite Lauf überschrieb den ersten. AbortChat brach dann nur einen ab, der
|
||||
// andere lief bis ins Run-Timeout. Ohne diese Zeitprüfung wäre der Test grün,
|
||||
// obwohl der Abbruch gar nicht gegriffen hat.
|
||||
// Crucial: _runningChats[agentId] used to hold only ONE CancellationTokenSource —
|
||||
// the second run overwrote the first. AbortChat then only aborted one, the
|
||||
// other ran until the run timeout. Without this time check the test would be green
|
||||
// although the abort did not take effect at all.
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5),
|
||||
"AbortChat muss beide Läufe sofort beenden, nicht erst über das Timeout");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// E4 — Gegenprobe: verschiedene Agenten dürfen parallel laufen
|
||||
// E4 — counter-check: different agents may run in parallel
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Verschiedene_Agenten_laufen_wirklich_parallel()
|
||||
{
|
||||
// Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
|
||||
// gegeneinander sperren.
|
||||
// Important counter-check: the fix for B2 must not accidentally lock all agents
|
||||
// against each other.
|
||||
//
|
||||
// Bis zum 2026-09-24 antwortete die Attrappe hier nur mit Text: Das langsame
|
||||
// Werkzeug lief nie, die Läufe waren sofort fertig, und geprüft wurde nur, ob drei
|
||||
// leere Läufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
|
||||
// Agenten - und schlug unter Last fehl, weil es die Maschine mass statt der Engine.
|
||||
// Jetzt ruft jeder Agent das Werkzeug wirklich auf, und bewiesen wird die Überlappung.
|
||||
// Until 2026-09-24 the fake here only answered with text: the slow
|
||||
// tool never ran, the runs were finished immediately, and all that was checked was whether three
|
||||
// empty runs stay below 450 ms. That passed even with agents locking each other
|
||||
// - and failed under load, because it measured the machine instead of the engine.
|
||||
// Now every agent really calls the tool, and the overlap is proven.
|
||||
var tool = FakeTool.Slow(TimeSpan.FromMilliseconds(500));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ using FsCheck.Xunit;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Property-Based Tests: Statt einzelner Beispiele werden hunderte zufällige,
|
||||
/// aber gültige Konversationen erzeugt und geprüft, dass die Compaction die
|
||||
/// API-Invarianten in JEDEM Fall erhält.
|
||||
/// Property-based tests: instead of individual examples, hundreds of random
|
||||
/// but valid conversations are generated and it is checked that compaction preserves the
|
||||
/// API invariants in EVERY case.
|
||||
///
|
||||
/// Genau diese Grenzfälle — wo die Tail-Grenze zufällig mitten in einen
|
||||
/// Tool-Zyklus fällt — schreibt von Hand niemand auf. Bug B1 fällt in diese Klasse.
|
||||
/// Exactly these edge cases — where the tail boundary happens to fall in the middle of a
|
||||
/// tool cycle — nobody writes down by hand. Bug B1 belongs to this class.
|
||||
/// </summary>
|
||||
public sealed class ContextCompactorPropertyTests
|
||||
{
|
||||
@@ -30,7 +30,7 @@ public sealed class ContextCompactorPropertyTests
|
||||
{
|
||||
var messages = shape.ToMessages();
|
||||
|
||||
// Vorbedingung: Der Generator erzeugt nur gültige Ausgangsfolgen.
|
||||
// Precondition: the generator only produces valid initial sequences.
|
||||
if (!ContextInvariants.IsValid(messages))
|
||||
return false.ToProperty().Label("Generator hat eine ungültige Folge erzeugt");
|
||||
|
||||
@@ -71,8 +71,8 @@ public sealed class ContextCompactorPropertyTests
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Beschreibt die Form einer Konversation. FsCheck erzeugt davon zufällige
|
||||
/// Varianten und schrumpft sie im Fehlerfall auf das minimale Gegenbeispiel.
|
||||
/// Describes the shape of a conversation. FsCheck generates random
|
||||
/// variants of it and shrinks them in case of failure to the minimal counterexample.
|
||||
/// </summary>
|
||||
public sealed record ConversationShape(bool HasSystemPrompt, IReadOnlyList<TurnShape> Turns)
|
||||
{
|
||||
@@ -122,10 +122,10 @@ public static class ConversationArbitrary
|
||||
return Arb.From(gen, Shrink);
|
||||
}
|
||||
|
||||
/// <summary>Im Fehlerfall auf das kleinste Gegenbeispiel reduzieren.</summary>
|
||||
/// <summary>In case of failure reduce to the smallest counterexample.</summary>
|
||||
private static IEnumerable<ConversationShape> Shrink(ConversationShape shape)
|
||||
{
|
||||
// Weniger Turns
|
||||
// Fewer turns
|
||||
for (var i = 0; i < shape.Turns.Count; i++)
|
||||
{
|
||||
var reduced = shape.Turns.Where((_, idx) => idx != i).ToList();
|
||||
|
||||
@@ -7,19 +7,19 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Regressionstests für Bug B1: Die Compaction behält blind die letzten N Nachrichten.
|
||||
/// Liegt diese Grenze mitten in einer Tool-Sequenz, entsteht eine tool-Antwort ohne
|
||||
/// zugehörigen assistant-tool_call — die API lehnt das mit HTTP 400 ab.
|
||||
/// Regression tests for bug B1: compaction blindly keeps the last N messages.
|
||||
/// If this boundary lies in the middle of a tool sequence, a tool answer without
|
||||
/// the corresponding assistant tool_call results — the API rejects that with HTTP 400.
|
||||
/// </summary>
|
||||
public sealed class ContextCompactorTests
|
||||
{
|
||||
private const string Model = "test/model";
|
||||
|
||||
/// <summary>Schwelle so gesetzt, dass Compaction sicher ausgelöst wird.</summary>
|
||||
/// <summary>Threshold set so that compaction is surely triggered.</summary>
|
||||
private static LoopGuardConfig AlwaysCompact => new()
|
||||
{
|
||||
MaxContextTokens = 1_000,
|
||||
CompactionThreshold = 0.5 // Schwelle = 500 Tokens
|
||||
CompactionThreshold = 0.5 // threshold = 500 tokens
|
||||
};
|
||||
|
||||
private static LoopGuardConfig NeverCompact => new()
|
||||
@@ -36,7 +36,7 @@ public sealed class ContextCompactorTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// C1/C2 — Die eigentliche Bug-Reproduktion
|
||||
// C1/C2 — the actual bug reproduction
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Theory]
|
||||
@@ -50,14 +50,14 @@ public sealed class ContextCompactorTests
|
||||
[InlineData(8)]
|
||||
public async Task Compaction_erhaelt_gueltige_Sequenz_bei_beliebiger_Toolzyklus_Laenge(int toolCallsPerCycle)
|
||||
{
|
||||
// Je nach Anzahl der Tool-Calls fällt die "letzte 6 Nachrichten"-Grenze
|
||||
// an eine andere Stelle im Zyklus. Mindestens eine Variante trifft mitten hinein.
|
||||
// Depending on the number of tool calls the "last 6 messages" boundary falls
|
||||
// at a different place in the cycle. At least one variant hits right in the middle.
|
||||
var (compactor, _) = CreateCompactor();
|
||||
var messages = Conversation.Start()
|
||||
.Repeat(times: 4, toolCallsPerCycle: toolCallsPerCycle, resultLength: 200)
|
||||
.Build();
|
||||
|
||||
ContextInvariants.AssertValid(messages); // Vorbedingung: Ausgangslage ist gültig
|
||||
ContextInvariants.AssertValid(messages); // Precondition: the initial state is valid
|
||||
|
||||
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class ContextCompactorTests
|
||||
[Fact]
|
||||
public async Task Compaction_trennt_Toolantwort_nie_von_ihrem_Aufruf()
|
||||
{
|
||||
// Gezielt konstruiert: Der Tail von 6 Nachrichten beginnt genau bei einer tool-Antwort.
|
||||
// Constructed on purpose: the tail of 6 messages begins exactly at a tool answer.
|
||||
var (compactor, _) = CreateCompactor();
|
||||
var messages = Conversation.Start()
|
||||
.User("Erste Anfrage")
|
||||
@@ -81,7 +81,7 @@ public sealed class ContextCompactorTests
|
||||
|
||||
ContextInvariants.AssertValid(messages);
|
||||
|
||||
// Keine verwaiste tool-Antwort
|
||||
// No orphaned tool answer
|
||||
for (var i = 0; i < messages.Count; i++)
|
||||
{
|
||||
if (messages[i].Role != "tool") continue;
|
||||
@@ -126,7 +126,7 @@ public sealed class ContextCompactorTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// C5/C6 — Fehlerfälle der Zusammenfassung
|
||||
// C5/C6 — failure cases of the summary
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -158,7 +158,7 @@ public sealed class ContextCompactorTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// C7/C8 — Tool-Result-Pruning
|
||||
// C7/C8 — tool result pruning
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -192,7 +192,7 @@ public sealed class ContextCompactorTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// C9/C10 — Schätzung und Schwelle
|
||||
// C9/C10 — estimate and threshold
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -213,7 +213,7 @@ public sealed class ContextCompactorTests
|
||||
[Fact]
|
||||
public async Task Unterhalb_der_Schwelle_wird_kein_ApiCall_ausgeloest()
|
||||
{
|
||||
// Wichtig fürs Budget: Compaction darf nicht unnötig ein Modell anwerfen.
|
||||
// Important for the budget: compaction must not fire up a model unnecessarily.
|
||||
var (compactor, client) = CreateCompactor();
|
||||
var messages = Conversation.Start().Repeat(2).Build();
|
||||
|
||||
|
||||
@@ -6,24 +6,24 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Test A13 der Teststrategie — Regressionstests für B8 (#206).
|
||||
/// Test A13 of the test strategy — regression tests for B8 (#206).
|
||||
///
|
||||
/// <para><c>send_message</c> ist ein synchroner Aufruf: A hält sein Gate, während es
|
||||
/// auf B wartet. Rief B nun A, wartete B auf ein Gate, das A erst freigibt, wenn B
|
||||
/// geantwortet hat. Beide standen, bis der Timeout des äußeren Laufs sie auflöste.</para>
|
||||
/// <para><c>send_message</c> is a synchronous call: A holds its gate while it
|
||||
/// waits for B. If B then called A, B waited for a gate that A only releases once B
|
||||
/// has answered. Both stood still until the timeout of the outer run resolved it.</para>
|
||||
///
|
||||
/// <para>Die Tests prüfen die drei Zusagen aus <see cref="DelegationChain"/>: kein
|
||||
/// Agent zweimal in einer Kette, höchstens <c>MaxDelegationDepth</c> Weitergaben, und
|
||||
/// die Kette gehört zum Aufruf statt zum Agenten.</para>
|
||||
/// <para>The tests check the three promises from <see cref="DelegationChain"/>: no
|
||||
/// agent twice in a chain, at most <c>MaxDelegationDepth</c> hand-offs, and
|
||||
/// the chain belongs to the call instead of to the agent.</para>
|
||||
///
|
||||
/// <para>Ein Zeitlimit steht bewusst an jedem Test, der verklemmen könnte: Ohne die
|
||||
/// Prüfung liefe er nicht rot, sondern gar nicht mehr weiter.</para>
|
||||
/// <para>A time limit is deliberately on every test that could deadlock: without the
|
||||
/// check it would not turn red, but not continue at all.</para>
|
||||
/// </summary>
|
||||
public sealed class DelegationDepthTests
|
||||
{
|
||||
private static readonly TimeSpan Frist = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <summary>Wartet auf den Lauf, scheitert aber statt zu hängen.</summary>
|
||||
/// <summary>Waits for the run, but fails instead of hanging.</summary>
|
||||
private static async Task<AgentRunResult> MitFrist(Task<AgentRunResult> lauf)
|
||||
{
|
||||
var fertig = await Task.WhenAny(lauf, Task.Delay(Frist));
|
||||
@@ -32,7 +32,7 @@ public sealed class DelegationDepthTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// A13 — der gemeldete Fall
|
||||
// A13 — the reported case
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -41,7 +41,7 @@ public sealed class DelegationDepthTests
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
["agent-b"] = "agent-a" // zurück zu A — der Zyklus
|
||||
["agent-b"] = "agent-a" // back to A — the cycle
|
||||
});
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
@@ -55,7 +55,7 @@ public sealed class DelegationDepthTests
|
||||
|
||||
result.Status.ShouldBe(AgentRunStatus.Completed);
|
||||
|
||||
// Der Rückweg B → A ist abgelehnt worden, der Hinweg A → B nicht.
|
||||
// The way back B → A was rejected, the way there A → B was not.
|
||||
var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" });
|
||||
rueckweg.Delivered.ShouldBeFalse();
|
||||
rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus");
|
||||
@@ -67,8 +67,8 @@ public sealed class DelegationDepthTests
|
||||
[Fact]
|
||||
public async Task Die_Ablehnung_kommt_als_Ergebnis_zurueck_nicht_als_Wurf()
|
||||
{
|
||||
// Der abgewiesene Agent soll weiterarbeiten können — er bekommt einen Grund,
|
||||
// keinen Abbruch. Sonst führte die Zyklusprüfung nur zu einem anderen Ausfall.
|
||||
// The rejected agent should be able to continue working — it gets a reason,
|
||||
// not an abort. Otherwise the cycle check would only lead to a different failure.
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
@@ -89,13 +89,13 @@ public sealed class DelegationDepthTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Die Staffel, die keinen Zyklus bildet
|
||||
// The relay that does not form a cycle
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Staffel_endet_an_der_Tiefengrenze()
|
||||
{
|
||||
// A → B → C → D wäre die Tiefe 3. Mit MaxDelegationDepth = 2 ist bei C Schluss.
|
||||
// A → B → C → D would be depth 3. With MaxDelegationDepth = 2 it stops at C.
|
||||
var tool = new DelegatingTool(new Dictionary<string, string>
|
||||
{
|
||||
["agent-a"] = "agent-b",
|
||||
@@ -149,13 +149,13 @@ public sealed class DelegationDepthTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Die Kette gehört zum Aufruf, nicht zum Agenten
|
||||
// The chain belongs to the call, not to the agent
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Zwei_Laeufe_nacheinander_erben_die_Kette_des_anderen_nicht()
|
||||
{
|
||||
// Sonst könnte ein Agent nach einer tiefen Kette dauerhaft „verbraucht" sein.
|
||||
// Otherwise an agent could be permanently "used up" after a deep chain.
|
||||
var tool = new DelegatingTool(new Dictionary<string, string> { ["agent-a"] = "agent-b" });
|
||||
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
@@ -172,7 +172,7 @@ public sealed class DelegationDepthTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// spawn zählt mit
|
||||
// spawn counts too
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -198,23 +198,23 @@ public sealed class DelegationDepthTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #461 — was die Kette grundsätzlich nicht sehen kann
|
||||
// #461 — what the chain fundamentally cannot see
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Zwei unabhängige Ketten über Kreuz: Lauf 1 startet bei A und beauftragt B, Lauf 2
|
||||
/// startet zeitgleich bei B und beauftragt A. Keine der beiden Ketten enthält den
|
||||
/// Agenten der anderen — beide sind für sich zyklusfrei, und trotzdem hält Lauf 1
|
||||
/// gate(A) und wartet auf gate(B), während Lauf 2 gate(B) hält und auf gate(A) wartet.
|
||||
/// Two independent chains crossing over: run 1 starts at A and commissions B, run 2
|
||||
/// starts at the same time at B and commissions A. Neither chain contains the
|
||||
/// other's agent — both are cycle-free on their own, and yet run 1 holds
|
||||
/// gate(A) and waits for gate(B), while run 2 holds gate(B) and waits for gate(A).
|
||||
///
|
||||
/// <para>Das kann die Delegationskette prinzipiell nicht erkennen; nur eine Frist
|
||||
/// löst es auf. Ein delegierter Lauf wartet deshalb höchstens
|
||||
/// <c>DelegationGateTimeout</c> auf ein fremdes Gate und sagt danach ab.</para>
|
||||
/// <para>The delegation chain fundamentally cannot recognize that; only a deadline
|
||||
/// resolves it. A delegated run therefore waits at most
|
||||
/// <c>DelegationGateTimeout</c> for a foreign gate and refuses afterwards.</para>
|
||||
///
|
||||
/// <para>Die Pause vor der Weitergabe ist der Kern des Aufbaus: Der Fake-Client
|
||||
/// antwortet synchron, ohne sie liefe der erste Chat vollständig durch, bevor der
|
||||
/// zweite beginnt — die Ketten könnten sich gar nicht überschneiden, und der Test
|
||||
/// wäre grün, ohne etwas zu zeigen.</para>
|
||||
/// <para>The pause before the hand-off is the core of the setup: the fake client
|
||||
/// answers synchronously; without it the first chat would run through completely before the
|
||||
/// second begins — the chains could not overlap at all, and the test
|
||||
/// would be green without showing anything.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Zwei_unabhaengige_Ketten_ueber_Kreuz_verklemmen_sich_nicht()
|
||||
@@ -241,9 +241,9 @@ public sealed class DelegationDepthTests
|
||||
|
||||
fertig.ShouldBe(beide, "Beide Laeufe warten auf das Gate des jeweils anderen (#461).");
|
||||
|
||||
// Beide äußeren Läufe kommen zu Ende. Mindestens eine Weitergabe ist an der
|
||||
// Frist gescheitert — welche, entscheidet die Reihenfolge und ist nicht
|
||||
// festgelegt; dass beide Läufe zurückkommen, schon.
|
||||
// Both outer runs come to an end. At least one hand-off failed at the
|
||||
// deadline — which one is decided by the order and is not
|
||||
// fixed; that both runs come back is.
|
||||
(await laufA).Status.ShouldBe(AgentRunStatus.Completed);
|
||||
(await laufB).Status.ShouldBe(AgentRunStatus.Completed);
|
||||
tool.Handoffs.ShouldContain(h => !h.Delivered && h.Error!.Contains("belegt"));
|
||||
@@ -252,9 +252,9 @@ public sealed class DelegationDepthTests
|
||||
[Fact]
|
||||
public async Task Ein_Chat_vom_Menschen_wartet_weiter_so_lange_es_dauert()
|
||||
{
|
||||
// Die Gegenprobe zur Frist: Sie gilt nur für delegierte Läufe. Ein zweiter
|
||||
// Chat desselben Agenten reiht sich ein und wird bedient, auch wenn der erste
|
||||
// länger braucht als die Delegationsfrist.
|
||||
// The counter-check to the deadline: it only applies to delegated runs. A second
|
||||
// chat of the same agent queues up and is served, even if the first one
|
||||
// takes longer than the delegation deadline.
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(600)));
|
||||
fixture.Engine.DelegationGateTimeout = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
@@ -270,7 +270,7 @@ public sealed class DelegationDepthTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Die Kette selbst
|
||||
// The chain itself
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -285,8 +285,8 @@ public sealed class DelegationDepthTests
|
||||
[Fact]
|
||||
public void Append_veraendert_die_Kette_des_Aufrufers_nicht()
|
||||
{
|
||||
// Unveränderlich: Zwei Weitergaben aus demselben Lauf dürfen sich nicht
|
||||
// gegenseitig sehen — sonst zählte die zweite die erste als Vorgänger.
|
||||
// Immutable: two hand-offs from the same run must not see
|
||||
// each other — otherwise the second would count the first as a predecessor.
|
||||
var kette = DelegationChain.Root("a");
|
||||
|
||||
var nachB = kette.Append("b");
|
||||
|
||||
@@ -7,12 +7,12 @@ using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Die Härtung des Harness gegen stillen Verlust: #547 bis #551.
|
||||
/// The hardening of the harness against silent loss: #547 to #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.
|
||||
/// 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
|
||||
{
|
||||
@@ -30,7 +30,7 @@ public class HarnessHaertungTests
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
// Die zweite Anfrage trägt den Verlauf, den die erste erzeugt hat.
|
||||
// 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 });
|
||||
|
||||
@@ -40,8 +40,8 @@ public class HarnessHaertungTests
|
||||
[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.
|
||||
// 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");
|
||||
|
||||
@@ -86,8 +86,8 @@ public class HarnessHaertungTests
|
||||
|
||||
Assert.Equal(AgentRunStatus.Truncated, result.Status);
|
||||
|
||||
// Das Teilergebnis geht trotzdem mit — es wegzuwerfen wäre schlimmer, als es
|
||||
// gekennzeichnet weiterzureichen.
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -127,8 +127,8 @@ public class HarnessHaertungTests
|
||||
|
||||
Assert.NotNull(mitAufruf.ReasoningDetails);
|
||||
|
||||
// Signatur und Reihenfolge müssen erhalten sein — daran erkennt der Anbieter
|
||||
// seinen eigenen Block wieder.
|
||||
// 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());
|
||||
@@ -137,8 +137,8 @@ public class HarnessHaertungTests
|
||||
[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.
|
||||
// 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}]
|
||||
""";
|
||||
@@ -200,7 +200,7 @@ public class HarnessHaertungTests
|
||||
public async Task Ohne_Denktiefe_geht_kein_reasoning_Feld_mit()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("a1"); // Vorgabe ist "off"
|
||||
var agent = fixture.AddAgent("a1"); // the default is "off"
|
||||
fixture.Client.RespondsWithText("ok");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
@@ -249,8 +249,8 @@ public class HarnessHaertungTests
|
||||
[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.
|
||||
// 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");
|
||||
@@ -278,12 +278,12 @@ public class HarnessHaertungTests
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
// Der belastbare Nachweis: Aufrufe haben sich überlappt.
|
||||
// The reliable proof: calls overlapped.
|
||||
//
|
||||
// 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.
|
||||
// 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}.");
|
||||
}
|
||||
@@ -310,8 +310,8 @@ public class HarnessHaertungTests
|
||||
[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.
|
||||
// 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");
|
||||
@@ -378,9 +378,9 @@ public class HarnessHaertungTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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
|
||||
{
|
||||
|
||||
@@ -7,9 +7,9 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// #605: Im Dauerbetrieb lief die Kompaktierung bei jedem Schritt an und blieb wirkungslos -
|
||||
/// Stufe 1 pruefte mit der rohen statt der kalibrierten Schaetzung, kompaktierte nur knapp
|
||||
/// unter die Schwelle, und der Zusammenfassungs-Aufruf wurde nicht verbucht.
|
||||
/// #605: In continuous operation compaction started at every step and stayed ineffective -
|
||||
/// stage 1 checked with the raw instead of the calibrated estimate, compacted only to just
|
||||
/// below the threshold, and the summarization call was not booked.
|
||||
/// </summary>
|
||||
public sealed class KompaktierungAbstandTests
|
||||
{
|
||||
@@ -22,9 +22,9 @@ public sealed class KompaktierungAbstandTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die rohe Schaetzung, wie sie nach Stufe 1 aussaehe - nachgebaut, um Schwellen zu
|
||||
/// waehlen, an denen alte und neue Pruefung verschieden entscheiden. Werte wie im
|
||||
/// ContextCompactor: die letzten 6 Nachrichten geschuetzt, Tool-Ergebnisse auf 2.000 Zeichen.
|
||||
/// The raw estimate as it would look after stage 1 - rebuilt in order to choose thresholds
|
||||
/// at which the old and new check decide differently. Values as in the
|
||||
/// ContextCompactor: the last 6 messages protected, tool results at 2,000 characters.
|
||||
/// </summary>
|
||||
private static int RohNachStufe1(List<ChatMessage> messages)
|
||||
{
|
||||
@@ -50,14 +50,14 @@ public sealed class KompaktierungAbstandTests
|
||||
{
|
||||
var (compactor, client) = Erzeuge();
|
||||
|
||||
// Dieses Modell zaehlt rund dreimal so viele Tokens, wie Zeichen/4 vermuten laesst.
|
||||
// This model counts about three times as many tokens as chars/4 suggests.
|
||||
for (var i = 0; i < 12; i++)
|
||||
compactor.Calibrate(Model, 1_000, 3_000);
|
||||
|
||||
var messages = LangerVerlauf();
|
||||
var rohDanach = RohNachStufe1(messages);
|
||||
|
||||
// Roh liegt Stufe 1 unter der Schwelle, kalibriert deutlich darueber.
|
||||
// Raw, stage 1 lies below the threshold, calibrated clearly above it.
|
||||
var schwelle = (int)(rohDanach * 1.5);
|
||||
var guard = new LoopGuardConfig { MaxContextTokens = schwelle * 2, CompactionThreshold = 0.5 };
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class KompaktierungAbstandTests
|
||||
var messages = LangerVerlauf();
|
||||
var rohDanach = RohNachStufe1(messages);
|
||||
|
||||
// Stufe 1 bringt knapp unter die Schwelle (90 %), aber nicht unter das Ziel (55 %).
|
||||
// Stage 1 brings it just below the threshold (90 %), but not below the target (55 %).
|
||||
var schwelle = (int)(rohDanach * 1.2);
|
||||
var guard = new LoopGuardConfig { MaxContextTokens = (int)(schwelle / 0.9), CompactionThreshold = 0.9 };
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ClawdDotNet.Core.Tests.Engine;
|
||||
public sealed class LoopGuardTests
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// L1 — Schrittzählung
|
||||
// L1 — step counting
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -33,24 +33,24 @@ public sealed class LoopGuardTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// L2 — Die maxTokens-Semantik (Bug B3)
|
||||
// L2 — the maxTokens semantics (bug B3)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Kernproblem B3: RecordTokens summiert die TotalTokens jedes Schritts.
|
||||
/// Da jeder Schritt den kompletten Kontext erneut sendet, wächst diese Summe
|
||||
/// quadratisch — ein völlig normaler Chat bricht dadurch nach wenigen Schritten ab.
|
||||
/// Core problem B3: RecordTokens sums up the TotalTokens of every step.
|
||||
/// Since every step sends the complete context again, this sum grows
|
||||
/// quadratically — a completely normal chat thus aborts after a few steps.
|
||||
///
|
||||
/// Dieser Test beschreibt das GEWÜNSCHTE Verhalten: Ein Agent mit einem stabilen
|
||||
/// 20k-Kontext muss 10 Schritte durchhalten können.
|
||||
/// This test describes the DESIRED behavior: an agent with a stable
|
||||
/// 20k context must be able to last 10 steps.
|
||||
///
|
||||
/// Seit der Trennung von maxCumulativeTokens (Kostenbudget) und
|
||||
/// maxContextTokens (Kontextgröße) läuft er durch.
|
||||
/// Since the separation of maxCumulativeTokens (cost budget) and
|
||||
/// maxContextTokens (context size) it runs through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ein_stabiler_Kontext_ueberlebt_zehn_Schritte()
|
||||
{
|
||||
var config = new LoopGuardConfig(); // bewusst die Produktiv-Defaults
|
||||
var config = new LoopGuardConfig(); // deliberately the production defaults
|
||||
var guard = new LoopGuard(config);
|
||||
|
||||
Should.NotThrow(() =>
|
||||
@@ -58,7 +58,7 @@ public sealed class LoopGuardTests
|
||||
for (var step = 0; step < 10; step++)
|
||||
{
|
||||
guard.RecordStep();
|
||||
// Realistisch: 20k Kontext geht rein, ~500 Tokens kommen raus.
|
||||
// Realistic: 20k context goes in, ~500 tokens come out.
|
||||
guard.RecordTokens(20_000 + 500);
|
||||
}
|
||||
}, $"Ein Kontext von 20k über 10 Schritte ist normal und darf nicht am " +
|
||||
@@ -87,7 +87,7 @@ public sealed class LoopGuardTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// L3 — Thread-Sicherheit
|
||||
// L3 — thread safety
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -5,15 +5,15 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// #203 — die Compaction lief nach dem API-Aufruf.
|
||||
/// #203 — compaction ran after the API call.
|
||||
///
|
||||
/// <para>Sie stützte sich auf die <c>promptTokens</c> der gerade beantworteten Anfrage:
|
||||
/// Als der Wert vorlag, war der überfüllte Prompt längst abgerechnet, und gekürzt wurde
|
||||
/// erst für den nächsten Schritt. Genau ein Schritt zu spät — und zwar der teuerste.</para>
|
||||
/// <para>It relied on the <c>promptTokens</c> of the request just answered:
|
||||
/// by the time the value was available, the overfull prompt had long been billed, and truncation
|
||||
/// only happened for the next step. Exactly one step too late — and the most expensive one.</para>
|
||||
///
|
||||
/// <para>Die Zusage lautet jetzt: <b>Keine Anfrage geht über die Kontextgrenze hinaus.</b>
|
||||
/// Geprüft wird vor dem Senden, mit der Schätzung; die gemessenen Werte ziehen die
|
||||
/// Schätzung hinterher nach.</para>
|
||||
/// <para>The promise now reads: <b>no request goes beyond the context limit.</b>
|
||||
/// The check happens before sending, with the estimate; the measured values adjust the
|
||||
/// estimate afterwards.</para>
|
||||
/// </summary>
|
||||
public sealed class ProactiveCompactionTests
|
||||
{
|
||||
@@ -26,17 +26,17 @@ public sealed class ProactiveCompactionTests
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("agent-a");
|
||||
|
||||
// Schwelle: 1000 geschätzte Tokens.
|
||||
// Threshold: 1000 estimated tokens.
|
||||
agent.LoopGuard.MaxContextTokens = 2_000;
|
||||
agent.LoopGuard.CompactionThreshold = 0.5;
|
||||
agent.LoopGuard.SummaryModel = SummaryModel;
|
||||
|
||||
fixture.Client.AlwaysRespondsWithText("Verstanden.");
|
||||
|
||||
// Jede Nachricht bringt rund 150 geschaetzte Tokens mit; nach wenigen Runden
|
||||
// waere der Kontext ohne Eingriff ueber der Grenze. Klein genug, dass auch die
|
||||
// sechs geschuetzten Tail-Nachrichten zusammen noch unter die Grenze passen -
|
||||
// sonst koennte selbst eine Kompaktierung sie nicht mehr unterschreiten.
|
||||
// Every message brings about 150 estimated tokens; after a few rounds
|
||||
// the context would be above the limit without intervention. Small enough that even the
|
||||
// six protected tail messages together still fit below the limit -
|
||||
// otherwise not even a compaction could get under it any more.
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await fixture.Engine.ChatAsync(
|
||||
@@ -49,9 +49,9 @@ public sealed class ProactiveCompactionTests
|
||||
|
||||
anfragenAnDenAgenten.Count.ShouldBe(20);
|
||||
|
||||
// Der eigentliche Punkt: nicht "irgendwann wurde gekürzt", sondern "es ging nie
|
||||
// etwas Überfülltes hinaus". Vorher hing die Prüfung an den promptTokens der
|
||||
// Antwort — beim Fake immer 100 —, kam also nie über die Schwelle und kürzte nie.
|
||||
// The actual point: not "at some point it was truncated", but "nothing overfull ever
|
||||
// went out". Previously the check hung on the promptTokens of the
|
||||
// answer — always 100 with the fake —, so it never got over the threshold and never truncated.
|
||||
foreach (var anfrage in anfragenAnDenAgenten)
|
||||
{
|
||||
ContextCompactor.EstimateTokens(anfrage.Messages)
|
||||
@@ -77,12 +77,12 @@ public sealed class ProactiveCompactionTests
|
||||
agent, $"Frage {i}: " + new string('y', 600), "test-instance", default);
|
||||
}
|
||||
|
||||
// Es wurde überhaupt kompaktiert — sonst sagt der Test darüber nichts aus.
|
||||
// Compaction happened at all — otherwise the test says nothing about it.
|
||||
fixture.Client.ReceivedRequests.ShouldContain(r => r.Model == SummaryModel);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Kalibrierung
|
||||
// Calibration
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private static ContextCompactor NeuerKompaktierer()
|
||||
@@ -105,7 +105,7 @@ public sealed class ProactiveCompactionTests
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
|
||||
// Die API berechnet durchweg doppelt so viel wie geschätzt.
|
||||
// The API consistently charges twice as much as estimated.
|
||||
for (var i = 0; i < 20; i++)
|
||||
compactor.Calibrate("m", 1_000, 2_000);
|
||||
|
||||
@@ -119,9 +119,9 @@ public sealed class ProactiveCompactionTests
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
compactor.Calibrate("m", 1_000, 4_000); // einmal weit daneben
|
||||
compactor.Calibrate("m", 1_000, 4_000); // far off once
|
||||
|
||||
// Ein Messwert verschiebt den Faktor nur um sein Gewicht, nicht auf seinen Wert.
|
||||
// A measurement only shifts the factor by its weight, not to its value.
|
||||
compactor.EstimateFor("m", messages).ShouldBeLessThan(roh * 2);
|
||||
}
|
||||
|
||||
@@ -135,15 +135,15 @@ public sealed class ProactiveCompactionTests
|
||||
for (var i = 0; i < 50; i++)
|
||||
compactor.Calibrate("m", 1, 1_000_000);
|
||||
|
||||
// Höchstens das Vierfache — was darüber läge, ist kein Kalibrierungsfall mehr.
|
||||
// At most four times — anything above that is no longer a calibration case.
|
||||
compactor.EstimateFor("m", messages).ShouldBeLessThanOrEqualTo(roh * 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jedes_Modell_wird_fuer_sich_kalibriert()
|
||||
{
|
||||
// Verschiedene Tokenizer zerlegen denselben Text verschieden — ein gemeinsamer
|
||||
// Faktor wäre der Mittelwert von etwas, das nichts miteinander zu tun hat.
|
||||
// Different tokenizers split the same text differently — a common
|
||||
// factor would be the average of things that have nothing to do with each other.
|
||||
var messages = Conversation.Start().Repeat(4).Build();
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
@@ -162,8 +162,8 @@ public sealed class ProactiveCompactionTests
|
||||
var roh = ContextCompactor.EstimateTokens(messages);
|
||||
|
||||
var compactor = NeuerKompaktierer();
|
||||
compactor.Calibrate("m", 0, 5_000); // ohne Schätzung kein Verhältnis
|
||||
compactor.Calibrate("m", 1_000, 0); // ohne Messwert auch nicht
|
||||
compactor.Calibrate("m", 0, 5_000); // without an estimate no ratio
|
||||
compactor.Calibrate("m", 1_000, 0); // without a measurement neither
|
||||
|
||||
compactor.EstimateFor("m", messages).ShouldBe(roh);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ClawdDotNet.Core.Tests.Engine;
|
||||
public sealed class PromptCacheTests
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Aktivierung
|
||||
// Activation
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Theory]
|
||||
@@ -26,7 +26,7 @@ public sealed class PromptCacheTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Platzierung der Breakpoints
|
||||
// Placement of the breakpoints
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -54,7 +54,7 @@ public sealed class PromptCacheTests
|
||||
[Fact]
|
||||
public void Eine_AssistantNachricht_ohne_Inhalt_traegt_keinen_Breakpoint()
|
||||
{
|
||||
// assistant mit tool_calls hat keinen Textinhalt und kann keinen Block tragen.
|
||||
// assistant with tool_calls has no text content and cannot carry a block.
|
||||
var messages = Conversation.Start().User("A").Build();
|
||||
messages.Add(ChatMessage.AssistantWithToolCalls([
|
||||
new ToolCall { Id = "c1", Function = new ToolCallFunction { Name = "T", Arguments = "{}" } }
|
||||
@@ -69,7 +69,7 @@ public sealed class PromptCacheTests
|
||||
[Fact]
|
||||
public void Es_werden_hoechstens_zwei_Breakpoints_gesetzt()
|
||||
{
|
||||
// Anbieter erlauben nur eine begrenzte Zahl — sie dürfen sich nicht ansammeln.
|
||||
// Providers only allow a limited number — they must not accumulate.
|
||||
var messages = Conversation.Start().Repeat(10).Build();
|
||||
|
||||
PromptCache.ApplyBreakpoints(messages);
|
||||
@@ -101,14 +101,14 @@ public sealed class PromptCacheTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Präfix-Stabilität — der entscheidende Test
|
||||
// Prefix stability — the decisive test
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Der gecachte Prompt-Abschnitt muss zwischen zwei Schritten zeichengenau identisch
|
||||
/// sein. Ein einziger Zeitstempel im System-Prompt würde den Cache bei jedem Schritt
|
||||
/// verwerfen — die Kosten blieben unverändert, ohne dass es irgendwo auffiele.
|
||||
/// Genau davor schützt dieser Test.
|
||||
/// The cached prompt section must be identical to the character between two
|
||||
/// steps. A single timestamp in the system prompt would discard the cache at every step
|
||||
/// — the costs would stay unchanged without it being noticed anywhere.
|
||||
/// That is exactly what this test protects against.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Der_Praefix_bleibt_ueber_alle_Schritte_zeichengleich()
|
||||
@@ -118,7 +118,7 @@ public sealed class PromptCacheTests
|
||||
agent.Model = "anthropic/claude-sonnet-4-5";
|
||||
agent.PromptCaching = "on";
|
||||
|
||||
// Acht Tool-Schritte, dann eine Textantwort.
|
||||
// Eight tool steps, then a text answer.
|
||||
for (var i = 0; i < 8; i++)
|
||||
fixture.Client.RespondsWithToolCall("TestTool");
|
||||
fixture.Client.RespondsWithText("Fertig");
|
||||
@@ -127,8 +127,8 @@ public sealed class PromptCacheTests
|
||||
|
||||
fixture.Client.ReceivedRequests.Count.ShouldBe(9);
|
||||
|
||||
// Die system-Nachricht ist der stabile Präfix — sie muss in jedem Request
|
||||
// byte-identisch serialisiert werden.
|
||||
// The system message is the stable prefix — it must be serialized
|
||||
// byte-identically in every request.
|
||||
var systemPayloads = fixture.Client.ReceivedRequests
|
||||
.Select(r => JsonSerializer.Serialize(r.Messages[0]))
|
||||
.Distinct()
|
||||
@@ -147,7 +147,7 @@ public sealed class PromptCacheTests
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Returning("ok"));
|
||||
var agent = fixture.AddAgent("agent-nocache", "TestTool");
|
||||
agent.Model = "openai/gpt-4o";
|
||||
agent.PromptCaching = "auto"; // bei diesem Modell also aus
|
||||
agent.PromptCaching = "auto"; // so off for this model
|
||||
|
||||
fixture.Client.RespondsWithText("Fertig");
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Form des ausgehenden Requests. B11/T8: <c>max_tokens</c> muss gesetzt sein, damit ein
|
||||
/// Modell nicht in einem Schritt sein volles (teuerstes) Ausgabe-Limit ausschöpft.
|
||||
/// Shape of the outgoing request. B11/T8: <c>max_tokens</c> must be set so that a
|
||||
/// model does not exhaust its full (most expensive) output limit in one step.
|
||||
/// </summary>
|
||||
public sealed class RequestShapeTests
|
||||
{
|
||||
@@ -28,7 +28,7 @@ public sealed class RequestShapeTests
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("agent-a");
|
||||
agent.LoopGuard.MaxResponseTokens = 0; // Anbieter-Standard
|
||||
agent.LoopGuard.MaxResponseTokens = 0; // provider default
|
||||
fixture.Client.RespondsWithText("fertig");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
|
||||
|
||||
@@ -11,15 +11,15 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Die Entscheidungen aus der Durchsprache der Testbetriebsbefunde (#572, 2026-09-14):
|
||||
/// #577 (Abschluss), #579 (Vorpruefung), #581 (Wiederholungen), #589 (Zeit im Chat),
|
||||
/// #193 (Rahmung), #205 (AgentEditor) und #599 (Belegkosten).
|
||||
/// The decisions from the review of the test operation findings (#572, 2026-09-14):
|
||||
/// #577 (completion), #579 (pre-check), #581 (repetitions), #589 (time in chat),
|
||||
/// #193 (framing), #205 (AgentEditor) and #599 (receipt costs).
|
||||
/// </summary>
|
||||
public sealed class TestbetriebEntscheidungenTests
|
||||
{
|
||||
private static string? Text(ChatMessage m) => m.Content?.ToString();
|
||||
|
||||
// ─── #599 Belegkosten ───
|
||||
// ─── #599 receipt costs ───
|
||||
|
||||
[Fact]
|
||||
public async Task Beleg_verbucht_den_gemeldeten_Betrag_wie_der_Verbrauch()
|
||||
@@ -37,7 +37,7 @@ public sealed class TestbetriebEntscheidungenTests
|
||||
beleg.CostIsKnown.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ─── #581 Wiederholte Fehlschlaege ───
|
||||
// ─── #581 repeated failures ───
|
||||
|
||||
[Fact]
|
||||
public async Task Derselbe_Fehlschlag_bekommt_beim_zweiten_Mal_einen_Hinweis_und_beendet_beim_dritten_den_Lauf()
|
||||
@@ -80,7 +80,7 @@ public sealed class TestbetriebEntscheidungenTests
|
||||
public void Ein_abgerufenes_JSON_mit_Feld_error_ist_kein_Fehlschlag()
|
||||
=> WerkzeugFehler.TryRead(UntrustedContent.Rahmen("WebFetch", """{"error":"remote"}"""), out _).ShouldBeFalse();
|
||||
|
||||
// ─── #577 Abschluss ───
|
||||
// ─── #577 completion ───
|
||||
|
||||
[Fact]
|
||||
public async Task Leere_Antwort_wird_einmal_nachgefasst()
|
||||
@@ -176,7 +176,7 @@ public sealed class TestbetriebEntscheidungenTests
|
||||
.ShouldContain(LaufAbschluss.ErgebnisVorgabe);
|
||||
}
|
||||
|
||||
// ─── #579 Vorpruefung ───
|
||||
// ─── #579 pre-check ───
|
||||
|
||||
private sealed class VorpruefTool(string? grund) : IAgentTool
|
||||
{
|
||||
@@ -228,7 +228,7 @@ public sealed class TestbetriebEntscheidungenTests
|
||||
(await staging.CountPendingAsync(default)).ShouldBe(1);
|
||||
}
|
||||
|
||||
// ─── #193 Rahmung ───
|
||||
// ─── #193 framing ───
|
||||
|
||||
[Fact]
|
||||
public void Rahmen_entschaerft_eine_eingeschleuste_Schlussmarke()
|
||||
@@ -267,7 +267,7 @@ public sealed class TestbetriebEntscheidungenTests
|
||||
Text(fixture.Client.ReceivedRequests[0].Messages[0]).ShouldBe("Du bist agent-a.");
|
||||
}
|
||||
|
||||
// ─── #589 Zeit im Chat ───
|
||||
// ─── #589 time in chat ───
|
||||
|
||||
[Fact]
|
||||
public async Task Chatnachricht_traegt_die_Zeit_im_Kontext_aber_nicht_im_Verlauf()
|
||||
|
||||
@@ -7,12 +7,12 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Regressionstests fuer die Befunde aus dem Testbetrieb auf der Linux-VM (#572).
|
||||
/// Regression tests for the findings from test operation on the Linux VM (#572).
|
||||
/// </summary>
|
||||
public sealed class TestbetriebRegressionTests
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #578 — Abgebrochene Laeufe tragen ihre bezahlten Token
|
||||
// #578 — aborted runs carry the tokens they paid for
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -22,7 +22,7 @@ public sealed class TestbetriebRegressionTests
|
||||
var agent = fixture.AddAgent("agent-grenze", "TestTool");
|
||||
agent.LoopGuard.MaxSteps = 3;
|
||||
|
||||
// Jede Antwort ist ein Werkzeugaufruf mit 100 Prompt- und 20 Completion-Token.
|
||||
// Every answer is a tool call with 100 prompt and 20 completion tokens.
|
||||
fixture.Client.AlwaysRespondsWithToolCall("TestTool");
|
||||
|
||||
var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default);
|
||||
@@ -66,7 +66,7 @@ public sealed class TestbetriebRegressionTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #581 — Unvollstaendiges Argument-JSON
|
||||
// #581 — incomplete argument JSON
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Theory]
|
||||
@@ -82,7 +82,7 @@ public sealed class TestbetriebRegressionTests
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
|
||||
var agent = fixture.AddAgent("agent-abgeschnitten", "TestTool");
|
||||
|
||||
// Genau die Form aus dem Pruefstand: Das JSON bricht vor dem grossen Wert ab.
|
||||
// Exactly the shape from the test bench: the JSON breaks off before the large value.
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool", "{\"action\": \"write\", \"path\": \"gross.txt\"")
|
||||
.RespondsWithText("verstanden");
|
||||
@@ -98,7 +98,7 @@ public sealed class TestbetriebRegressionTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #589 — Zeit und Termin in der Auftragsnachricht
|
||||
// #589 — time and schedule in the assignment message
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -9,15 +9,15 @@ namespace ClawdDotNet.Core.Tests.Engine;
|
||||
public sealed class TokenAccountingTests
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// B4 — Prompt und Completion getrennt erfassen
|
||||
// B4 — record prompt and completion separately
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Prompt_und_Completion_werden_getrennt_aufsummiert()
|
||||
{
|
||||
// Bisher schätzte die UI 50/50. Real liegt das Verhältnis eher bei 95:5 —
|
||||
// und da Ausgabe-Tokens ein Vielfaches kosten, war die Kostenanzeige
|
||||
// um ein Mehrfaches daneben.
|
||||
// So far the UI estimated 50/50. In reality the ratio is more like 95:5 —
|
||||
// and since output tokens cost a multiple, the cost display was
|
||||
// off by several times.
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Returning("ok"));
|
||||
var agent = fixture.AddAgent("agent-tokens", "TestTool");
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class TokenAccountingTests
|
||||
var agent = fixture.AddAgent("agent-sum", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool") // 100 / 20 / 120 laut Fake
|
||||
.RespondsWithToolCall("TestTool") // 100 / 20 / 120 according to the fake
|
||||
.RespondsWithToolCall("TestTool")
|
||||
.RespondsWithText("Fertig");
|
||||
|
||||
@@ -56,14 +56,14 @@ public sealed class TokenAccountingTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// T9 — Cache-Wirkung messbar machen
|
||||
// T9 — make the cache effect measurable
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Gecachte_Tokens_werden_durchgereicht()
|
||||
{
|
||||
// Ohne diese Zahl liesse sich nicht belegen, ob das Prompt-Caching greift —
|
||||
// ein still wirkungsloser Cache wäre sonst nicht zu bemerken.
|
||||
// Without this number it could not be proven whether prompt caching works —
|
||||
// a silently ineffective cache would otherwise go unnoticed.
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("agent-cached");
|
||||
|
||||
@@ -89,7 +89,7 @@ public sealed class TokenAccountingTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// T3 — Compaction läuft mit dem günstigen Modell
|
||||
// T3 — compaction runs with the cheap model
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
@@ -135,14 +135,14 @@ public sealed class TokenAccountingTests
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Nur der wegfallende Teil wird zusammengefasst
|
||||
// Only the part that goes away is summarized
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Der_erhaltene_Tail_geht_nicht_in_den_Zusammenfassungs_Aufruf()
|
||||
{
|
||||
// Der Tail bleibt wörtlich erhalten — ihn zusätzlich zusammenzufassen
|
||||
// wäre doppelt bezahlter Kontext.
|
||||
// The tail is kept verbatim — summarizing it additionally
|
||||
// would be context paid for twice.
|
||||
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung.");
|
||||
var compactor = new ContextCompactor(client, TestLogging.Factory);
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// T2 aus der Token-Analyse: Ein einzelnes Tool-Ergebnis konnte den Kontext sprengen.
|
||||
/// Ein WebFetch mit dem Standardlimit von 512 KB entspricht rund 130.000 Tokens in
|
||||
/// EINER Antwort — die Compaction greift erst danach, bezahlt ist der Request längst.
|
||||
/// T2 from the token analysis: a single tool result could blow up the context.
|
||||
/// A WebFetch with the default limit of 512 KB corresponds to about 130,000 tokens in
|
||||
/// ONE answer — compaction only applies afterwards, the request has long been paid for.
|
||||
/// </summary>
|
||||
public sealed class ToolResultTruncationTests
|
||||
{
|
||||
@@ -46,7 +46,7 @@ public sealed class ToolResultTruncationTests
|
||||
await fixture.Engine.ChatAsync(agent, "Prüfe", "test-instance", default);
|
||||
|
||||
var context = fixture.Engine.GetChatContext(agent.AgentId);
|
||||
// Unveraendert heisst seit #193: ungekuerzt, nur gerahmt.
|
||||
// Since #193 unchanged means: untruncated, only framed.
|
||||
context.Single(m => m.Role == "tool").Content.ShouldBe(UntrustedContent.Rahmen("TestTool", klein));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user