using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; using FsCheck; using FsCheck.Xunit; namespace ClawdDotNet.Core.Tests.Engine; /// /// Property-Based Tests: Statt einzelner Beispiele werden hunderte zufällige, /// aber gültige Konversationen erzeugt und geprüft, dass die Compaction die /// API-Invarianten in JEDEM Fall erhält. /// /// Genau diese Grenzfälle — wo die Tail-Grenze zufällig mitten in einen /// Tool-Zyklus fällt — schreibt von Hand niemand auf. Bug B1 fällt in diese Klasse. /// public sealed class ContextCompactorPropertyTests { private const string Model = "test/model"; private static readonly LoopGuardConfig AlwaysCompact = new() { MaxContextTokens = 1_000, CompactionThreshold = 0.5 }; [Property(MaxTest = 300, Arbitrary = [typeof(ConversationArbitrary)])] public Property Compaction_erhaelt_immer_eine_gueltige_Nachrichtenfolge(ConversationShape shape) { var messages = shape.ToMessages(); // Vorbedingung: Der Generator erzeugt nur gültige Ausgangsfolgen. if (!ContextInvariants.IsValid(messages)) return false.ToProperty().Label("Generator hat eine ungültige Folge erzeugt"); var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung des Verlaufs."); var compactor = new ContextCompactor(client, TestLogging.Factory); compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default) .GetAwaiter().GetResult(); var valid = ContextInvariants.IsValid(messages); return valid.ToProperty() .Label($"Nach Compaction ungültig. Ausgangsform: {shape}\n{ContextInvariants.Describe(messages)}"); } [Property(MaxTest = 300, Arbitrary = [typeof(ConversationArbitrary)])] public Property Compaction_behaelt_den_SystemPrompt_genau_einmal(ConversationShape shape) { var messages = shape.ToMessages(); var hadSystem = messages.Any(m => m.Role == "system"); var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung."); var compactor = new ContextCompactor(client, TestLogging.Factory); compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default) .GetAwaiter().GetResult(); var systemCount = messages.Count(m => m.Role == "system"); var expected = hadSystem ? 1 : 0; return (systemCount == expected).ToProperty() .Label($"system-Nachrichten: erwartet {expected}, gefunden {systemCount}"); } } // ═══════════════════════════════════════════════════════════════ // Generator // ═══════════════════════════════════════════════════════════════ /// /// Beschreibt die Form einer Konversation. FsCheck erzeugt davon zufällige /// Varianten und schrumpft sie im Fehlerfall auf das minimale Gegenbeispiel. /// public sealed record ConversationShape(bool HasSystemPrompt, IReadOnlyList Turns) { public List ToMessages() { var c = Conversation.Start(HasSystemPrompt ? "Du bist ein Testagent." : null); foreach (var turn in Turns) { c.User($"Anfrage mit {turn.ToolCalls} Tool-Aufrufen"); if (turn.ToolCalls > 0) c.ToolCycle(turn.ToolCalls, turn.ResultLength); if (turn.EndsWithText) c.Assistant("Abschließende Antwort des Agenten."); } return c.Build(); } public override string ToString() => $"system={HasSystemPrompt}, turns=[{string.Join(", ", Turns)}]"; } public sealed record TurnShape(int ToolCalls, int ResultLength, bool EndsWithText) { public override string ToString() => $"{ToolCalls}tc/{ResultLength}b/{(EndsWithText ? "text" : "offen")}"; } public static class ConversationArbitrary { public static Arbitrary Conversations() { var turnGen = from toolCalls in Gen.Choose(0, 4) from resultLength in Gen.Elements(20, 200, 2_500, 9_000) from endsWithText in Gen.Elements(true, false) select new TurnShape(toolCalls, resultLength, endsWithText); var gen = from hasSystem in Gen.Elements(true, false) from turnCount in Gen.Choose(1, 12) from turns in Gen.ListOf(turnCount, turnGen) select new ConversationShape(hasSystem, turns.ToList()); return Arb.From(gen, Shrink); } /// Im Fehlerfall auf das kleinste Gegenbeispiel reduzieren. private static IEnumerable Shrink(ConversationShape shape) { // Weniger Turns for (var i = 0; i < shape.Turns.Count; i++) { var reduced = shape.Turns.Where((_, idx) => idx != i).ToList(); if (reduced.Count > 0) yield return shape with { Turns = reduced }; } // Weniger Tool-Calls pro Turn for (var i = 0; i < shape.Turns.Count; i++) { var turn = shape.Turns[i]; if (turn.ToolCalls <= 0) continue; var reduced = shape.Turns.ToList(); reduced[i] = turn with { ToolCalls = turn.ToolCalls - 1 }; yield return shape with { Turns = reduced }; } if (shape.HasSystemPrompt) yield return shape with { HasSystemPrompt = false }; } }