using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; public sealed class LoopGuardTests { // ═══════════════════════════════════════════════════════════ // L1 — Schrittzählung // ═══════════════════════════════════════════════════════════ [Fact] public void RecordStep_erlaubt_genau_MaxSteps_Schritte() { var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = 5 }); for (var i = 0; i < 5; i++) guard.RecordStep(); guard.Steps.ShouldBe(5); Should.Throw(() => guard.RecordStep()); } [Fact] public void RecordStep_nennt_das_erreichte_Limit_in_der_Meldung() { var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = 3 }); for (var i = 0; i < 3; i++) guard.RecordStep(); var ex = Should.Throw(() => guard.RecordStep()); ex.Message.ShouldContain("3"); } // ═══════════════════════════════════════════════════════════ // L2 — Die maxTokens-Semantik (Bug B3) // ═══════════════════════════════════════════════════════════ /// /// 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. /// /// Dieser Test beschreibt das GEWÜNSCHTE Verhalten: Ein Agent mit einem stabilen /// 20k-Kontext muss 10 Schritte durchhalten können. /// /// Seit der Trennung von maxCumulativeTokens (Kostenbudget) und /// maxContextTokens (Kontextgröße) läuft er durch. /// [Fact] public void Ein_stabiler_Kontext_ueberlebt_zehn_Schritte() { var config = new LoopGuardConfig(); // bewusst die Produktiv-Defaults var guard = new LoopGuard(config); Should.NotThrow(() => { for (var step = 0; step < 10; step++) { guard.RecordStep(); // Realistisch: 20k Kontext geht rein, ~500 Tokens kommen raus. guard.RecordTokens(20_000 + 500); } }, $"Ein Kontext von 20k über 10 Schritte ist normal und darf nicht am " + $"Kostenbudget {config.MaxCumulativeTokens} scheitern."); } [Fact] public void RecordTokens_greift_wenn_das_Kostenbudget_wirklich_erschoepft_ist() { var guard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = 1_000 }); Should.Throw(() => guard.RecordTokens(1_001)); } [Fact] public void Die_Meldung_unterscheidet_Schritt_und_Kostenlimit() { var stepGuard = new LoopGuard(new LoopGuardConfig { MaxSteps = 1 }); stepGuard.RecordStep(); Should.Throw(() => stepGuard.RecordStep()) .Message.ShouldContain("Schritt-Limit"); var tokenGuard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = 100 }); Should.Throw(() => tokenGuard.RecordTokens(101)) .Message.ShouldContain("Kostenbudget"); } // ═══════════════════════════════════════════════════════════ // L3 — Thread-Sicherheit // ═══════════════════════════════════════════════════════════ [Fact] public void RecordStep_zaehlt_unter_Parallelzugriff_korrekt() { var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = int.MaxValue }); Parallel.For(0, 1_000, _ => guard.RecordStep()); guard.Steps.ShouldBe(1_000); } [Fact] public void RecordTokens_summiert_unter_Parallelzugriff_korrekt() { var guard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = int.MaxValue }); Parallel.For(0, 1_000, _ => guard.RecordTokens(10)); guard.Tokens.ShouldBe(10_000); } }