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_allows_exactly_MaxSteps_steps() { 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_names_the_reached_limit_in_the_message() { 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 A_stable_context_survives_ten_steps() { 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_applies_when_the_cost_budget_is_really_exhausted() { var guard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = 1_000 }); Should.Throw(() => guard.RecordTokens(1_001)); } [Fact] public void The_message_distinguishes_step_and_cost_limit() { 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_counts_correctly_under_concurrent_access() { 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_sums_correctly_under_concurrent_access() { var guard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = int.MaxValue }); Parallel.For(0, 1_000, _ => guard.RecordTokens(10)); guard.Tokens.ShouldBe(10_000); } }