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; /// /// Regressionstests für Bug B1: Die Compaction behält blind die letzten N Nachrichten. /// Liegt diese Grenze mitten in einer Tool-Sequenz, entsteht eine tool-Antwort ohne /// zugehörigen assistant-tool_call — die API lehnt das mit HTTP 400 ab. /// public sealed class ContextCompactorTests { private const string Model = "test/model"; /// Schwelle so gesetzt, dass Compaction sicher ausgelöst wird. private static LoopGuardConfig AlwaysCompact => new() { MaxContextTokens = 1_000, CompactionThreshold = 0.5 // Schwelle = 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 — Die eigentliche Bug-Reproduktion // ═══════════════════════════════════════════════════════════ [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) { // Je nach Anzahl der Tool-Calls fällt die "letzte 6 Nachrichten"-Grenze // an eine andere Stelle im Zyklus. Mindestens eine Variante trifft mitten hinein. var (compactor, _) = CreateCompactor(); var messages = Conversation.Start() .Repeat(times: 4, toolCallsPerCycle: toolCallsPerCycle, resultLength: 200) .Build(); ContextInvariants.AssertValid(messages); // Vorbedingung: Ausgangslage ist gültig await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); ContextInvariants.AssertValid(messages); } [Fact] public async Task Compaction_trennt_Toolantwort_nie_von_ihrem_Aufruf() { // Gezielt konstruiert: Der Tail von 6 Nachrichten beginnt genau bei einer tool-Antwort. 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, 5_000, AlwaysCompact, Model, default); ContextInvariants.AssertValid(messages); // Keine verwaiste tool-Antwort 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, 5_000, 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, 5_000, AlwaysCompact, Model, default); messages.ShouldNotContain(m => m.Role == "system"); } // ═══════════════════════════════════════════════════════════ // C5/C6 — Fehlerfälle der Zusammenfassung // ═══════════════════════════════════════════════════════════ [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, 5_000, 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, 5_000, 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, 5_000, 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, 5_000, 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 — Schätzung und Schwelle // ═══════════════════════════════════════════════════════════ [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() { // Wichtig fürs Budget: Compaction darf nicht unnötig ein Modell anwerfen. var (compactor, client) = CreateCompactor(); var messages = Conversation.Start().Repeat(2).Build(); var compacted = await compactor.CompactIfNeededAsync(messages, 10, 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, 50_000, AlwaysCompact, Model, default); compacted.ShouldBeTrue(); messages.Count.ShouldBeLessThan(before); ContextInvariants.AssertValid(messages); } }