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>
This commit is contained in:
Richard
2026-07-27 10:07:54 +02:00
co-authored by Claude Opus 4.8
parent 92e50d3ac4
commit 667cecce25
17 changed files with 1887 additions and 5 deletions
+463
View File
@@ -0,0 +1,463 @@
# 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.