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:
co-authored by
Claude Opus 4.8
parent
92e50d3ac4
commit
667cecce25
@@ -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` + 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.
|
||||
Reference in New Issue
Block a user