Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Engine/ProactiveCompactionTests.cs
T
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

171 lines
6.6 KiB
C#

using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// #203 — die Compaction lief nach dem API-Aufruf.
///
/// <para>Sie stützte sich auf die <c>promptTokens</c> der gerade beantworteten Anfrage:
/// Als der Wert vorlag, war der überfüllte Prompt längst abgerechnet, und gekürzt wurde
/// erst für den nächsten Schritt. Genau ein Schritt zu spät — und zwar der teuerste.</para>
///
/// <para>Die Zusage lautet jetzt: <b>Keine Anfrage geht über die Kontextgrenze hinaus.</b>
/// Geprüft wird vor dem Senden, mit der Schätzung; die gemessenen Werte ziehen die
/// Schätzung hinterher nach.</para>
/// </summary>
public sealed class ProactiveCompactionTests
{
private const string AgentModel = "test/model";
private const string SummaryModel = "test/summary";
[Fact]
public async Task Keine_Anfrage_geht_ueber_die_Kontextgrenze_hinaus()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("agent-a");
// Schwelle: 1000 geschätzte Tokens.
agent.LoopGuard.MaxContextTokens = 2_000;
agent.LoopGuard.CompactionThreshold = 0.5;
agent.LoopGuard.SummaryModel = SummaryModel;
fixture.Client.AlwaysRespondsWithText("Verstanden.");
// Jede Nachricht bringt rund 150 geschaetzte Tokens mit; nach wenigen Runden
// waere der Kontext ohne Eingriff ueber der Grenze. Klein genug, dass auch die
// sechs geschuetzten Tail-Nachrichten zusammen noch unter die Grenze passen -
// sonst koennte selbst eine Kompaktierung sie nicht mehr unterschreiten.
for (var i = 0; i < 20; i++)
{
await fixture.Engine.ChatAsync(
agent, $"Frage {i}: " + new string('y', 600), "test-instance", default);
}
var anfragenAnDenAgenten = fixture.Client.ReceivedRequests
.Where(r => r.Model == AgentModel)
.ToList();
anfragenAnDenAgenten.Count.ShouldBe(20);
// Der eigentliche Punkt: nicht "irgendwann wurde gekürzt", sondern "es ging nie
// etwas Überfülltes hinaus". Vorher hing die Prüfung an den promptTokens der
// Antwort — beim Fake immer 100 —, kam also nie über die Schwelle und kürzte nie.
foreach (var anfrage in anfragenAnDenAgenten)
{
ContextCompactor.EstimateTokens(anfrage.Messages)
.ShouldBeLessThan(agent.LoopGuard.MaxContextTokens);
}
}
[Fact]
public async Task Gekuerzt_wird_mit_dem_guenstigen_Modell_bevor_gesendet_wird()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("agent-a");
agent.LoopGuard.MaxContextTokens = 2_000;
agent.LoopGuard.CompactionThreshold = 0.5;
agent.LoopGuard.SummaryModel = SummaryModel;
fixture.Client.AlwaysRespondsWithText("Verstanden.");
for (var i = 0; i < 20; i++)
{
await fixture.Engine.ChatAsync(
agent, $"Frage {i}: " + new string('y', 600), "test-instance", default);
}
// Es wurde überhaupt kompaktiert — sonst sagt der Test darüber nichts aus.
fixture.Client.ReceivedRequests.ShouldContain(r => r.Model == SummaryModel);
}
// ═══════════════════════════════════════════════════════════
// Kalibrierung
// ═══════════════════════════════════════════════════════════
private static ContextCompactor NeuerKompaktierer()
=> new(new FakeChatClient(), TestLogging.Factory);
[Fact]
public void Ohne_Messwerte_gilt_die_rohe_Schaetzung()
{
var messages = Conversation.Start().Repeat(4).Build();
NeuerKompaktierer().EstimateFor("m", messages)
.ShouldBe(ContextCompactor.EstimateTokens(messages));
}
[Fact]
public void Eine_unterschaetzte_Anfrage_hebt_die_Schaetzung_an()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
// Die API berechnet durchweg doppelt so viel wie geschätzt.
for (var i = 0; i < 20; i++)
compactor.Calibrate("m", 1_000, 2_000);
compactor.EstimateFor("m", messages).ShouldBeGreaterThan(roh);
}
[Fact]
public void Ein_einzelner_Ausreisser_kippt_die_Schwelle_nicht()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
compactor.Calibrate("m", 1_000, 4_000); // einmal weit daneben
// Ein Messwert verschiebt den Faktor nur um sein Gewicht, nicht auf seinen Wert.
compactor.EstimateFor("m", messages).ShouldBeLessThan(roh * 2);
}
[Fact]
public void Ein_absurder_Messwert_wird_gedeckelt()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
for (var i = 0; i < 50; i++)
compactor.Calibrate("m", 1, 1_000_000);
// Höchstens das Vierfache — was darüber läge, ist kein Kalibrierungsfall mehr.
compactor.EstimateFor("m", messages).ShouldBeLessThanOrEqualTo(roh * 4);
}
[Fact]
public void Jedes_Modell_wird_fuer_sich_kalibriert()
{
// Verschiedene Tokenizer zerlegen denselben Text verschieden — ein gemeinsamer
// Faktor wäre der Mittelwert von etwas, das nichts miteinander zu tun hat.
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
for (var i = 0; i < 20; i++)
compactor.Calibrate("gross", 1_000, 3_000);
compactor.EstimateFor("gross", messages).ShouldBeGreaterThan(roh);
compactor.EstimateFor("unberuehrt", messages).ShouldBe(roh);
}
[Fact]
public void Unbrauchbare_Messwerte_werden_verworfen()
{
var messages = Conversation.Start().Repeat(4).Build();
var roh = ContextCompactor.EstimateTokens(messages);
var compactor = NeuerKompaktierer();
compactor.Calibrate("m", 0, 5_000); // ohne Schätzung kein Verhältnis
compactor.Calibrate("m", 1_000, 0); // ohne Messwert auch nicht
compactor.EstimateFor("m", messages).ShouldBe(roh);
}
}