Die Avalonia-Portierung ist abgeschlossen, damit ist die in ClawdDotNet.slnx angekuendigte Aufgabe "WinForms-Oberflaeche entfernen" faellig. Der Stand davor liegt unter dem Tag vor-fruehjahrsputz-2026-08. Entfernt (56 Dateien, seit dem Herausloesen der Anwendungsschicht nicht mehr Teil des Builds): ClawdDotNet.csproj, Program.cs, sieben frm_*-Formulare, UI/, Models/, EmbeddedUI/, Properties/, Resources/, Services/, das alte Anwendungssymbol und Deploy-Build.ps1 (ersetzt durch deploy/publish.py). Dazu configs/*.json - Beispielkonfigurationen aus der Zeit vor dem Instanzverzeichnis, auf die nur noch die alten Prompts verwiesen. Die vier Entwicklungs-Prompts der Anfangszeit ziehen nach docs/archiv/ um, mit README, das ihren Stand einordnet. Eine Regel darin gilt weiter - die Pflichtfelder fetchedAt/dataAsOf/source der Internet-Tools -, deshalb Archiv statt Loeschen; der WebSearch-Plan verweist auf den neuen Pfad. Toter Code - PlaceholderPageViewModel samt Ansicht: Es gibt keinen Platzhalter-Bereich mehr, seit alle neun Seiten portiert sind. - Snappier als direkter Paketverweis: MongoDB.Driver loest es ohnehin auf dieselbe Fassung auf, der Verweis hob nichts an. Zwei Fehler, die dabei sichtbar wurden - Die taegliche Sicherung lief ins Leere. Die Oberflaeche bot sie an und schrieb Uhrzeit, Zielordner und Anzahl in die Einstellungen, aber der BackupScheduler wurde nirgends erzeugt. Jetzt am AppHost verdrahtet und in den geordneten Abbau aufgenommen. - SettingsPageViewModel hielt die vier Sicherungs-Einstellungen doppelt. Aus der Ansicht waren sie laengst verschwunden, gelesen und beim Speichern zurueckgeschrieben wurden sie weiter: Wer die Uhrzeit auf der Sicherungs-Seite aenderte und danach die Einstellungen speicherte, bekam den alten Wert zurueck. Pakete: keine bekannten Sicherheitsluecken mehr - SQLitePCLRaw.bundle_e_sqlite3 auf 2.1.13 angehoben. Microsoft.Data.Sqlite bringt 2.1.11 mit, darin steckt GHSA-2m69-gcr7-jv3q (NU1903, hoch). - SharpCompress bleibt als direkter Verweis stehen. Beim Aufraeumen erst als ungenutzt entfernt - dabei kam die von MongoDB.Driver gezogene Fassung 0.30.1 mit GHSA-6c8g-7p36-r338 zurueck. Der Verweis ist eine Anhebung, kein Ballast; das steht jetzt als Kommentar dabei. Dokumentation - Roadmap mit Statusblock: A1 und A3 erledigt, A2 nur zur Haelfte - Gate, Policy und Dienst greifen, aber keine Ansicht ruft ApproveAsync auf, ein gestagter Aufruf liegt unbeantwortet. Das ist jetzt Punkt 1 der Reihung. Rocket.Chat steht und kollidiert mit A5 (Matrix) - Entscheidung faellig. - Avalonia-Portierungsleitfaden -> Oberflaechen-Leitfaden: kein Auftrag mehr, sondern Beschreibung des Stands. - Bestandsaufnahme und Linux-Analyse als datierte Befunde gekennzeichnet; der teure Teil der Linux-Analyse (8.900 Zeilen WinForms) ist hinfaellig. - Verweise auf frm_*, WebView2 und ClawdDotNet.csproj in den lebenden Dokumenten richtiggestellt. Build fehlerfrei, 585 Tests gruen (6 uebersprungen). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21 KiB
ClawdDotNet — Teststrategie
Ergänzung zur Bestandsaufnahme. Ziel: Fehlerklassen wie B1 (Compaction zerstört tool_call-Paarung) und B2 (Race Condition) strukturell unmöglich machen, statt sie im Betrieb zu entdecken.
Leitgedanke: Nicht Coverage maximieren, sondern die teuren Fehler abfangen. In diesem Projekt sind das drei Kategorien:
- Kontext-Invarianten — ungültige Nachrichtensequenzen kosten API-Fehler mitten im Lauf
- Sicherheitsgrenzen — Pfad-, SQL-, URL- und Empfänger-Prüfungen
- Nebenläufigkeit & Zeit — Scheduler, paralleler Chat-Zugriff, Cron
UI-Layout, Designer-Code und Fremdbibliotheken werden bewusst nicht getestet.
1. Voraussetzung: Testbarkeit herstellen
Vier kleine Refactorings, ohne die die wichtigsten Tests nicht schreibbar sind.
R1 — IChatCompletionClient extrahieren ⚠️ blockiert alles Weitere
AgentEngine.cs:15 und
ContextCompactor.cs:10 hängen an der
konkreten, sealed-Klasse OpenRouterClient. Ohne Interface ist kein einziger Engine-Test möglich.
public interface IChatCompletionClient
{
Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct);
}
public sealed class OpenRouterClient : IChatCompletionClient, IDisposable { … }
Dazu ein FakeChatClient im Testprojekt, der eine vorprogrammierte Antwortfolge liefert und
alle empfangenen Requests mitschreibt:
internal sealed class FakeChatClient : IChatCompletionClient
{
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
public List<ChatRequest> ReceivedRequests { get; } = new();
public FakeChatClient RespondsWithText(string text) { … }
public FakeChatClient RespondsWithToolCall(string tool, string argsJson) { … }
public FakeChatClient Throws(Exception ex) { … }
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
{
// Tiefe Kopie ablegen — die Engine mutiert die Nachrichtenliste weiter!
ReceivedRequests.Add(DeepClone(request));
…
}
}
Die tiefe Kopie ist wesentlich:
AgentEnginereicht dieselbeList<ChatMessage>-Instanz weiter und verändert sie danach. Ohne Kopie prüfen die Tests den Endzustand statt des tatsächlich gesendeten Requests.
R2 — TimeProvider statt DateTime.Now
.NET 10 bringt TimeProvider mit; FakeTimeProvider steckt in
Microsoft.Extensions.TimeProvider.Testing. Betrifft
CronExpression.cs:53,
AgentScheduler.cs:75,
ToolJobScheduler.cs:111.
Damit werden Zeitumstellungs- und Langzeit-Tests deterministisch und laufen in Millisekunden statt in Echtzeit.
R3 — IProcessRunner für externe Prozesse
SocialMediaManagerTool.cs:442
startet yt-dlp/ffmpeg direkt. Für den Injection-Test (S2) muss die Argumentbildung ohne
Prozessstart prüfbar sein:
internal static string[] BuildYtDlpArgs(string url, …); // rein, testbar
public interface IProcessRunner { Task<ProcessResult> RunAsync(string exe, string[] args, CancellationToken ct); }
R4 — Dateisystem nicht abstrahieren
Bewusste Entscheidung: Die Path-Traversal-Tests sollen echte Windows-Pfadsemantik prüfen
(.., UNC, Alternate Data Streams, Trailing Dots). Eine IFileSystem-Abstraktion würde genau
die Fehlerklasse verstecken, um die es geht. Stattdessen ein Temp-Verzeichnis-Fixture:
internal sealed class TempWorkspace : IDisposable
{
public string Personal { get; }
public string Shared { get; }
public TempWorkspace() { /* unter Path.GetTempPath()/clawd-tests/{guid} anlegen */ }
public void Dispose() { /* rekursiv löschen, Fehler schlucken */ }
}
2. Projektstruktur & Stack
tests/
ClawdDotNet.Core.Tests/ ← Engine, Compaction, LoopGuard, Cron, Config, State
ClawdDotNet.Tools.Tests/ ← alle Tool-Projekte, v.a. Sicherheitsgrenzen
ClawdDotNet.Integration.Tests/ ← echte Endpunkte, standardmäßig übersprungen
| Paket | Zweck |
|---|---|
xunit.v3 |
Test-Framework |
Shouldly |
Assertions — lesbar, frei nutzbar. (FluentAssertions ist ab v8 für kommerzielle Nutzung kostenpflichtig; falls gewünscht ist AwesomeAssertions ein freier Fork von v7.) |
NSubstitute |
Mocks für ILogger, IStateStore, IAgentTool |
Microsoft.Extensions.TimeProvider.Testing |
FakeTimeProvider |
FsCheck.Xunit |
Property-Based Tests für die Kontext-Invarianten |
Verify.Xunit |
Snapshot-Tests des Request-JSON |
Die drei Projekte in ClawdDotNet.slnx unter einem Ordner /tests/ eintragen.
Nebenbefund: In
ClawdDotNet.slnxfehlen vier Tool-Projekte (AgentComm, AgentSpawn, AgentEditor, SocialMediaManager), obwohl sie insrc/ClawdDotNet.App/ClawdDotNet.App.csprojreferenziert sind. Sie sollten mit aufgenommen werden, sonst laufen sie in der IDE-Solution nicht mit.
3. Die Tests
Prio 1 — Regressionstests für die gefundenen Bugs
Vorgehen: erst den roten Test, dann den Fix. Diese Tests sind der eigentliche Beleg, dass die Bugs behoben sind — und sie bleiben als Wächter erhalten.
3.1 ContextCompactor — Kontext-Invarianten (deckt B1 ab)
Das Herzstück. Ich würde eine wiederverwendbare Prüffunktion bauen und sie überall einsetzen:
internal static class ContextInvariants
{
/// Wirft, wenn die Nachrichtenfolge von der API abgelehnt würde.
public static void AssertValid(IReadOnlyList<ChatMessage> messages)
{
// 1. Höchstens eine system-Nachricht, und zwar an Position 0
// 2. Jede tool-Nachricht hat eine unmittelbar vorausgehende Gruppe,
// deren assistant-Nachricht ein tool_call mit passender Id enthält
// 3. Jede assistant-Nachricht mit N tool_calls hat genau N tool-Antworten
// 4. Keine tool-Nachricht ohne ToolCallId
}
}
| # | Test | Erwartung |
|---|---|---|
| C1 | Compaction bei Tail-Grenze mitten in einer Tool-Sequenz | AssertValid besteht — schlägt heute fehl |
| C2 | Compaction bei assistant(2 tool_calls) → tool → tool an der Grenze |
Beide tool-Antworten bleiben zusammen mit ihrem Aufruf |
| C3 | System-Prompt vorhanden | Bleibt an Position 0 erhalten |
| C4 | Kein System-Prompt vorhanden | Kein leerer/erfundener System-Eintrag |
| C5 | LLM-Zusammenfassung wirft Exception | Nachrichtenliste bleibt unverändert und gültig (kein halb geleerter Zustand) |
| C6 | LLM liefert leeren String zurück | dito |
| C7 | PruneToolResults |
Kürzt nur außerhalb der letzten 6 Nachrichten |
| C8 | PruneToolResults bei Inhalt ≤ 2000 Zeichen |
Bleibt unverändert (kein Marker angehängt) |
| C9 | EstimateTokens |
Monoton: mehr Inhalt ⇒ nie weniger Tokens |
| C10 | Compaction unterhalb der Schwelle | Kein API-Call an den Fake (spart Geld!) |
Property-Based (FsCheck) — der wertvollste Test des ganzen Projekts:
[Property(MaxTest = 500)]
public Property Compaction_erhaelt_immer_eine_gueltige_Sequenz()
{
return Prop.ForAll(ValidConversationGenerator(), // 1..200 gültige Nachrichten
async conversation =>
{
await _compactor.CompactIfNeededAsync(conversation, 999_999, _guard, "m", default);
ContextInvariants.AssertValid(conversation);
});
}
Ein Generator für gültige Konversationen (System, dann abwechselnd User/Assistant, gelegentlich Assistant-mit-N-Tool-Calls gefolgt von genau N Tool-Antworten) findet Grenzfälle, die von Hand niemand schreibt — genau die Klasse, in die B1 fällt.
3.2 LoopGuard (deckt B3 ab)
| # | Test | Erwartung |
|---|---|---|
| L1 | MaxSteps erreicht |
Wirft bei Schritt N+1, nicht bei N |
| L2 | Konstanter 20k-Kontext über 10 Schritte | Bricht nicht ab — definiert die Zielsemantik nach dem Fix |
| L3 | Kostenbudget überschritten | Wirft mit klarer Meldung, welches Limit griff |
| L4 | 100 parallele RecordStep |
Zähler exakt 100 (Interlocked-Korrektheit) |
L2 ist der Test, der den heutigen Fehlentwurf festnagelt: Er schlägt mit den aktuellen Defaults
fehl und geht nach der Trennung von maxCumulativeTokens und maxContextTokens durch.
3.3 AgentEngine — Nebenläufigkeit (deckt B2 ab)
| # | Test | Erwartung |
|---|---|---|
| E1 | Zwei parallele ChatAsync für denselben Agenten |
Kontext danach AssertValid; schlägt heute fehl |
| E2 | 50 parallele ChatAsync, je 2 Tool-Schritte |
Keine Exception, Nachrichtenzahl deterministisch |
| E3 | AbortChat bei zwei laufenden Läufen |
Beide werden abgebrochen |
| E4 | Parallele Läufe für verschiedene Agenten | Laufen wirklich parallel (keine globale Serialisierung) |
E4 ist wichtig als Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten gegeneinander sperren.
3.4 CronExpression & Scheduler (deckt B6, B7 ab)
| # | Test | Erwartung |
|---|---|---|
| R1 | Tabelle: */30 * * * *, 0 3 * * 1-5, 0 0 29 2 *, 15,45 * * * * |
Korrekte nächste Zeitpunkte |
| R2 | Invariante über viele Ausdrücke | GetNextOccurrence(t) > t und Matches(ergebnis) |
| R3 | Ungültig: "* * *", "99 * * * *", "a b c d e", "*/0 * * * *" |
Saubere FormatException — heute wirft teils int.Parse/DivideByZero |
| R4 | Zeitumstellung Oktober, 0 2 * * * |
Feuert genau einmal |
| R5 | Zeitumstellung März, 0 2 * * * |
Feuert trotzdem (wird nicht übersprungen) |
| R6 | 0 3 1 1 * (jährlich) |
Scheduler überlebt Wartezeit > 24 Tage — schlägt heute fehl |
| R7 | Lauf dauert länger als das Intervall | Keine Überlappung, keine Aufstauung |
| R8 | Exception in einem Job-Tick | Scheduler-Schleife läuft weiter, Fehler wird geloggt |
3.5 Kostenerfassung (deckt B4 ab)
| # | Test | Erwartung |
|---|---|---|
| K1 | Modell nicht in der Preistabelle | Sichtbar markiert (nicht stillschweigend 0) |
| K2 | anthropic/claude-sonnet-4-5 (der Default!) |
Liefert einen Preis ≠ 0 |
| K3 | 95k Prompt / 5k Completion | Exakter Betrag gegen handgerechneten Wert — kein 50/50-Split |
Prio 2 — Sicherheitstests
Diese Tests sind gleichzeitig die dauerhafte Dokumentation der Bedrohungsmodelle. Jeder
Angriffsvektor aus der Bestandsaufnahme wird ein [Theory]-Fall.
3.6 FileRW — Pfadsicherheit (deckt S6 ab)
[Theory]
[InlineData("../../../Windows/System32/drivers/etc/hosts")]
[InlineData(@"..\..\evil.txt")]
[InlineData(@"C:\Windows\System32\config\SAM")]
[InlineData(@"\\server\share\evil.txt")]
[InlineData("subdir/../../../outside.txt")]
[InlineData("file.txt:hidden")] // Alternate Data Stream
[InlineData("file.txt.")] // Windows entfernt den Punkt
[InlineData(" .. /../evil.txt")]
public async Task Read_lehnt_Pfade_ausserhalb_des_Workspace_ab(string path) { … }
Plus der Präfix-Angriff, der heute durchgeht:
| # | Test | Erwartung |
|---|---|---|
| F1 | Root …\Workspace, Ziel …\Workspace-Backup\x.txt |
Abgelehnt — schlägt heute fehl |
| F2 | Endungs-Whitelist: .exe, .ps1, .dll |
Abgelehnt |
| F3 | .TXT bei Whitelist .txt |
Erlaubt (Groß/Kleinschreibung) |
| F4 | report.txt.exe |
Abgelehnt |
| F5 | copy mit destinationPath außerhalb des Ziel-Workspace |
Abgelehnt (eigener Pfad, eigener Test!) |
| F6 | sharedAccessLevel: Denied |
Jede shared-Aktion abgelehnt |
| F7 | ReadWrite + delete in shared |
Abgelehnt (nur Admin) |
| F8 | protectedPath stocks/, Schreiben auf existierende stocks/x.json |
Abgelehnt |
| F9 | protectedPath stocks/, Schreiben auf neue stocks/y.json |
Erlaubt |
| F10 | protectedPath stocks/, Pfad stocks-alt/x.json |
Nicht geschützt (Präfix-Verwechslung) |
| F11 | Admin-Level + protectedPath | Umgeht den Schutz |
| F12 | 20 parallele stock_add auf denselben Ticker |
_index.json enthält exakt 20 Einträge, gültiges JSON |
F12 deckt B9 ab und ist ein echter Nebenläufigkeitstest gegen das Dateisystem.
3.7 DatabaseTool — SQL-Whitelist (deckt S1 ab)
Die Bypass-Tests. Alle schlagen heute fehl und definieren die Zielsemantik:
| # | SQL | AccessLevel | Erwartung |
|---|---|---|---|
| D1 | DELETE FROM users -- prices |
ReadWrite | Abgelehnt |
| D2 | SELECT * FROM users WHERE note='prices' |
ReadOnly | Abgelehnt |
| D3 | SELECT * FROM prices; DROP TABLE users |
Admin | Abgelehnt (Mehrfach-Statement) |
| D4 | SELECT * FROM prices |
ReadOnly | Erlaubt |
| D5 | SELECT * FROM prices WHERE note='update' |
ReadOnly | Erlaubt (heutiger False Positive) |
| D6 | UPDATE prices SET x=1 |
ReadOnly | Abgelehnt |
| D7 | UPDATE prices SET x=1 |
ReadWrite | Erlaubt |
| D8 | DROP TABLE prices |
ReadWrite | Abgelehnt |
| D9 | allowedTables fehlt komplett |
egal | Abgelehnt (fail closed) |
| D10 | SELECT mit 10.000 Zeilen Ergebnis |
ReadOnly | Ergebnis wird gekappt |
D4/D5/D7 sind die Gegenproben — sie verhindern, dass der Fix zu restriktiv wird und legitime Abfragen blockiert.
3.8 WebFetch — SSRF (deckt S5 ab)
Mit einem FakeHttpMessageHandler, der Redirects simuliert:
| # | Test | Erwartung |
|---|---|---|
| W1 | Erlaubte Domain → Redirect auf nicht erlaubte | Abgelehnt — schlägt heute fehl |
| W2 | http://169.254.169.254/latest/meta-data/ |
Abgelehnt |
| W3 | http://localhost:8418/ |
Abgelehnt (privates Netz) |
| W4 | http://192.168.178.10/ |
Abgelehnt |
| W5 | file:///C:/Windows/win.ini |
Abgelehnt |
| W6 | Whitelist example.com, URL example.com.attacker.net |
Abgelehnt |
| W7 | Whitelist example.com, URL sub.example.com |
Erlaubt |
| W8 | Host mywww.example.com |
Wird nicht zu myexample.com verstümmelt |
| W9 | Antwort größer als maxResponseKb |
Sauber gekappt, kein OOM |
3.9 Mail — Empfängerprüfung
| # | Test | Erwartung |
|---|---|---|
| M1 | Nicht gelistete Adresse | Abgelehnt |
| M2 | ALLOWED@X.DE bei Whitelist allowed@x.de |
Erlaubt |
| M3 | allowed@x.de, evil@y.de (zwei in einem Feld) |
Abgelehnt |
| M4 | "allowed@x.de" <evil@y.de> (Display-Name-Trick) |
Abgelehnt |
| M5 | allowedRecipients fehlt |
Abgelehnt (fail closed) |
| M6 | read_inbox mit 500 Mails |
Ergebnis gekappt |
3.10 SocialMediaManager — Argument-Injection (deckt S2 ab)
| # | Test | Erwartung |
|---|---|---|
| Y1 | BuildYtDlpArgs("--exec calc.exe https://youtube.com/x") |
Wirft oder quotet — nie als eigene Option |
| Y2 | URL https://evil.com/x |
Abgelehnt (nur YouTube-Domains) |
| Y3 | URL mit Leerzeichen/Anführungszeichen | Erscheint als ein Argument in ArgumentList |
| Y4 | Legitime URL/Handle (@kanal, volle URL) |
Wird korrekt aufgelöst |
3.11 DirectAPI — Key-Leak (deckt S3 ab)
Ein einzelner, sehr einfacher Test mit hohem Wert:
[Fact]
public async Task Ergebnis_enthaelt_niemals_den_ApiKey()
{
const string key = "SECRET-KEY-12345";
var result = await _tool.ExecuteAsync(QuoteFor("NVDA"), ContextWithKey(key), default);
result.Content.ShouldNotContain(key); // schlägt heute fehl
}
Analog für alle Provider (twelvedata, alphavantage, massive) und alle Aktionen — als
[Theory]. Dieselbe Prüfung sollte es zusätzlich für die Log-Ausgabe geben.
Prio 3 — Engine-Verhalten (mit FakeChatClient)
| # | Test | Erwartung |
|---|---|---|
| A1 | Antwort ohne tool_calls | Genau 1 API-Call, FinalMessage korrekt, Status Completed |
| A2 | Antwort mit 2 tool_calls, dann Text | 2 Tools ausgeführt, 2 tool-Nachrichten mit passenden Ids, dann 2. Call |
| A3 | Tool wirft Exception | Lauf läuft weiter, Fehler kommt als tool-Antwort ins Modell |
| A4 | Modell ruft nicht zugewiesenes Tool auf | PermissionGate greift, Fehlermeldung als tool-Antwort, kein Absturz |
| A5 | Modell ruft unbekanntes Tool auf | Saubere Fehlermeldung |
| A6 | Timeout (FakeTimeProvider) |
Status Cancelled |
| A7 | Externe Cancellation | Status Cancelled |
| A8 | Loop-Limit erreicht | Status LoopLimitExceeded |
| A9 | OnRunCompleted feuert in jedem Endzustand genau einmal |
Parametrisiert über alle 5 Status |
| A10 | System-Prompt | Im Request genau einmal enthalten |
| A11 | FullSystemPrompt leer |
Kein leerer system-Eintrag im Request |
| A12 | Tool-Ergebnis über Limit | Gekappt (nach T2-Fix) |
| A13 | send_message A→B→A |
Nach maxDelegationDepth abgebrochen (nach B8-Fix) |
| A14 | spawn auf bereits laufenden Agenten |
Abgelehnt mit Hinweis |
| A15 | Snapshot des Request-JSON (Verify) | Wire-Format bleibt stabil |
A9 und A15 sind Invarianten-Tests mit überproportionalem Nutzen: A9 verhindert, dass beim Erweitern der Fehlerbehandlung die Token-Erfassung stillschweigend ausfällt; A15 schützt beim Caching-Umbau (T1) davor, das Wire-Format versehentlich zu verändern.
Prio 4 — Konfiguration & Persistenz
| # | Bereich | Test |
|---|---|---|
| P1 | ConfigLoader |
Fehlender openRouterApiKey → aussagekräftige Exception |
| P2 | ConfigLoader |
Doppelte agentId → Exception mit beiden Namen |
| P3 | ConfigLoader |
JSON mit Kommentaren und trailing commas wird akzeptiert |
| P4 | ConfigLoader |
Datei fehlt → FileNotFoundException mit Pfad |
| P5 | Chat-Persistenz | Round-Trip History + Context, inkl. tool_calls |
| P6 | Chat-Persistenz | Beide Alt-Formate lesbar (Array und {"messages":[…]}) |
| P7 | Chat-Persistenz | Korrupte JSON-Datei → Warnung, Agent startet trotzdem |
| P8 | Chat-Persistenz | Umlaute/Emoji überleben den Round-Trip (UTF-8) |
| P9 | SqliteStateStore |
Set/Get/Delete Round-Trip; Überschreiben; unbekannter Key → null |
| P10 | SqliteStateStore |
50 parallele Schreibzugriffe → kein database is locked |
| P11 | AgentConfig.FullSystemPrompt |
Reihenfolge Identity → Soul → SystemPrompt |
| P12 | AgentConfig.FullSystemPrompt |
Leere Teile werden ausgelassen, kein doppelter Leerraum |
| P13 | ToolRegistry |
GetForAgent liefert nur zugewiesene Tools |
P10 ist relevant, weil alle Tool-Jobs denselben Store teilen.
Prio 5 — Token & Caching (nach dem Umbau aus T1)
Diese Tests sichern ab, dass die Optimierung tatsächlich greift — ein stillschweigend nicht funktionierender Cache wäre sonst nicht bemerkbar.
| # | Test | Erwartung |
|---|---|---|
| T1 | Cache-Breakpoint | Sitzt am Ende von System-Prompt und Tool-Definitionen |
| T2 | Präfix-Stabilität über 10 Schritte | Serialisierter Präfix ist byte-identisch — der entscheidende Test |
| T3 | cached_tokens |
Wird aus der Antwort gelesen und in AgentRunResult weitergereicht |
| T4 | Compaction | Nutzt summaryModel, nicht agentConfig.Model |
| T5 | Tool-Ergebnis-Kappung | Greift bei allen Tools zentral, nicht nur bei WebFetch |
| T6 | max_tokens |
Ist im Request gesetzt |
T2 ist der Test, der den ganzen Caching-Aufwand absichert: Ein einziger Zeitstempel im System-Prompt würde den Cache bei jedem Schritt invalidieren — die Kosten blieben unverändert, ohne dass es irgendwo auffällt.
4. Arbeitsregeln
Test-first bei Bugfixes. Für jeden Punkt aus der Bestandsaufnahme zuerst den roten Test, dann den Fix. Das beweist, dass der Test den Fehler wirklich fängt — ein nachträglich geschriebener Test bestätigt oft nur die Implementierung.
Keine echten Netzwerkzugriffe in Unit-Tests. Sie sind langsam, flaky und kosten bei OpenRouter echtes Geld. Integrationstests kommen ins separate Projekt und werden markiert:
[Trait("Category", "Integration")]
Standardlauf:
dotnet test --filter "Category!=Integration"
Zeit immer über TimeProvider. Kein Thread.Sleep, kein Task.Delay in Tests — sonst
dauert die Suite Minuten und wird instabil.
Nebenläufigkeitstests wiederholen. Race Conditions treten sporadisch auf; einmaliges Bestehen sagt wenig:
[Theory]
[Repeat(50)]
Benennung durchgängig deutsch, im Stil Methode_Bedingung_Erwartung:
Compaction_bei_Toolsequenz_an_der_Grenze_erhaelt_gueltige_Paarung.
5. Startpaket
Für die erste Sitzung schlage ich ~35 Tests vor, die exakt die fünf Sofort-Punkte abdecken:
| Reihenfolge | Umfang | Deckt ab |
|---|---|---|
1. R1 (IChatCompletionClient) + Testprojekte anlegen |
Refactoring | Voraussetzung |
2. ContextInvariants.AssertValid + C1–C10 + Property-Test |
~12 Tests | B1 |
| 3. L1–L4 | 4 Tests | B3 |
| 4. E1–E4 | 4 Tests | B2 |
| 5. Y1–Y4 | 4 Tests | S2 |
| 6. DirectAPI Key-Leak (alle Provider) | ~6 Tests | S3 |
| 7. A9 + A15 (Invarianten) | 6 Tests | Absicherung für den Umbau |
Danach ist die Basis da, um die Fixes aus der Bestandsaufnahme mit Netz umzusetzen — und die Prio-2-Sicherheitstests lassen sich schrittweise ergänzen, wenn die jeweiligen Tools angefasst werden.
Langfristig sinnvoll: ein Gitea-Actions-Workflow, der bei jedem Push
dotnet test --filter "Category!=Integration" ausführt.