Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorTests.cs
RichardandClaude Opus 5 cffd6f4b77 Bugtracker-Durchgang: 13 Bugs behoben, Build ohne Warnungen
Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111):
Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact).

Sicherheit und Zielplattform
  #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft
       fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException)
       verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und
       der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem
       Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und
       TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen.
  #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt.
       Eine Schreibweise statt einer Sonderregel je Windows-Form.
  #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert
       zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis
       des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs.

Agentenkommunikation
  #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu
       MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat.
  #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und
       reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes
       Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein.
  #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld
       gelesen.

Betrieb und Oberflaeche
  #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces
       an ihrer Meldung - auch ueber Taktgrenzen hinweg.
  #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den
       anderen Spalten nicht den Platz nimmt.
  #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste.
  #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die
       helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar
       bleibt statt auf einen Wert zusammenzufallen.

Hygiene
  #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten.
  #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten
       Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5).
  #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die
       Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet
       jetzt, WELCHER Zugangsschluessel fehlt, statt an einer
       KeyNotFoundException zu scheitern.
  #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen
       Prompt-Groessen kalibrieren die Schaetzung je Modell.

Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer
Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten
Stand geprueft, damit belegt ist, dass er den Fehler faengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:04:48 +02:00

240 lines
10 KiB
C#

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, 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, 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, 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, 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, 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, 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, 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, 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, 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, AlwaysCompact, Model, default);
compacted.ShouldBeTrue();
messages.Count.ShouldBeLessThan(before);
ContextInvariants.AssertValid(messages);
}
}