Files
ClawdDotNet/docs/Teststrategie.md
T
RichardandClaude Opus 4.8 667cecce25 Testfundament aufbauen und Bestandsaufnahme dokumentieren
IChatCompletionClient aus OpenRouterClient extrahiert, damit AgentEngine und
ContextCompactor ohne echte API-Aufrufe testbar sind.

Neues Testprojekt tests/ClawdDotNet.Core.Tests (xUnit, Shouldly, NSubstitute,
FsCheck) mit:
- FakeChatClient (programmierbare Antwortfolgen, Deep-Copy der Requests)
- ContextInvariants (prueft die API-Regeln fuer tool_call-Paarung)
- Conversation-Builder fuer gueltige Testkonversationen
- 26 Tests: Compaction, LoopGuard, 2 Property-Tests

10 Tests sind bewusst rot — sie reproduzieren die Bugs B1, B3 und B14 aus der
Bestandsaufnahme und werden mit den Fixes gruen.

Ausserdem: fehlende Tool-Projekte in slnx ergaenzt, Test-Pakete im
packageSourceMapping der NuGet.Config eingetragen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:07:54 +02:00

464 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ClawdDotNet — Teststrategie
Ergänzung zur [Bestandsaufnahme](Bestandsaufnahme-2026-07.md).
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:
1. **Kontext-Invarianten** — ungültige Nachrichtensequenzen kosten API-Fehler mitten im Lauf
2. **Sicherheitsgrenzen** — Pfad-, SQL-, URL- und Empfänger-Prüfungen
3. **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`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L15) und
[`ContextCompactor.cs:10`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L10) hängen an der
konkreten, `sealed`-Klasse `OpenRouterClient`. Ohne Interface ist kein einziger Engine-Test möglich.
```csharp
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:
```csharp
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: `AgentEngine` reicht dieselbe `List<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`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs#L53),
[`AgentScheduler.cs:75`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs#L75),
[`ToolJobScheduler.cs:111`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs#L111).
Damit werden Zeitumstellungs- und Langzeit-Tests deterministisch und laufen in Millisekunden
statt in Echtzeit.
### R3 — `IProcessRunner` für externe Prozesse
[`SocialMediaManagerTool.cs:442`](../src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs#L442)
startet yt-dlp/ffmpeg direkt. Für den Injection-Test (S2) muss die **Argumentbildung** ohne
Prozessstart prüfbar sein:
```csharp
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:
```csharp
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.slnx` fehlen vier Tool-Projekte (AgentComm, AgentSpawn,
> AgentEditor, SocialMediaManager), obwohl sie in `ClawdDotNet.csproj` referenziert 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:
```csharp
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:**
```csharp
[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)
```csharp
[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:
```csharp
[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:
```csharp
[Trait("Category", "Integration")]
```
Standardlauf:
```bash
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:
```csharp
[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` + C1C10 + Property-Test | ~12 Tests | **B1** |
| 3. L1L4 | 4 Tests | **B3** |
| 4. E1E4 | 4 Tests | **B2** |
| 5. Y1Y4 | 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.