Testfundament aufbauen und Bestandsaufnahme dokumentieren
IChatCompletionClient aus OpenRouterClient extrahiert, damit AgentEngine und ContextCompactor ohne echte API-Aufrufe testbar sind. Neues Testprojekt tests/ClawdDotNet.Core.Tests (xUnit, Shouldly, NSubstitute, FsCheck) mit: - FakeChatClient (programmierbare Antwortfolgen, Deep-Copy der Requests) - ContextInvariants (prueft die API-Regeln fuer tool_call-Paarung) - Conversation-Builder fuer gueltige Testkonversationen - 26 Tests: Compaction, LoopGuard, 2 Property-Tests 10 Tests sind bewusst rot — sie reproduzieren die Bugs B1, B3 und B14 aus der Bestandsaufnahme und werden mit den Fixes gruen. Ausserdem: fehlende Tool-Projekte in slnx ergaenzt, Test-Pakete im packageSourceMapping der NuGet.Config eingetragen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
92e50d3ac4
commit
667cecce25
@@ -0,0 +1,150 @@
|
||||
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: 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.
|
||||
/// </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_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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Beschreibt die Form einer Konversation. FsCheck erzeugt davon zufällige
|
||||
/// Varianten und schrumpft sie im Fehlerfall auf das minimale Gegenbeispiel.
|
||||
/// </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>Im Fehlerfall auf das kleinste Gegenbeispiel reduzieren.</summary>
|
||||
private static IEnumerable<ConversationShape> 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user