using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; public sealed class LoopGuardTests { // ═══════════════════════════════════════════════════════════ // L1 — step counting // ═══════════════════════════════════════════════════════════ [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 — the maxTokens semantics (bug B3) // ═══════════════════════════════════════════════════════════ /// /// 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. /// /// This test describes the DESIRED behavior: an agent with a stable /// 20k context must be able to last 10 steps. /// /// Since the separation of maxCumulativeTokens (cost budget) and /// maxContextTokens (context size) it runs through. /// [Fact] public void Ein_stabiler_Kontext_ueberlebt_zehn_Schritte() { var config = new LoopGuardConfig(); // deliberately the production defaults var guard = new LoopGuard(config); Should.NotThrow(() => { for (var step = 0; step < 10; step++) { guard.RecordStep(); // 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 " + $"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 safety // ═══════════════════════════════════════════════════════════ [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); } }