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. /// /// Er schlägt mit den aktuellen Defaults fehl und wird grün, sobald /// maxCumulativeTokens (Kostenbudget) und maxContextTokens (Kontextgröße) /// getrennt sind. /// [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 " + $"Limit maxTokens={config.MaxTokens} scheitern."); } [Fact] public void RecordTokens_greift_wenn_das_Kostenbudget_wirklich_erschoepft_ist() { var guard = new LoopGuard(new LoopGuardConfig { MaxTokens = 1_000 }); Should.Throw(() => guard.RecordTokens(1_001)); } // ═══════════════════════════════════════════════════════════ // 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 { MaxTokens = int.MaxValue }); Parallel.For(0, 1_000, _ => guard.RecordTokens(10)); guard.Tokens.ShouldBe(10_000); } }