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; /// /// Regression tests for bug B1: compaction blindly keeps the last N messages. /// If this boundary lies in the middle of a tool sequence, a tool answer without /// the corresponding assistant tool_call results — the API rejects that with HTTP 400. /// public sealed class ContextCompactorTests { private const string Model = "test/model"; /// Threshold set so that compaction is surely triggered. private static LoopGuardConfig AlwaysCompact => new() { MaxContextTokens = 1_000, CompactionThreshold = 0.5 // threshold = 500 tokens }; private static LoopGuardConfig NeverCompact => new() { MaxContextTokens = 10_000_000, CompactionThreshold = 0.9 }; private static (ContextCompactor Compactor, FakeChatClient Client) CreateCompactor( string summary = "- Der Agent hat Daten geprüft.\n- Ergebnis war unauffällig.") { var client = new FakeChatClient().AlwaysRespondsWithText(summary); return (new ContextCompactor(client, TestLogging.Factory), client); } // ═══════════════════════════════════════════════════════════ // C1/C2 — the actual bug reproduction // ═══════════════════════════════════════════════════════════ [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(5)] [InlineData(6)] [InlineData(7)] [InlineData(8)] public async Task Compaction_erhaelt_gueltige_Sequenz_bei_beliebiger_Toolzyklus_Laenge(int toolCallsPerCycle) { // Depending on the number of tool calls the "last 6 messages" boundary falls // at a different place in the cycle. At least one variant hits right in the middle. var (compactor, _) = CreateCompactor(); var messages = Conversation.Start() .Repeat(times: 4, toolCallsPerCycle: toolCallsPerCycle, resultLength: 200) .Build(); ContextInvariants.AssertValid(messages); // Precondition: the initial state is valid await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); ContextInvariants.AssertValid(messages); } [Fact] public async Task Compaction_trennt_Toolantwort_nie_von_ihrem_Aufruf() { // Constructed on purpose: the tail of 6 messages begins exactly at a tool answer. var (compactor, _) = CreateCompactor(); var messages = Conversation.Start() .User("Erste Anfrage") .ToolCycle(count: 3, resultLength: 300) .Assistant("Zwischenergebnis") .User("Zweite Anfrage") .ToolCycle(count: 3, resultLength: 300) .Build(); await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); ContextInvariants.AssertValid(messages); // No orphaned tool answer for (var i = 0; i < messages.Count; i++) { if (messages[i].Role != "tool") continue; var hasParent = messages .Take(i) .Any(m => m.ToolCalls?.Any(tc => tc.Id == messages[i].ToolCallId) == true); hasParent.ShouldBeTrue( $"tool-Antwort an Position {i} (id={messages[i].ToolCallId}) hat keinen Aufruf mehr.\n" + ContextInvariants.Describe(messages)); } } // ═══════════════════════════════════════════════════════════ // C3/C4 — System-Prompt // ═══════════════════════════════════════════════════════════ [Fact] public async Task Compaction_behaelt_den_SystemPrompt_an_Position_0() { const string systemPrompt = "Du bist ein sehr spezifischer Testagent."; var (compactor, _) = CreateCompactor(); var messages = Conversation.Start(systemPrompt).Repeat(6).Build(); await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); messages[0].Role.ShouldBe("system"); messages[0].Content.ShouldBe(systemPrompt); messages.Count(m => m.Role == "system").ShouldBe(1); } [Fact] public async Task Compaction_erfindet_keinen_SystemPrompt_wenn_keiner_da_war() { var (compactor, _) = CreateCompactor(); var messages = Conversation.Start(systemPrompt: null).Repeat(6).Build(); await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); messages.ShouldNotContain(m => m.Role == "system"); } // ═══════════════════════════════════════════════════════════ // C5/C6 — failure cases of the summary // ═══════════════════════════════════════════════════════════ [Fact] public async Task Compaction_laesst_Nachrichten_unveraendert_wenn_die_Zusammenfassung_fehlschlaegt() { var client = new FakeChatClient().Throws(new HttpRequestException("API nicht erreichbar")); var compactor = new ContextCompactor(client, TestLogging.Factory); var messages = Conversation.Start().Repeat(6).Build(); var before = messages.Count; await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); ContextInvariants.AssertValid(messages); messages.Count.ShouldBe(before, "bei fehlgeschlagener Zusammenfassung darf nichts verloren gehen"); } [Fact] public async Task Compaction_laesst_Nachrichten_unveraendert_wenn_die_Zusammenfassung_leer_ist() { var client = new FakeChatClient().AlwaysRespondsWithText(" "); var compactor = new ContextCompactor(client, TestLogging.Factory); var messages = Conversation.Start().Repeat(6).Build(); var before = messages.Count; await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); ContextInvariants.AssertValid(messages); messages.Count.ShouldBe(before); } // ═══════════════════════════════════════════════════════════ // C7/C8 — tool result pruning // ═══════════════════════════════════════════════════════════ [Fact] public async Task Pruning_kuerzt_lange_Toolergebnisse_ausserhalb_des_geschuetzten_Endes() { var (compactor, _) = CreateCompactor(); var messages = Conversation.Start() .Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 8_000) .Build(); await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); ContextInvariants.AssertValid(messages); messages.Where(m => m.Role == "tool") .ShouldAllBe(m => m.Content!.Length <= 8_000, "kein Tool-Ergebnis darf nach dem Pruning gewachsen sein"); } [Fact] public async Task Pruning_laesst_kurze_Toolergebnisse_unveraendert() { var (compactor, _) = CreateCompactor(); var messages = Conversation.Start() .Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 100) .Build(); await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); foreach (var content in messages.Where(m => m.Role == "tool").Select(m => m.Content)) (content ?? "").ShouldNotContain("gekürzt", Case.Insensitive); } // ═══════════════════════════════════════════════════════════ // C9/C10 — estimate and threshold // ═══════════════════════════════════════════════════════════ [Fact] public void EstimateTokens_waechst_monoton_mit_dem_Inhalt() { var klein = Conversation.Start().Repeat(1).Build(); var mittel = Conversation.Start().Repeat(5).Build(); var gross = Conversation.Start().Repeat(20).Build(); var a = ContextCompactor.EstimateTokens(klein); var b = ContextCompactor.EstimateTokens(mittel); var c = ContextCompactor.EstimateTokens(gross); a.ShouldBeLessThan(b); b.ShouldBeLessThan(c); } [Fact] public async Task Unterhalb_der_Schwelle_wird_kein_ApiCall_ausgeloest() { // Important for the budget: compaction must not fire up a model unnecessarily. var (compactor, client) = CreateCompactor(); var messages = Conversation.Start().Repeat(2).Build(); var compacted = await compactor.CompactIfNeededAsync(messages, NeverCompact, Model, default); compacted.ShouldBeFalse(); client.CallCount.ShouldBe(0); } [Fact] public async Task Compaction_reduziert_die_Nachrichtenzahl_deutlich() { var (compactor, _) = CreateCompactor(); var messages = Conversation.Start().Repeat(15).Build(); var before = messages.Count; var compacted = await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default); compacted.ShouldBeTrue(); messages.Count.ShouldBeLessThan(before); ContextInvariants.AssertValid(messages); } }