Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs
T
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:18:43 +02:00

113 lines
4.5 KiB
C#

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<LoopLimitExceededException>(() => 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<LoopLimitExceededException>(() => guard.RecordStep());
ex.Message.ShouldContain("3");
}
// ═══════════════════════════════════════════════════════════
// L2 — the maxTokens semantics (bug B3)
// ═══════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
[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<LoopLimitExceededException>(() => 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<LoopLimitExceededException>(() => stepGuard.RecordStep())
.Message.ShouldContain("Schritt-Limit");
var tokenGuard = new LoopGuard(new LoopGuardConfig { MaxCumulativeTokens = 100 });
Should.Throw<LoopLimitExceededException>(() => 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);
}
}