using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; /// /// #605: In continuous operation compaction started at every step and stayed ineffective - /// stage 1 checked with the raw instead of the calibrated estimate, compacted only to just /// below the threshold, and the summarization call was not booked. /// public sealed class CompactionMarginTests { private const string Model = "test/model"; private static (ContextCompactor Compactor, FakeChatClient Client) Create() { var client = new FakeChatClient().AlwaysRespondsWithText("- Der Agent hat Daten geprueft."); return (new ContextCompactor(client, TestLogging.Factory), client); } /// /// The raw estimate as it would look after stage 1 - rebuilt in order to choose thresholds /// at which the old and new check decide differently. Values as in the /// ContextCompactor: the last 6 messages protected, tool results at 2,000 characters. /// private static int RawAfterStage1(List messages) { var copy = messages .Select(m => new ChatMessage { Role = m.Role, Content = m.Content, ToolCallId = m.ToolCallId, ToolCalls = m.ToolCalls }) .ToList(); var protection = Math.Max(0, copy.Count - 6); for (var i = 0; i < protection; i++) { if (copy[i].Role == "tool" && copy[i].Content is { Length: > 2_000 } content) copy[i].Content = content[..2_000] + "\n\n[... Ergebnis gekürzt ...]"; } return ContextCompactor.EstimateTokens(copy); } private static List LongHistory() => Conversation.Start().Repeat(times: 6, toolCallsPerCycle: 1, resultLength: 8_000).Build(); [Fact] public async Task After_stage_1_the_calibrated_estimate_decides() { var (compactor, client) = Create(); // This model counts about three times as many tokens as chars/4 suggests. for (var i = 0; i < 12; i++) compactor.Calibrate(Model, 1_000, 3_000); var messages = LongHistory(); var rawAfter = RawAfterStage1(messages); // Raw, stage 1 lies below the threshold, calibrated clearly above it. var threshold = (int)(rawAfter * 1.5); var guard = new LoopGuardConfig { MaxContextTokens = threshold * 2, CompactionThreshold = 0.5 }; await compactor.CompactIfNeededAsync(messages, guard, Model, default); client.CallCount.ShouldBe(1, "die rohe Zahl meldete faelschlich 'unter der Schwelle' und Stufe 2 lief nie"); ContextInvariants.AssertValid(messages); } [Fact] public async Task Just_below_the_threshold_is_not_enough_compaction_keeps_a_margin() { var (compactor, client) = Create(); var messages = LongHistory(); var rawAfter = RawAfterStage1(messages); // Stage 1 brings it just below the threshold (90 %), but not below the target (55 %). var threshold = (int)(rawAfter * 1.2); var guard = new LoopGuardConfig { MaxContextTokens = (int)(threshold / 0.9), CompactionThreshold = 0.9 }; await compactor.CompactIfNeededAsync(messages, guard, Model, default); client.CallCount.ShouldBe(1, "knapp unter der Schwelle waere beim naechsten Schritt wieder darueber"); ContextInvariants.AssertValid(messages); } [Fact] public async Task The_summary_call_is_booked() { var (compactor, _) = Create(); var booked = new List(); var messages = Conversation.Start().Repeat(15).Build(); await compactor.CompactIfNeededAsync( messages, new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 }, Model, default, booked.Add); booked.ShouldHaveSingleItem().PromptTokens.ShouldBe(100); } [Fact] public async Task Below_the_threshold_nothing_is_booked() { var (compactor, client) = Create(); var booked = new List(); await compactor.CompactIfNeededAsync( Conversation.Start().Repeat(2).Build(), new LoopGuardConfig { MaxContextTokens = 10_000_000, CompactionThreshold = 0.9 }, Model, default, booked.Add); booked.ShouldBeEmpty(); client.CallCount.ShouldBe(0); } }