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,239 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ContextCompactorTests
|
||||
{
|
||||
private const string Model = "test/model";
|
||||
|
||||
/// <summary>Schwelle so gesetzt, dass Compaction sicher ausgelöst wird.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user