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>
151 lines
5.6 KiB
C#
151 lines
5.6 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Property-based tests: instead of individual examples, hundreds of random
|
|
/// but valid conversations are generated and it is checked that compaction preserves the
|
|
/// API invariants in EVERY case.
|
|
///
|
|
/// Exactly these edge cases — where the tail boundary happens to fall in the middle of a
|
|
/// tool cycle — nobody writes down by hand. Bug B1 belongs to this class.
|
|
/// </summary>
|
|
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_always_keeps_a_valid_message_sequence(ConversationShape shape)
|
|
{
|
|
var messages = shape.ToMessages();
|
|
|
|
// Precondition: the generator only produces valid initial sequences.
|
|
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, 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_keeps_the_system_prompt_exactly_once(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, 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
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Describes the shape of a conversation. FsCheck generates random
|
|
/// variants of it and shrinks them in case of failure to the minimal counterexample.
|
|
/// </summary>
|
|
public sealed record ConversationShape(bool HasSystemPrompt, IReadOnlyList<TurnShape> Turns)
|
|
{
|
|
public List<ChatMessage> 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<ConversationShape> 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);
|
|
}
|
|
|
|
/// <summary>In case of failure reduce to the smallest counterexample.</summary>
|
|
private static IEnumerable<ConversationShape> Shrink(ConversationShape shape)
|
|
{
|
|
// Fewer 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 };
|
|
}
|
|
}
|