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>
This commit is contained in:
Richard
2026-09-03 19:04:48 +02:00
co-authored by Claude Opus 5
parent 740649789e
commit cffd6f4b77
44 changed files with 1942 additions and 228 deletions
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.App.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClawdDotNet.App\ClawdDotNet.App.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,97 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.App.Tests;
/// <summary>
/// #375: Die Einstellungsseite zeigte ein Log-Verzeichnis, das nie galt.
///
/// <c>InstanceConfig.LogDirectory</c> war mit <c>"./Logs"</c> vorbelegt und damit nie
/// leer — der Zweig auf die Anwendungseinstellung war unerreichbar. Aufgelöst wurde der
/// Wert zudem mit <c>Path.GetFullPath</c> ohne Basis, also gegen das Arbeitsverzeichnis
/// des Prozesses: Wo die Logs landeten, hing davon ab, aus welchem Verzeichnis jemand
/// gestartet hatte. Für den Zielbetrieb (systemd-Dienst, startet in <c>/</c>) war das
/// <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.
/// </summary>
public sealed class LogDirectoryResolutionTests
{
private static readonly string InstancePath =
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test"));
private static readonly string ApplicationLogDirectory =
Path.Combine(AppPaths.DataDirectory, "Logs");
private static InstanceConfig Instance(string logDirectory, string? workingDirectory = null)
=> new()
{
LogDirectory = logDirectory,
WorkingDirectory = workingDirectory ?? InstancePath
};
[Fact]
public void Die_Vorbelegung_ist_leer()
{
// Der Kern des Befunds: Solange hier "./Logs" stand, konnte die
// Anwendungseinstellung nicht greifen.
new InstanceConfig().LogDirectory.ShouldBe("");
}
[Fact]
public void Ohne_Angabe_in_der_Instanz_gilt_die_Anwendungseinstellung()
{
AppHost.ResolveLogDirectory(Instance(""), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
[Fact]
public void Ein_absoluter_Wert_in_der_Instanz_geht_vor()
{
var eigen = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs"));
AppHost.ResolveLogDirectory(Instance(eigen), ApplicationLogDirectory, InstancePath)
.ShouldBe(eigen);
}
[Fact]
public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis()
{
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler.
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses()
{
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
.ShouldNotBe(Path.GetFullPath("./Logs"));
}
[Fact]
public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck()
{
// Das Feld steht seit CreateInstance absolut in der Datei, kann aber von Hand
// geleert worden sein. Dann trägt der Ort, aus dem geladen wurde.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: ""), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Ein_relatives_WorkingDirectory_faellt_ebenfalls_auf_den_Instanzpfad_zurueck()
{
// "./data/" ist die alte Vorbelegung des Feldes und wäre wieder gegen das
// Arbeitsverzeichnis des Prozesses aufgelöst worden.
AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: "./data/"), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs"));
}
[Fact]
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
{
AppHost.ResolveLogDirectory(Instance(" "), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
}
@@ -0,0 +1,88 @@
using System.Text.Json;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Die andere Hälfte von #375: Bestehende Instanzen tragen <c>"logDirectory": "./Logs"</c>
/// in ihrer <c>InstanceSettings.json</c> — <see cref="InstanceDirectoryManager.CreateInstance"/>
/// hat es dort hineingeschrieben. Der Wert hatte nie die Bedeutung, die er vorgab, und
/// wird beim Laden zu „leer", also zur Anwendungseinstellung.
///
/// Wer bewusst einen anderen Pfad eingetragen hat, behält ihn.
/// </summary>
public sealed class InstanceLogDirectoryMigrationTests : IDisposable
{
private readonly string _instancesDir;
private readonly InstanceDirectoryManager _manager;
public InstanceLogDirectoryMigrationTests()
{
_instancesDir = Path.Combine(
Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Instances");
_manager = new InstanceDirectoryManager(_instancesDir);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_instancesDir)!, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
/// <summary>Legt eine Instanz an und setzt logDirectory in der Datei auf den Altwert.</summary>
private string InstanceWithLogDirectory(string value)
{
var dir = _manager.CreateInstance("Test");
var path = Path.Combine(dir, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(path))!;
config.LogDirectory = value;
File.WriteAllText(path, JsonSerializer.Serialize(config));
return dir;
}
[Theory]
[InlineData("./Logs")]
[InlineData(@".\Logs")]
[InlineData("Logs")]
public void Die_alte_Vorbelegung_wird_zu_keiner_Angabe(string alt)
{
var dir = InstanceWithLogDirectory(alt);
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe("");
}
[Fact]
public void Ein_bewusst_gesetzter_Pfad_bleibt_stehen()
{
var eigen = Path.Combine(Path.GetTempPath(), "clawd-eigene-logs");
var dir = InstanceWithLogDirectory(eigen);
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe(eigen);
}
[Fact]
public void Die_Datei_wird_beim_Laden_nicht_zurueckgeschrieben()
{
// Die Migration greift nur im Speicher. Ein Ladevorgang darf nichts anfassen —
// erst das nächste Speichern lässt den Altwert verschwinden.
var dir = InstanceWithLogDirectory("./Logs");
var path = Path.Combine(dir, "InstanceSettings.json");
_manager.LoadInstanceConfig(dir);
File.ReadAllText(path).ShouldContain("./Logs");
}
[Fact]
public void Eine_neue_Instanz_bekommt_gar_keinen_Wert()
{
var dir = _manager.CreateInstance("Frisch");
_manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe("");
File.ReadAllText(Path.Combine(dir, "InstanceSettings.json")).ShouldNotContain("./Logs");
}
}
@@ -0,0 +1,200 @@
using ClawdDotNet.App.Services;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #376: Jedes Modul schreibt in seine eigene Datei, und eingesammelt wurde Datei für
/// Datei — also alphabetisch nach Modulname. In der Log-Ansicht standen die Zeilen
/// damit in Blöcken je Modul statt in der Reihenfolge, in der sie entstanden sind.
/// Beobachtet wurde eine Zeile von 14:34:40 über vier Zeilen von 14:34:39.
///
/// Das Dateisystem wird hier nicht abstrahiert: <see cref="LogTail"/> ist genau der
/// Umgang mit echten, nebenher wachsenden Dateien — eine Abstraktion würde das
/// Verhalten verstecken, um das es geht.
/// </summary>
public sealed class LogTailTests : IDisposable
{
private readonly string _logDirectory;
private readonly string _today;
public LogTailTests()
{
_logDirectory = Path.Combine(
Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Logs");
// LogTail liest ausschließlich das Verzeichnis des heutigen Tages, und zwar
// nach Ortszeit — die Schreibseite legt es genauso an.
_today = Path.Combine(_logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
Directory.CreateDirectory(_today);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_logDirectory)!, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private void Write(string module, params string[] lines)
=> File.AppendAllLines(Path.Combine(_today, $"{module}.log"), lines);
private LogTail Tail() => new(_logDirectory);
// ═══════════════════════════════════════════════════════════
// Reihenfolge über Moduldateien hinweg
// ═══════════════════════════════════════════════════════════
[Fact]
public void Zeilen_aus_mehreren_Modulen_stehen_in_zeitlicher_Reihenfolge()
{
// Genau der beobachtete Fall: "Deploymentcenter" steht alphabetisch vor
// "Startup", seine Zeile ist aber die jüngste von allen.
Write("Deploymentcenter", "[14:34:40.246] [INF] kein Token hinterlegt");
Write("Startup",
"[14:34:39.840] [INF] ClawdDotNet startet",
"[14:34:39.871] [INF] Instanz-Verzeichnis",
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[14:34:39.840] [INF] ClawdDotNet startet",
"[14:34:39.871] [INF] Instanz-Verzeichnis",
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert",
"[14:34:40.246] [INF] kein Token hinterlegt"
]);
}
[Fact]
public void Bei_gleichem_Zeitstempel_bleibt_die_bisherige_Ordnung()
{
// Stabil sortiert: Zwei Zeilen derselben Millisekunde dürfen nicht die Plätze
// tauschen, sonst zerfällt ein zusammenhängender Ablauf innerhalb eines Moduls.
//
// Über Moduldateien hinweg entscheidet dann der Modulname — "Alpha" vor "Beta".
// Directory.GetFiles liefert unter Linux die Verzeichnisreihenfolge; ohne die
// Sortierung in ReadNew stünde hier von Lauf zu Lauf etwas anderes.
Write("Alpha", "[10:00:00.000] [INF] alpha eins", "[10:00:00.000] [INF] alpha zwei");
Write("Beta", "[10:00:00.000] [INF] beta eins");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.000] [INF] alpha eins",
"[10:00:00.000] [INF] alpha zwei",
"[10:00:00.000] [INF] beta eins"
]);
}
[Fact]
public void Ein_Stacktrace_bleibt_bei_seiner_Meldung()
{
// Eine Ausnahme hängt ihre Zeilen ohne eigenen Zeitstempel unter die Meldung.
// Ohne Sonderbehandlung wären sie ohne Schlüssel nach ganz vorn gerutscht.
Write("Alpha", "[10:00:00.500] [INF] danach");
Write("Engine",
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
"System.InvalidOperationException: kaputt",
" bei ClawdDotNet.Engine.Run()");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
"System.InvalidOperationException: kaputt",
" bei ClawdDotNet.Engine.Run()",
"[10:00:00.500] [INF] danach"
]);
}
[Fact]
public void Ein_Stacktrace_bleibt_auch_ueber_die_Taktgrenze_bei_seiner_Meldung()
{
// Der Takt kann mitten in einem Eintrag enden: Die Meldung ist gelesen, die
// Fortsetzung kommt erst beim nächsten Aufruf. Der Zeitstempel muss dann aus
// dem vorigen Takt weitergetragen werden.
var tail = Tail();
Write("Engine", "[10:00:00.100] [ERR] Aufruf fehlgeschlagen");
tail.ReadNew();
Write("Alpha", "[10:00:00.500] [INF] danach");
Write("Engine", " bei ClawdDotNet.Engine.Run()");
var lines = tail.ReadNew();
lines.Select(l => l.Text).ShouldBe([
" bei ClawdDotNet.Engine.Run()",
"[10:00:00.500] [INF] danach"
]);
}
[Fact]
public void Eine_Uhrzeit_mitten_in_der_Meldung_ist_kein_Sortierschluessel()
{
// Gesucht wird der Zeitstempel am Zeilenanfang, nicht irgendeine Uhrzeit im Text.
Write("Alpha", "[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]");
Write("Beta", "[10:00:00.200] [INF] danach");
var lines = Tail().ReadNew();
lines.Select(l => l.Text).ShouldBe([
"[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]",
"[10:00:00.200] [INF] danach"
]);
}
// ═══════════════════════════════════════════════════════════
// Was die Sortierung nicht kaputtmachen darf
// ═══════════════════════════════════════════════════════════
[Fact]
public void Nur_das_Neue_kommt_zurueck()
{
var tail = Tail();
Write("Alpha", "[10:00:00.100] [INF] erste");
tail.ReadNew().Count.ShouldBe(1);
Write("Alpha", "[10:00:00.200] [INF] zweite");
tail.ReadNew().Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] zweite"]);
}
[Fact]
public void Der_Modulfilter_laesst_nur_sein_Modul_durch()
{
Write("Alpha", "[10:00:00.100] [INF] alpha");
Write("Beta", "[10:00:00.200] [INF] beta");
var lines = Tail().ReadNew(module: "Beta");
lines.Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] beta"]);
}
[Fact]
public void Eine_weggefilterte_Zeile_traegt_ihren_Zeitstempel_trotzdem_weiter()
{
// Der Stufenfilter darf die Zuordnung der Fortsetzungszeilen nicht verlieren:
// Die Meldung fällt weg, ihr Stacktrace nicht — er hat keine eigene Stufe und
// wird bewusst durchgelassen. Er muss dann an ihrer Stelle stehen.
Write("Alpha", "[10:00:00.500] [ERR] spaeter Fehler");
Write("Engine",
"[10:00:00.100] [INF] harmlos",
" Fortsetzung ohne Stufe");
var lines = Tail().ReadNew(level: LogLevelFilter.Error);
lines.Select(l => l.Text).ShouldBe([
" Fortsetzung ohne Stufe",
"[10:00:00.500] [ERR] spaeter Fehler"
]);
}
[Fact]
public void Ohne_Tagesverzeichnis_kommt_eine_leere_Liste()
{
Directory.Delete(_today, recursive: true);
Tail().ReadNew().ShouldBeEmpty();
}
}
@@ -89,7 +89,7 @@ public sealed class BudgetGuardTests : IDisposable
status.IsAllowed.ShouldBeFalse();
status.Limit.ShouldBe(BudgetLimit.AgentCost);
status.Reason.ShouldContain("5");
status.Reason.ShouldNotBeNull().ShouldContain("5");
}
// ═══════════════════════════════════════════════════════════
@@ -120,7 +120,7 @@ public sealed class BudgetGuardTests : IDisposable
var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default);
status.IsAllowed.ShouldBeFalse();
status.Reason.ShouldContain("keine Preise");
status.Reason.ShouldNotBeNull().ShouldContain("keine Preise");
}
// ═══════════════════════════════════════════════════════════
@@ -38,9 +38,9 @@ public sealed class ErrorReporterTests
sent.ShouldBeTrue();
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/errors/v1/report");
handler.LastBody!.ShouldContain("\"exception\":\"System.InvalidOperationException\"");
handler.LastBody.ShouldContain("\"message\":\"Kaputt\"");
handler.LastBody.ShouldContain("\"level\":\"fatal\"");
handler.LastBody.ShouldContain("\"project_slug\":\"clawddotnet\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"message\":\"Kaputt\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"level\":\"fatal\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"project_slug\":\"clawddotnet\"");
}
/// <summary>
@@ -101,7 +101,7 @@ public sealed class InstanceHealthProviderTests
var health = await provider.GetAsync(default);
health.Message.ShouldContain("Produktion");
health.Message.ShouldNotBeNull().ShouldContain("Produktion");
health.Metrics.Keys.ShouldNotContain("instanceName");
}
}
@@ -23,7 +23,7 @@ public sealed class TokenProvisionerTests
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/tokens/v1/provision");
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer master");
handler.LastBody!.ShouldContain("\"instance_id\":\"inst-1\"");
handler.LastBody.ShouldContain("watchdog:ping");
handler.LastBody.ShouldNotBeNull().ShouldContain("watchdog:ping");
token.Token.ShouldBe("dc_sub_abc");
token.TokenId.ShouldBe("tok_s_1");
@@ -30,10 +30,10 @@ public sealed class WatchdogClientTests
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer tok");
handler.LastBody.ShouldNotBeNull();
handler.LastBody.ShouldContain("\"source\":\"clawddotnet\"");
handler.LastBody.ShouldContain("\"instance\":\"inst-1\"");
handler.LastBody.ShouldContain("\"type\":\"heartbeat\"");
handler.LastBody.ShouldContain("\"interval\":60");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"source\":\"clawddotnet\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"instance\":\"inst-1\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"type\":\"heartbeat\"");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"interval\":60");
}
/// <summary>
@@ -84,7 +84,7 @@ public sealed class WatchdogClientTests
60, default);
handler.LastBody!.ShouldContain("\"agentCount\":3");
handler.LastBody.ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}");
handler.LastBody.ShouldNotBeNull().ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}");
}
/// <summary>Leere Angaben bleiben weg, statt als leeres Objekt zu erscheinen.</summary>
@@ -96,7 +96,7 @@ public sealed class WatchdogClientTests
await Client(handler).SendHeartbeatAsync(Health(), 60, default);
handler.LastBody!.ShouldNotContain("\"checks\"");
handler.LastBody.ShouldNotContain("\"metrics\"");
handler.LastBody.ShouldNotBeNull().ShouldNotContain("\"metrics\"");
}
[Fact]
@@ -64,7 +64,7 @@ public sealed class WatchdogHeartbeatServiceTests
var last = client.Heartbeats[^1];
last.Status.ShouldBe(WatchdogStatus.Maintenance);
last.Message.ShouldContain("0.1.1");
last.Message.ShouldNotBeNull().ShouldContain("0.1.1");
client.Events[^1].Kind.ShouldBe(WatchdogEventKind.MaintenanceStart);
}
@@ -37,7 +37,7 @@ public sealed class ContextCompactorPropertyTests
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung des Verlaufs.");
var compactor = new ContextCompactor(client, TestLogging.Factory);
compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default)
compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default)
.GetAwaiter().GetResult();
var valid = ContextInvariants.IsValid(messages);
@@ -55,7 +55,7 @@ public sealed class ContextCompactorPropertyTests
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung.");
var compactor = new ContextCompactor(client, TestLogging.Factory);
compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default)
compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default)
.GetAwaiter().GetResult();
var systemCount = messages.Count(m => m.Role == "system");
@@ -59,7 +59,7 @@ public sealed class ContextCompactorTests
ContextInvariants.AssertValid(messages); // Vorbedingung: Ausgangslage ist gültig
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
}
@@ -77,7 +77,7 @@ public sealed class ContextCompactorTests
.ToolCycle(count: 3, resultLength: 300)
.Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
@@ -107,7 +107,7 @@ public sealed class ContextCompactorTests
var (compactor, _) = CreateCompactor();
var messages = Conversation.Start(systemPrompt).Repeat(6).Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
messages[0].Role.ShouldBe("system");
messages[0].Content.ShouldBe(systemPrompt);
@@ -120,7 +120,7 @@ public sealed class ContextCompactorTests
var (compactor, _) = CreateCompactor();
var messages = Conversation.Start(systemPrompt: null).Repeat(6).Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
messages.ShouldNotContain(m => m.Role == "system");
}
@@ -137,7 +137,7 @@ public sealed class ContextCompactorTests
var messages = Conversation.Start().Repeat(6).Build();
var before = messages.Count;
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
messages.Count.ShouldBe(before, "bei fehlgeschlagener Zusammenfassung darf nichts verloren gehen");
@@ -151,7 +151,7 @@ public sealed class ContextCompactorTests
var messages = Conversation.Start().Repeat(6).Build();
var before = messages.Count;
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
messages.Count.ShouldBe(before);
@@ -169,7 +169,7 @@ public sealed class ContextCompactorTests
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 8_000)
.Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
ContextInvariants.AssertValid(messages);
messages.Where(m => m.Role == "tool")
@@ -185,7 +185,7 @@ public sealed class ContextCompactorTests
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 100)
.Build();
await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default);
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);
@@ -217,7 +217,7 @@ public sealed class ContextCompactorTests
var (compactor, client) = CreateCompactor();
var messages = Conversation.Start().Repeat(2).Build();
var compacted = await compactor.CompactIfNeededAsync(messages, 10, NeverCompact, Model, default);
var compacted = await compactor.CompactIfNeededAsync(messages, NeverCompact, Model, default);
compacted.ShouldBeFalse();
client.CallCount.ShouldBe(0);
@@ -230,7 +230,7 @@ public sealed class ContextCompactorTests
var messages = Conversation.Start().Repeat(15).Build();
var before = messages.Count;
var compacted = await compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default);
var compacted = await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
compacted.ShouldBeTrue();
messages.Count.ShouldBeLessThan(before);
@@ -0,0 +1,307 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// Test A13 der Teststrategie — Regressionstests für B8 (#206).
///
/// <para><c>send_message</c> ist ein synchroner Aufruf: A hält sein Gate, während es
/// auf B wartet. Rief B nun A, wartete B auf ein Gate, das A erst freigibt, wenn B
/// geantwortet hat. Beide standen, bis der Timeout des äußeren Laufs sie auflöste.</para>
///
/// <para>Die Tests prüfen die drei Zusagen aus <see cref="DelegationChain"/>: kein
/// Agent zweimal in einer Kette, höchstens <c>MaxDelegationDepth</c> Weitergaben, und
/// die Kette gehört zum Aufruf statt zum Agenten.</para>
///
/// <para>Ein Zeitlimit steht bewusst an jedem Test, der verklemmen könnte: Ohne die
/// Prüfung liefe er nicht rot, sondern gar nicht mehr weiter.</para>
/// </summary>
public sealed class DelegationDepthTests
{
private static readonly TimeSpan Frist = TimeSpan.FromSeconds(20);
/// <summary>Wartet auf den Lauf, scheitert aber statt zu hängen.</summary>
private static async Task<AgentRunResult> MitFrist(Task<AgentRunResult> lauf)
{
var fertig = await Task.WhenAny(lauf, Task.Delay(Frist));
fertig.ShouldBe(lauf, "Der Lauf ist nicht zurückgekommen — das ist die Verklemmung aus B8.");
return await lauf;
}
// ═══════════════════════════════════════════════════════════
// A13 — der gemeldete Fall
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task A_nach_B_nach_A_wird_abgebrochen_statt_zu_verklemmen()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a" // zurück zu A — der Zyklus
});
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
var result = await MitFrist(
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
result.Status.ShouldBe(AgentRunStatus.Completed);
// Der Rückweg B → A ist abgelehnt worden, der Hinweg A → B nicht.
var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" });
rueckweg.Delivered.ShouldBeFalse();
rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus");
rueckweg.Error.ShouldNotBeNull().ShouldContain("agent-a -> agent-b -> agent-a");
tool.Handoffs.Single(h => h is { From: "agent-a", To: "agent-b" }).Delivered.ShouldBeTrue();
}
[Fact]
public async Task Die_Ablehnung_kommt_als_Ergebnis_zurueck_nicht_als_Wurf()
{
// Der abgewiesene Agent soll weiterarbeiten können — er bekommt einen Grund,
// keinen Abbruch. Sonst führte die Zyklusprüfung nur zu einem anderen Ausfall.
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a"
});
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
var result = await MitFrist(
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
result.Status.ShouldBe(AgentRunStatus.Completed);
result.FinalMessage.ShouldBe("Fertig");
}
// ═══════════════════════════════════════════════════════════
// Die Staffel, die keinen Zyklus bildet
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Eine_Staffel_endet_an_der_Tiefengrenze()
{
// A → B → C → D wäre die Tiefe 3. Mit MaxDelegationDepth = 2 ist bei C Schluss.
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-c",
["agent-c"] = "agent-d"
});
var fixture = new EngineFixture().WithTool(tool);
fixture.Engine.MaxDelegationDepth = 2;
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.AddAgent("agent-c", "TestTool");
fixture.AddAgent("agent-d", "TestTool");
fixture.Client.RespondsContextually();
await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
tool.Handoffs.Single(h => h.To == "agent-b").Delivered.ShouldBeTrue();
tool.Handoffs.Single(h => h.To == "agent-c").Delivered.ShouldBeTrue();
var zuTief = tool.Handoffs.Single(h => h.To == "agent-d");
zuTief.Delivered.ShouldBeFalse();
zuTief.Error.ShouldNotBeNull().ShouldContain("Delegationstiefe");
}
[Fact]
public async Task Innerhalb_der_Grenze_geht_die_Staffel_durch()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-c"
});
var fixture = new EngineFixture().WithTool(tool);
fixture.Engine.MaxDelegationDepth = 2;
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.AddAgent("agent-c", "TestTool");
fixture.Client.RespondsContextually();
var result = await MitFrist(
fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
result.Status.ShouldBe(AgentRunStatus.Completed);
tool.Handoffs.ShouldAllBe(h => h.Delivered);
}
// ═══════════════════════════════════════════════════════════
// Die Kette gehört zum Aufruf, nicht zum Agenten
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Zwei_Laeufe_nacheinander_erben_die_Kette_des_anderen_nicht()
{
// Sonst könnte ein Agent nach einer tiefen Kette dauerhaft „verbraucht" sein.
var tool = new DelegatingTool(new Dictionary<string, string> { ["agent-a"] = "agent-b" });
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
await MitFrist(fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default));
await MitFrist(fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default));
tool.Handoffs.Count(h => h.To == "agent-b").ShouldBe(2);
tool.Handoffs.ShouldAllBe(h => h.Delivered);
}
// ═══════════════════════════════════════════════════════════
// spawn zählt mit
// ═══════════════════════════════════════════════════════════
[Fact]
public async Task Auch_ueber_spawn_laesst_sich_die_Kette_nicht_umgehen()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a"
}, spawn: true);
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default));
var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" });
rueckweg.Delivered.ShouldBeFalse();
rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus");
}
// ═══════════════════════════════════════════════════════════
// #461 — was die Kette grundsätzlich nicht sehen kann
// ═══════════════════════════════════════════════════════════
/// <summary>
/// Zwei unabhängige Ketten über Kreuz: Lauf 1 startet bei A und beauftragt B, Lauf 2
/// startet zeitgleich bei B und beauftragt A. Keine der beiden Ketten enthält den
/// Agenten der anderen — beide sind für sich zyklusfrei, und trotzdem hält Lauf 1
/// gate(A) und wartet auf gate(B), während Lauf 2 gate(B) hält und auf gate(A) wartet.
///
/// <para>Das kann die Delegationskette prinzipiell nicht erkennen; nur eine Frist
/// löst es auf. Ein delegierter Lauf wartet deshalb höchstens
/// <c>DelegationGateTimeout</c> auf ein fremdes Gate und sagt danach ab.</para>
///
/// <para>Die Pause vor der Weitergabe ist der Kern des Aufbaus: Der Fake-Client
/// antwortet synchron, ohne sie liefe der erste Chat vollständig durch, bevor der
/// zweite beginnt — die Ketten könnten sich gar nicht überschneiden, und der Test
/// wäre grün, ohne etwas zu zeigen.</para>
/// </summary>
[Fact]
public async Task Zwei_unabhaengige_Ketten_ueber_Kreuz_verklemmen_sich_nicht()
{
var tool = new DelegatingTool(new Dictionary<string, string>
{
["agent-a"] = "agent-b",
["agent-b"] = "agent-a"
}, delayBeforeHandoff: TimeSpan.FromMilliseconds(300));
var fixture = new EngineFixture().WithTool(tool);
fixture.Engine.DelegationGateTimeout = TimeSpan.FromSeconds(1);
var a = fixture.AddAgent("agent-a", "TestTool");
var b = fixture.AddAgent("agent-b", "TestTool");
fixture.Client.RespondsContextually();
var laufA = fixture.Engine.ChatAsync(a, "A fragt B", "test-instance", default);
var laufB = fixture.Engine.ChatAsync(b, "B fragt A", "test-instance", default);
var beide = Task.WhenAll(laufA, laufB);
var fertig = await Task.WhenAny(beide, Task.Delay(Frist));
fertig.ShouldBe(beide, "Beide Laeufe warten auf das Gate des jeweils anderen (#461).");
// Beide äußeren Läufe kommen zu Ende. Mindestens eine Weitergabe ist an der
// Frist gescheitert — welche, entscheidet die Reihenfolge und ist nicht
// festgelegt; dass beide Läufe zurückkommen, schon.
(await laufA).Status.ShouldBe(AgentRunStatus.Completed);
(await laufB).Status.ShouldBe(AgentRunStatus.Completed);
tool.Handoffs.ShouldContain(h => !h.Delivered && h.Error!.Contains("belegt"));
}
[Fact]
public async Task Ein_Chat_vom_Menschen_wartet_weiter_so_lange_es_dauert()
{
// Die Gegenprobe zur Frist: Sie gilt nur für delegierte Läufe. Ein zweiter
// Chat desselben Agenten reiht sich ein und wird bedient, auch wenn der erste
// länger braucht als die Delegationsfrist.
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(600)));
fixture.Engine.DelegationGateTimeout = TimeSpan.FromMilliseconds(50);
var a = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsContextually();
var erster = fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default);
var zweiter = fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default);
(await MitFrist(erster)).Status.ShouldBe(AgentRunStatus.Completed);
(await MitFrist(zweiter)).Status.ShouldBe(AgentRunStatus.Completed);
}
// ═══════════════════════════════════════════════════════════
// Die Kette selbst
// ═══════════════════════════════════════════════════════════
[Fact]
public void Die_Kette_zaehlt_Weitergaben_nicht_Agenten()
{
var kette = DelegationChain.Root("a");
kette.Depth.ShouldBe(0);
kette.Append("b").Depth.ShouldBe(1);
kette.Append("b").Append("c").Depth.ShouldBe(2);
}
[Fact]
public void Append_veraendert_die_Kette_des_Aufrufers_nicht()
{
// Unveränderlich: Zwei Weitergaben aus demselben Lauf dürfen sich nicht
// gegenseitig sehen — sonst zählte die zweite die erste als Vorgänger.
var kette = DelegationChain.Root("a");
var nachB = kette.Append("b");
var nachC = kette.Append("c");
kette.Agents.ShouldBe(["a"]);
nachB.Agents.ShouldBe(["a", "b"]);
nachC.Agents.ShouldBe(["a", "c"]);
nachB.Contains("c").ShouldBeFalse();
}
[Fact]
public void Die_Kette_liest_sich_als_Weg()
{
DelegationChain.Root("a").Append("b").Append("c").ToString()
.ShouldBe("a -> b -> c");
}
}
@@ -0,0 +1,170 @@
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);
}
}
@@ -100,14 +100,14 @@ public sealed class TokenAccountingTests
var guard = new LoopGuardConfig
{
MaxContextTokens = 1_000,
MaxContextTokens = 100,
CompactionThreshold = 0.5,
SummaryModel = "google/gemini-2.5-flash"
};
var messages = Conversation.Start().Repeat(10).Build();
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default);
await compactor.CompactIfNeededAsync(messages, guard, "anthropic/claude-opus-4", default);
client.ReceivedRequests.ShouldNotBeEmpty();
client.ReceivedRequests[0].Model.ShouldBe("google/gemini-2.5-flash",
@@ -122,14 +122,14 @@ public sealed class TokenAccountingTests
var guard = new LoopGuardConfig
{
MaxContextTokens = 1_000,
MaxContextTokens = 100,
CompactionThreshold = 0.5,
SummaryModel = ""
};
var messages = Conversation.Start().Repeat(10).Build();
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default);
await compactor.CompactIfNeededAsync(messages, guard, "anthropic/claude-opus-4", default);
client.ReceivedRequests[0].Model.ShouldBe("anthropic/claude-opus-4");
}
@@ -146,14 +146,14 @@ public sealed class TokenAccountingTests
var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung.");
var compactor = new ContextCompactor(client, TestLogging.Factory);
var guard = new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 };
var guard = new LoopGuardConfig { MaxContextTokens = 100, CompactionThreshold = 0.5 };
var messages = Conversation.Start()
.Repeat(8)
.User("EINZIGARTIGE-LETZTE-NACHRICHT")
.Build();
await compactor.CompactIfNeededAsync(messages, 50_000, guard, "test/model", default);
await compactor.CompactIfNeededAsync(messages, guard, "test/model", default);
var summaryPrompt = client.ReceivedRequests[0].Messages.Last().Content!;
summaryPrompt.ShouldNotContain("EINZIGARTIGE-LETZTE-NACHRICHT");
@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using System.Text.Json;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Ein Tool, das einen anderen Agenten beauftragt — der Nachbau dessen, was
/// <c>AgentComm.send_message</c> und <c>AgentSpawn.spawn_agent</c> tun.
///
/// <para>Der Nachbau ist nötig, weil beide Werkzeuge in eigenen Projekten liegen, die
/// <c>ClawdDotNet.Core.Tests</c> nicht referenziert. Entscheidend ist ohnehin nur die
/// eine Zeile, in der sie sich hier gleichen: Die Delegationskette des eigenen Laufs
/// wird weitergereicht. Ein Tool, das sie unterschlägt, macht aus einem Zyklus eine
/// neue Beauftragung — genau der Fall, den B8 beschreibt.</para>
///
/// <para>Wen ein Agent beauftragt, steht in <paramref name="routes"/>: agentId → Ziel.
/// Wer dort nicht vorkommt, delegiert nicht und antwortet schlicht.</para>
/// </summary>
internal sealed class DelegatingTool : IAgentTool
{
private readonly IReadOnlyDictionary<string, string> _routes;
private readonly bool _spawn;
private readonly TimeSpan _delayBeforeHandoff;
public string Name => "TestTool";
public string Description => "Beauftragt einen anderen Agenten.";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{ "type": "object", "properties": { "message": { "type": "string" } } }
""").RootElement.Clone();
/// <summary>Was zurückkam — Aufrufer, Ziel und das Ergebnis der Weitergabe.</summary>
public ConcurrentBag<(string From, string To, bool Delivered, string? Error)> Handoffs { get; } = new();
/// <param name="delayBeforeHandoff">
/// Wartezeit, bevor weitergegeben wird. Der Fake-Client antwortet synchron; ohne
/// diese Pause läuft ein Chat vollständig durch, bevor der nächste überhaupt
/// beginnt, und zwei Ketten können sich nicht überschneiden.
/// </param>
public DelegatingTool(
IReadOnlyDictionary<string, string> routes,
bool spawn = false,
TimeSpan delayBeforeHandoff = default)
{
_routes = routes;
_spawn = spawn;
_delayBeforeHandoff = delayBeforeHandoff;
}
public async Task<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (!_routes.TryGetValue(context.AgentId, out var target))
return ToolResult.Ok("nichts zu delegieren");
if (_delayBeforeHandoff > TimeSpan.Zero)
await Task.Delay(_delayBeforeHandoff, ct);
if (_spawn)
{
var spawned = await context.MessageRouter!.SpawnAgentAsync(
context.AgentId, target, "Auftrag", context.InstanceId, context.Delegation, ct);
Handoffs.Add((context.AgentId, target, spawned.Started, spawned.Error));
return spawned.Started
? ToolResult.Ok(spawned.FinalMessage ?? "")
: ToolResult.Ok(JsonSerializer.Serialize(new { spawned = false, error = spawned.Error }));
}
var result = await context.MessageRouter!.SendMessageAsync(
context.AgentId, target, "Frage", context.InstanceId, context.Delegation, ct);
Handoffs.Add((context.AgentId, target, result.Delivered, result.Error));
// Wie das echte Tool: Eine Ablehnung ist kein Wurf, sondern ein Ergebnis, das
// der Agent lesen und auf das er anders reagieren kann.
return result.Delivered
? ToolResult.Ok(result.Response ?? "")
: ToolResult.Ok(JsonSerializer.Serialize(new { delivered = false, error = result.Error }));
}
}
@@ -50,7 +50,7 @@ public sealed class SecretProtectorTests : IDisposable
var protectedValue = SecretProtector.Protect(secret);
protectedValue.ShouldNotContain(secret);
protectedValue.ShouldNotBeNull().ShouldNotContain(secret);
protectedValue.ShouldNotBe(secret);
}
@@ -190,7 +190,14 @@ public sealed class SecretProtectorTests : IDisposable
SecretProtector.Unprotect(second).ShouldBe("noch-geheimer");
}
/// <summary>
/// <c>[LinuxFact]</c> hält den Test unter Windows an, der Plattform-Analysator sieht
/// das aber nicht — er kennt nur Attribute wie <c>SupportedOSPlatform</c>. Ohne die
/// Angabe blieb CA1416 als einzige Warnung im Build stehen, und eine einzelne
/// Warnung, die immer da ist, liest bald niemand mehr.
/// </summary>
[LinuxFact]
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
public void Die_Schluesseldatei_ist_nur_fuer_den_Besitzer_lesbar()
{
SecretProtector.Protect("geheim");
@@ -207,7 +214,7 @@ public sealed class SecretProtectorTests : IDisposable
var ex = Should.Throw<SecretProtectionException>(
() => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4])));
ex.Message.ShouldContain("neu eingetragen");
ex.Message.ShouldNotBeNull().ShouldContain("neu eingetragen");
}
}
@@ -270,8 +277,8 @@ public sealed class ConfigSecretsTests : IDisposable
ConfigSecrets.Protect(agent);
agent.Tools["Database"]["connectionString"]!.ToString().ShouldNotContain("geheim");
agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("mail-passwort");
agent.Tools["Database"]["connectionString"]!.ToString().ShouldNotBeNull().ShouldNotContain("geheim");
agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().ShouldNotContain("mail-passwort");
}
[Fact]
@@ -311,7 +318,7 @@ public sealed class ConfigSecretsTests : IDisposable
ConfigSecrets.Protect(agent);
agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("geheim123");
agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().ShouldNotContain("geheim123");
ConfigSecrets.Unprotect(agent);
agent.Tools["Mail"]["password"].ShouldBe("geheim123");
}
@@ -243,4 +243,94 @@ public sealed class TaskScheduleTests
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
.ShouldBe("2026-07-31T09:00:00.0000000Z");
}
// ═══════════════════════════════════════════════════════════
// Zeitumstellung — R4 und R5 der Teststrategie
//
// Europe/Berlin 2026: Am 29. März springt die Uhr um 02:00 auf 03:00, am
// 25. Oktober um 03:00 zurück auf 02:00. Ein täglicher Termin um 02:00 trifft
// also einmal im Jahr eine Uhrzeit, die es nicht gibt, und einmal eine, die es
// doppelt gibt.
// ═══════════════════════════════════════════════════════════
private static TaskItem TaeglichUmZwei(DateTime angelegt) => new()
{
Id = "t-dst",
CreatedAt = angelegt,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 2 * * *", TimeZone = "Europe/Berlin" }
};
/// <summary>R5 — der Fall, um den es in #235 geht.</summary>
[Fact]
public void Zeitumstellung_Maerz_wird_nicht_uebersprungen()
{
var task = TaeglichUmZwei(Utc(2026, 3, 28, 0, 0));
// "02:00" existiert am 29. März nicht. Der Termin fällt trotzdem an, und zwar
// auf dem ersten Zeitpunkt danach: 03:00 MESZ = 01:00 UTC.
// Vorher kam hier null — die Aufgabe fiel an diesem Tag ersatzlos aus.
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 2, 0))
.ShouldBe("2026-03-29T01:00:00.0000000Z");
}
[Fact]
public void Der_verschobene_Termin_feuert_trotzdem_nur_einmal()
{
var task = TaeglichUmZwei(Utc(2026, 3, 28, 0, 0));
var ersteFaelligkeit = TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 2, 0));
ersteFaelligkeit.ShouldNotBeNull();
TaskSchedule.DueOccurrence(task with { LastOccurrence = ersteFaelligkeit }, Utc(2026, 3, 29, 2, 30))
.ShouldBeNull();
}
/// <summary>R4 — die andere Richtung.</summary>
[Fact]
public void Zeitumstellung_Oktober_feuert_genau_einmal()
{
var task = TaeglichUmZwei(Utc(2026, 10, 24, 0, 0));
// 02:00 kommt am 25. Oktober zweimal vor: in Sommerzeit (00:00 UTC) und in
// Normalzeit (01:00 UTC). Beide Durchläufe ergeben denselben Occurrence-Key —
// der Marker fängt den zweiten ab.
var ersterDurchlauf = TaskSchedule.DueOccurrence(task, Utc(2026, 10, 25, 0, 30));
ersterDurchlauf.ShouldNotBeNull();
var zweiterDurchlauf = TaskSchedule.DueOccurrence(
task with { LastOccurrence = ersterDurchlauf }, Utc(2026, 10, 25, 1, 30));
zweiterDurchlauf.ShouldBeNull();
}
[Fact]
public void An_gewoehnlichen_Tagen_bleibt_es_bei_zwei_Uhr_Ortszeit()
{
// Die Gegenprobe: Die Verschiebung darf nur in der Lücke greifen.
var task = TaeglichUmZwei(Utc(2026, 3, 21, 0, 0));
// 02:00 MEZ = 01:00 UTC.
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 22, 2, 0))
.ShouldBe("2026-03-22T01:00:00.0000000Z");
}
[Fact]
public void Auch_ein_einmaliger_Termin_in_der_Luecke_faellt_nicht_aus()
{
// Derselbe Umgang auf dem at-Pfad — dort galt dieselbe Regel und dieselbe Lücke.
var task = new TaskItem
{
Id = "t-1",
When = new TaskWhen
{
Kind = TaskWhenKind.At,
Value = "2026-03-29T02:30:00",
TimeZone = "Europe/Berlin"
}
};
// 02:30 gibt es nicht; es wird 03:00 MESZ = 01:00 UTC.
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 0, 59)).ShouldBeNull();
TaskSchedule.DueOccurrence(task, Utc(2026, 3, 29, 1, 0))
.ShouldBe("2026-03-29T01:00:00.0000000Z");
}
}
@@ -53,7 +53,7 @@ public sealed class SqlGuardTests
var result = Inspect(sql);
result.IsValid.ShouldBeFalse();
result.Error.ShouldContain("Mehrere Statements");
result.Error.ShouldNotBeNull().ShouldContain("Mehrere Statements");
}
[Theory]
@@ -66,7 +66,7 @@ public sealed class SqlGuardTests
var result = Inspect(sql);
result.IsValid.ShouldBeFalse();
result.Error.ShouldContain("users");
result.Error.ShouldNotBeNull().ShouldContain("users");
}
[Fact]
@@ -144,8 +144,8 @@ public sealed class SqlGuardTests
{
var result = Inspect("SELECT * FROM geheim");
result.Error.ShouldContain("geheim");
result.Error.ShouldContain("prices");
result.Error.ShouldNotBeNull().ShouldContain("geheim");
result.Error.ShouldNotBeNull().ShouldContain("prices");
}
[Fact]
@@ -54,8 +54,14 @@ public sealed class WorkspacePathTests : IDisposable
/// Die Windows-Formen müssen auch unter Linux abgewiesen werden — dort greift
/// aber eine andere Regel: <c>Path.IsPathRooted(@"C:\temp\x")</c> ist unter Linux
/// <c>false</c>, weil das schlicht ein Dateiname mit Doppelpunkt ist. Gefangen
/// werden die Fälle dann von der Doppelpunkt- bzw. der <c>\\</c>-Prüfung.
/// Das Ergebnis ist auf beiden Plattformen dasselbe, der Weg dorthin nicht.
/// werden diese Fälle von der Zeichenregel: Doppelpunkt und Backslash sind im
/// Pfad unzulässig, getrennt wird mit <c>/</c>. Das Ergebnis ist auf beiden
/// Plattformen dasselbe, der Weg dorthin nicht.
///
/// Dieselbe Regel fängt unter Linux auch <c>@"..\..\evil.txt"</c> aus dem Test
/// darüber. Ein Ausbruch war das dort nie — der Backslash trennt unter Linux
/// nichts, die Datei wäre im Workspace gelandet —, aber der Test erwartete eine
/// Abweisung und bekam keine.
/// </summary>
[Theory]
[InlineData(@"C:\Windows\System32\config\SAM")]