From 667cecce2565ea0415c10a86872589fbd77c000f Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 27 Jul 2026 10:07:54 +0200 Subject: [PATCH] Testfundament aufbauen und Bestandsaufnahme dokumentieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ClawdDotNet.slnx | 7 + NuGet.Config | 11 + docs/Bestandsaufnahme-2026-07.md | 542 ++++++++++++++++++ docs/Teststrategie.md | 463 +++++++++++++++ .../Api/IChatCompletionClient.cs | 12 + src/ClawdDotNet.Core/Api/OpenRouterClient.cs | 2 +- src/ClawdDotNet.Core/Engine/AgentEngine.cs | 4 +- .../Engine/ContextCompactor.cs | 4 +- .../ClawdDotNet.Core.Tests.csproj | 25 + .../Engine/ContextCompactorPropertyTests.cs | 150 +++++ .../Engine/ContextCompactorTests.cs | 239 ++++++++ .../Engine/LoopGuardTests.cs | 100 ++++ tests/ClawdDotNet.Core.Tests/GlobalUsings.cs | 1 + .../Infrastructure/ContextInvariants.cs | 126 ++++ .../Infrastructure/Conversation.cs | 68 +++ .../Infrastructure/FakeChatClient.cs | 129 +++++ .../Infrastructure/TestLogging.cs | 9 + 17 files changed, 1887 insertions(+), 5 deletions(-) create mode 100644 docs/Bestandsaufnahme-2026-07.md create mode 100644 docs/Teststrategie.md create mode 100644 src/ClawdDotNet.Core/Api/IChatCompletionClient.cs create mode 100644 tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj create mode 100644 tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorPropertyTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/GlobalUsings.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/ContextInvariants.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/Conversation.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/TestLogging.cs diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx index 9025660..ccfa616 100644 --- a/ClawdDotNet.slnx +++ b/ClawdDotNet.slnx @@ -10,6 +10,13 @@ + + + + + + + diff --git a/NuGet.Config b/NuGet.Config index e201038..d293173 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -26,6 +26,17 @@ + + + + + + + + + + + diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md new file mode 100644 index 0000000..66111f9 --- /dev/null +++ b/docs/Bestandsaufnahme-2026-07.md @@ -0,0 +1,542 @@ +# ClawdDotNet — Bestandsaufnahme (Juli 2026) + +Vollständiges Review von Core-Engine, Security-Layer, Tools, Scheduling und UI. +Stand: Commit `92e50d3`. + +**Kurzfassung:** Die Architektur ist gut — die Trennung Core/Tools/UI, das `IAgentTool`-Interface, +der Tool-Job-Mechanismus und das Instanz-Konzept tragen. Die Probleme liegen fast alle in +drei Bereichen: (1) Sicherheitsprüfungen, die als String-Vergleiche implementiert sind und +deshalb umgehbar sind, (2) fehlende Nebenläufigkeits-Absicherung im geteilten Chat-Kontext, +(3) kein Prompt-Caching — der mit Abstand größte Kostenhebel. + +--- + +## 1. Kritische Sicherheitslücken (P0) + +### S1 — DatabaseTool: Tabellen-Whitelist ist vollständig umgehbar +[`DatabaseTool.cs:218`](../src/ClawdDotNet.Tools.Database/DatabaseTool.cs#L218) + +```csharp +return allowed.Any(t => t != null && inputLower.Contains(t)); +``` + +Die Prüfung sucht den erlaubten Tabellennamen *irgendwo* im SQL-String — auch in einem +Kommentar oder String-Literal. Bei `allowedTables: ["prices"]` und `accessLevel: ReadWrite`: + +```sql +DELETE FROM users -- prices +``` + +`IsWriteAttempt` → true (ReadWrite erlaubt), `IsTableAllowed` → true (enthält "prices"). +Die `users`-Tabelle wird gelöscht. Gleiches gilt für jede beliebige andere Tabelle. + +Zusätzlich: +- `IsWriteAttempt`/`IsAdminAttempt` ([Z.189/195](../src/ClawdDotNet.Tools.Database/DatabaseTool.cs#L189)) + sind ebenfalls Substring-Prüfungen → False Positives (`SELECT * FROM prices WHERE note='update'` + wird als Schreibzugriff blockiert). +- Mehrere Statements pro Aufruf sind nicht unterbunden (`;`-Verkettung). +- Query-Ergebnisse haben kein `LIMIT` → eine Tabelle mit 100k Zeilen landet komplett im Kontext. + +**Fix:** Echtes SQL-Parsing (z.B. via `Microsoft.SqlServer.TransactSql.ScriptDom` für MSSQL bzw. +einem generischen Tokenizer), oder — deutlich robuster — den freien SQL-Zugriff durch +vordefinierte, parametrisierte Named Queries ersetzen. Freies SQL vom LLM ist grundsätzlich +schwer abzusichern. + +### S2 — SocialMediaManager: Kommando-Injection über yt-dlp +[`SocialMediaManagerTool.cs:442`](../src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs#L442), auch Z.484 und Z.490 + +```csharp +var psi = new ProcessStartInfo(ytDlpPath, $"--print \"%(id)s\" --playlist-end 1 {channelUrl}") +``` + +`channelUrl` stammt aus dem Agenten-Input und wird ungequotet in die Argumentliste interpoliert. +`UseShellExecute = false` verhindert Shell-Metazeichen, aber **nicht** Options-Injection: +yt-dlp kennt `--exec`, das beliebige Befehle nach dem Download ausführt. + +Ein Wert wie `--exec "cmd /c ..." https://youtube.com/...` führt zu Codeausführung. +Kritisch wird das, weil der Agent untrusted Content verarbeitet (YouTube-Titel, X-Posts, +Mails) — eine Prompt-Injection in abgerufenen Inhalten kann den Agenten dazu bringen, +genau so einen "URL"-Parameter zu setzen. + +**Fix:** `ProcessStartInfo.ArgumentList` verwenden (quotet korrekt), zusätzlich die URL gegen +eine Regex validieren (`^https://(www\.)?(youtube\.com|youtu\.be)/…`) und `--`-Separator +vor dem URL-Argument setzen. + +### S3 — DirectAPI: API-Keys landen im Modell-Kontext +[`DirectAPITool.cs:83`](../src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs#L83), auch Z.99, 154, 177, 263 + +```csharp +var url = $"https://api.twelvedata.com/quote?symbol={symbol}&apikey={apiKey}"; +… +return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value); // url → "source" +``` + +Die vollständige URL inklusive `apikey=` wird als `source` an das Modell zurückgegeben. Damit +landet der Key: +- im Chat-Kontext → wird bei **jedem** Folge-Request erneut an OpenRouter gesendet, +- in `ChatContext.json` auf der Platte, +- in den Logs. + +**Fix:** Key als Header senden statt als Query-Parameter (wo möglich), und in `source` +grundsätzlich eine bereinigte URL zurückgeben (Query-String entfernen oder `apikey` maskieren). + +### S4 — PermissionGate ist faktisch wirkungslos +[`PermissionGate.cs:7`](../src/ClawdDotNet.Core/Security/PermissionGate.cs#L7) + +```csharp +public bool IsAllowed(string agentId, string toolName, AgentConfig agentConfig) + => agentConfig.Tools.ContainsKey(toolName); +``` + +Der einzige Check ist "Tool ist dem Agenten zugewiesen" — was `ToolRegistry.GetForAgent` ohnehin +schon filtert. Der `agentId`-Parameter wird nicht verwendet. Es gibt keine Aktions-Granularität +(lesen vs. schreiben vs. senden), kein Rate-Limiting, kein Audit. Die gesamte Sicherheit liegt +ad-hoc in den einzelnen Tools — jedes mit eigener, inkonsistenter Logik. + +**Fix:** Das Gate zum zentralen Policy-Punkt ausbauen: Aktions-Ebene (`Tool.Action`), +Rate-Limits pro Agent/Tool/Zeitfenster, Audit-Log jedes Aufrufs mit Argumenten, und ein +Approval-Hook für irreversible Aktionen (siehe F-A1). + +### S5 — WebFetch: kein SSRF-Schutz bei Redirects +[`WebFetchTool.cs:58`](../src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs#L58) + +Die Domain-Whitelist wird nur auf die *ursprüngliche* URL angewendet. `HttpClient` folgt +Redirects standardmäßig — eine erlaubte Domain kann auf `http://169.254.169.254/`, +`http://localhost:8418/` (dein Gitea!) oder beliebige interne Hosts weiterleiten. + +Nebenbei: `uri.Host.Replace("www.", "")` ([Z.59](../src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs#L59)) +ersetzt das Fragment überall im Hostnamen, nicht nur am Anfang. + +**Fix:** `HttpClientHandler { AllowAutoRedirect = false }` und Redirects manuell auflösen, +dabei jede Zwischen-URL erneut gegen die Whitelist prüfen. Zusätzlich private IP-Bereiche +(RFC1918, Loopback, Link-Local) hart blockieren. + +### S6 — FileRW: Path-Traversal-Prüfung per Präfix +[`FileRWTool.cs:211`](../src/ClawdDotNet.Tools.FileRW/FileRWTool.cs#L211) + +```csharp +if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) +``` + +Ohne abschließenden Verzeichnis-Separator: Root `C:\Instances\Dev\Workspace` erlaubt Zugriff auf +`C:\Instances\Dev\Workspace-Backup\…`. In der aktuellen Verzeichnisstruktur nicht direkt +ausnutzbar, aber eine Zeitbombe. + +**Fix:** `rootPath` mit `Path.DirectorySeparatorChar` terminieren, oder besser +`Path.GetRelativePath` + Prüfung auf `..`-Präfix. + +### S7 — Secrets im Klartext +`openRouterApiKey`, DB-`connectionString` (mit Passwort), Mail-`password`, Telegram-`password2FA` +liegen unverschlüsselt in `AgentSettings.json` / `InstanceConfig.json` +([`InstanceConfig.cs:46`](../src/ClawdDotNet.Core/Config/InstanceConfig.cs#L46)). + +**Fix:** DPAPI (`ProtectedData.Protect` mit `CurrentUser`-Scope) für alle Secret-Felder, +oder Windows Credential Manager. Zumindest sollten die Felder beim Speichern verschlüsselt +und erst zur Laufzeit entschlüsselt werden. + +--- + +## 2. Bugs (P1) + +### B1 — ContextCompactor zerstört die tool_call-Paarung ⚠️ +[`ContextCompactor.cs:140`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L140) + +```csharp +var tail = messages.Skip(Math.Max(0, messages.Count - ProtectedTailMessages)).ToList(); +messages.Clear(); +… messages.AddRange(tail); +``` + +Die letzten 6 Nachrichten werden blind übernommen. Wenn die Grenze mitten in einer +Tool-Sequenz liegt, beginnt der Tail mit einer `tool`-Nachricht, deren zugehörige +`assistant`-Nachricht mit `tool_calls` gerade gelöscht wurde. Die OpenRouter/Anthropic-API +lehnt das mit HTTP 400 ab ("tool_result without preceding tool_use"). + +Das passiert **genau dann**, wenn der Kontext lang wird — also im wichtigsten Moment. +Bei einer typischen Sequenz `assistant(2 tool_calls) → tool → tool` ist die Wahrscheinlichkeit +hoch. + +**Fix:** Die Tail-Grenze auf einen sicheren Schnittpunkt verschieben — rückwärts suchen bis zu +einer Nachricht mit Rolle `user` oder `assistant` ohne `tool_calls`. Analog muss ein +`assistant` mit `tool_calls` am Ende immer seine vollständigen `tool`-Antworten behalten. + +### B2 — Race Condition auf dem geteilten Chat-Kontext ⚠️ +[`AgentEngine.cs:250`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L250) und Z.268/316/322 + +`_chatContexts[agentId]` ist eine geteilte `List`. Nur der *Lookup* läuft unter +`_lock` — alle `messages.Add(...)` im Loop passieren ungeschützt. + +Für denselben Agenten kann `ChatAsync` mehrfach parallel laufen: +- Telegram-/Mail-ToolJob weckt den Agenten (`ToolJobScheduler.cs:187`), +- der Benutzer schickt gleichzeitig eine WebView-Nachricht, +- ein anderer Agent ruft `send_message` auf (`AgentEngine.cs:528`). + +Folgen: `List`-Korruption (nicht threadsicher), verschränkte Tool-Call-Sequenzen → +API-400-Fehler, und `_runningChats[agentId] = linkedCts` überschreibt den vorherigen Eintrag, +sodass `AbortChat` nur den zuletzt gestarteten Lauf abbricht. + +**Fix:** Pro Agent ein `SemaphoreSlim(1,1)`, das den gesamten `ChatAsync`-Durchlauf serialisiert. +Wartende Nachrichten in eine Queue statt parallel starten. + +### B3 — `maxTokens` vermischt Abrechnungs-Budget und Kontextgröße ⚠️ +[`LoopGuard.cs:22`](../src/ClawdDotNet.Core/Engine/LoopGuard.cs#L22), Defaults in +[`AgentConfig.cs:105`](../src/ClawdDotNet.Core/Config/AgentConfig.cs#L105) + +`RecordTokens` summiert `response.Usage.TotalTokens` über alle Schritte. Da jeder Schritt den +**kompletten** Kontext erneut sendet, wächst diese Summe quadratisch: + +| Schritt | Kontext | kumuliert | +|---|---|---| +| 1 | 20.000 | 20.000 | +| 2 | 21.000 | 41.000 | +| 3 | 22.000 | 63.000 | +| 4 | 23.000 | **86.000** → Abbruch | + +Mit `maxTokens: 80_000` bricht ein völlig normaler Chat nach 4 Schritten mit +"Max tokens exceeded" ab, obwohl der Kontext nie über 23k lag. Das ist vermutlich die +häufigste Frustquelle im laufenden Betrieb. + +**Fix:** Zwei getrennte Begriffe: +- `maxCumulativeTokens` — Kostenbudget pro Run (bewusst hoch, z.B. 500k) +- `maxContextTokens` — Kontextgröße, steuert die Compaction (existiert bereits) + +und beide getrennt im UI ausweisen. + +### B4 — Kostenanzeige ist doppelt falsch +[`frm_main.cs:292`](../frm_main.cs#L292) + +```csharp +_statusService?.RecordUsage(model, result.TokensUsed / 2, result.TokensUsed / 2); +``` + +1. **50/50-Aufteilung**: Reales Verhältnis in Agenten-Loops ist eher 95:5 (Prompt:Completion). + Da Output ~5x teurer ist, überschätzt die Anzeige die Kosten um Faktor ~2,5. +2. **Modell fehlt in der Preistabelle**: `AgentConfig.Model` hat den Default + `anthropic/claude-sonnet-4-5` — dieser Eintrag existiert in `ModelPricing` + ([`OpenRouterStatusService.cs:15`](../Services/OpenRouterStatusService.cs#L15)) nicht. + `CalculateCost` gibt dann stillschweigend `0` zurück. + +Die Tabelle ist zudem veraltet (`claude-sonnet-4`, `claude-opus-4`, `claude-haiku-4.5`). + +**Fix:** `Usage` um `PromptTokens`/`CompletionTokens` erweitern und bis in `AgentRunResult` +durchreichen. Preise live vom `/models`-Endpoint holen (liefert `pricing.prompt`/`pricing.completion`) +statt hartzucodieren. Bei unbekanntem Modell sichtbar warnen statt 0 anzuzeigen. + +### B5 — Tool-Ergebnisse landen ungekappt im Kontext +Ein einziger `WebFetch` mit dem Default `maxResponseKb: 512` +([`WebFetchTool.cs:90`](../src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs#L90)) erzeugt bis zu +512 KB Text ≈ **130.000 Tokens** in einer einzigen Tool-Antwort. `FileRW.read` +([`FileRWTool.cs:245`](../src/ClawdDotNet.Tools.FileRW/FileRWTool.cs#L245)) hat gar kein Limit, +`Database.query` und `Mail.read_inbox` ebenfalls nicht. + +Gekürzt wird erst nachträglich in der Compaction — und dort nur außerhalb der letzten +6 Nachrichten. Der teure Request ist zu dem Zeitpunkt längst bezahlt. + +**Fix:** Kappung beim Einfügen in `ExecuteToolCallAsync` +([`AgentEngine.cs:668`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L668)) — zentral, für alle +Tools, mit konfigurierbarem `maxToolResultTokens` und einem Hinweis an das Modell, dass gekürzt +wurde (inkl. Angebot, gezielt nachzulesen). + +### B6 — `Task.Delay` wirft bei langen Cron-Intervallen +[`AgentScheduler.cs:89`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs#L89) und +[`ToolJobScheduler.cs:126`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs#L126) + +`Task.Delay` wirft `ArgumentOutOfRangeException` bei Werten über ~24,8 Tagen. Ein jährlicher +Cron (`0 3 1 1 *`) erzeugt eine Wartezeit von bis zu 365 Tagen. Der `catch` fängt nur +`OperationCanceledException` — die Exception beendet den Scheduler-Task still. Da die Tasks in +`_schedulerTasks` nie beobachtet werden (`await` erst beim Dispose), fällt das nicht auf: der +Job läuft ab dann einfach nie wieder. + +**Fix:** Delay auf max. 1 Tag deckeln und in einer Schleife erneut prüfen. Zusätzlich alle +Scheduler-Tasks mit einem `ContinueWith`-Fehler-Logger versehen. + +### B7 — Cron rechnet in Lokalzeit +[`CronExpression.cs:51`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs#L51) mit +`DateTime.Now`. Bei Zeitumstellung: im Oktober läuft ein `0 2 * * *`-Job doppelt, im März gar nicht. + +**Fix:** Intern in UTC rechnen und nur für die Anzeige konvertieren, oder `TimeZoneInfo` +explizit berücksichtigen. + +### B8 — Keine Rekursionsbremse bei AgentComm / AgentSpawn +[`AgentEngine.cs:510`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L510) + +Agent A ruft `send_message(B)` → `ChatAsync(B)` → B ruft `send_message(A)` → … Es gibt keine +Tiefenbegrenzung und keinen Zyklus-Check. Bei `SpawnAgentAsync` schützt immerhin der +`IsRunning`-Check, bei `send_message` nicht. Begrenzt wird das nur durch die Timeouts — +in der Zwischenzeit brennt jeder Hop einen vollständigen LLM-Run. + +**Fix:** Aufruf-Tiefe im `AgentToolContext` mitführen (`maxDelegationDepth`, Default 3) und +den Aufrufpfad zur Zyklenerkennung mitgeben. + +### B9 — `index_Count` liest die Datei außerhalb des Locks erneut +[`FileRWTool.cs:569`](../src/ClawdDotNet.Tools.FileRW/FileRWTool.cs#L569) ruft +[`index_Count`](../src/ClawdDotNet.Tools.FileRW/FileRWTool.cs#L572) auf, nachdem das Lock +freigegeben wurde — Race mit parallelen `stock_add`-Aufrufen, plus ein überflüssiges +vollständiges Parsen der Index-Datei. Der Zähler ist innerhalb des Locks ohnehin bekannt +(`index.Count`). + +### B10 — `PersistChatState` schreibt bei jedem Eintrag alles neu +[`AgentEngine.cs:593`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L593) + +Vollständige History **und** vollständiger Kontext werden als `WriteIndented`-JSON bei jedem +einzelnen Chat-Eintrag rausgeschrieben → O(n²) Schreiblast über eine Sitzung. Bei einem Agenten +mit 500 Nachrichten sind das mehrere MB pro Nachricht. + +**Fix:** History als Append-only JSONL, Kontext nur periodisch/beim Beenden persistieren, +`WriteIndented = false`. + +### B11 — Kein `max_tokens` im Request +[`AgentEngine.cs:105`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L105) und Z.279 setzen +`ChatRequest.MaxTokens` nie. Ein Modell kann in einem Schritt sein volles Output-Limit +ausschöpfen. + +### B12 — Kein Retry/Backoff +[`OpenRouterClient.cs:42`](../src/ClawdDotNet.Core/Api/OpenRouterClient.cs#L42) wirft bei jedem +Nicht-2xx sofort. Ein einzelnes HTTP 429 killt einen kompletten geplanten Run. Bei OpenRouter +sind 429/502/503 im Normalbetrieb zu erwarten. + +**Fix:** Polly o.ä. mit exponentiellem Backoff + Jitter für 429/5xx, `Retry-After` respektieren. + +### B14 — Compaction dupliziert den System-Prompt bei kurzen Konversationen ⚠️ +[`ContextCompactor.cs:140`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L140) + +*Gefunden durch den Property-Test, nicht beim Lesen des Codes.* + +```csharp +var tail = messages.Skip(Math.Max(0, messages.Count - ProtectedTailMessages)).ToList(); +messages.Clear(); +if (systemMsg is not null) messages.Add(systemMsg); // ← System-Prompt +… +messages.AddRange(tail); // ← enthält ihn nochmal +``` + +Bei `messages.Count <= 6` ist `Skip(0)` — der Tail enthält also **alle** Nachrichten +einschließlich der System-Nachricht. Diese wird danach ein zweites Mal angehängt. + +Ergebnis: zwei `system`-Nachrichten, die zweite mitten in der Konversation. Zusätzlich wird +die komplette Konversation dupliziert, obwohl sie gerade zusammengefasst wurde — die +Compaction *vergrößert* den Kontext in diesem Fall, statt ihn zu verkleinern. + +Erreichbar, sobald wenige Nachrichten die Token-Schwelle reißen — z.B. ein einzelner +`WebFetch` mit 500 KB Ergebnis (siehe B5). Minimales Gegenbeispiel aus FsCheck: +System-Prompt + eine User-Nachricht. + +**Fix:** Den System-Prompt aus dem Tail herausfiltern, bevor er wieder angehängt wird — +und den Tail generell erst ab der Position hinter der System-Nachricht bilden. + +### B13 — Inkonsistente `instanceId` +`RunAsync`/`ChatAsync` bekommen `instanceId` als Parameter, `SendMessageAsync`/`SpawnAgentAsync` +verwenden dagegen das Feld `_instanceId`, das nur gesetzt wird, wenn +`SetAgentConfigProvider` aufgerufen wurde — sonst `""`. + +--- + +## 3. Konzeptfehler (P2) + +### K1 — Geplante Agenten haben kein Gedächtnis +`RunAsync` baut bei jedem Cron-Tick eine frische Nachrichtenliste +([`AgentEngine.cs:89`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L89)). Ein Agent, der alle +30 Minuten läuft, weiß nichts von seinem letzten Lauf: er ruft dieselben Quellen erneut ab, +zieht dieselben Schlüsse und kann keine Entwicklung über Zeit verfolgen. + +Das ist gleichzeitig die größte Fähigkeitslücke **und** eine dauerhafte Token-Verschwendung. +Der `IStateStore` (SQLite) existiert bereits — es fehlt das Konzept darüber. + +### K2 — Prompt-Injection ist unbehandelt +Agenten lesen untrusted Content (Web, Mail, Telegram, X, YouTube-Transkripte) und besitzen +gleichzeitig ausgehende Fähigkeiten (Mail senden, X posten, Dateien schreiben, DB schreiben). +Tool-Ergebnisse werden ununterscheidbar von echten Anweisungen in den Kontext gelegt. + +Das ist die klassische gefährliche Kombination aus Datenzugriff, untrusted Input und +Exfiltrations-Kanal. Ein präparierter Tweet oder eine Mail kann den Agenten steuern. + +**Ansatz:** Tool-Ergebnisse konsequent als Daten rahmen (``), +im System-Prompt verankern, dass daraus keine Anweisungen befolgt werden, und irreversible +Aktionen an eine Freigabe koppeln (siehe F-A1). + +### K3 — Keine Tests +Kein Testprojekt in `ClawdDotNet.slnx`. Bei einer Engine mit Zustandsmaschine, Compaction, +Scheduling und Nebenläufigkeit ist das riskant — die Bugs B1 und B2 wären mit +Unit-Tests sofort aufgefallen. + +**Ansatz:** `ClawdDotNet.Core.Tests` mit einem Fake-`OpenRouterClient` (Interface extrahieren). +Erste Kandidaten: `CronExpression`, `ContextCompactor` (Paarungs-Invarianten!), `LoopGuard`, +`FileRWTool`-Pfadprüfungen. + +### K4 — Kein Streaming +`request.Stream = false` fest verdrahtet ([`OpenRouterClient.cs:34`](../src/ClawdDotNet.Core/Api/OpenRouterClient.cs#L34)). +Bei langen Antworten wirkt die UI eingefroren; es gibt nur den Typing-Indicator. + +### K5 — Kein Kostenlimit +Es gibt kein hartes Budget pro Agent/Instanz/Tag. Ein Agent in einer Schleife (siehe B8) kann +unbeaufsichtigt Guthaben verbrennen. Die Credits-Anzeige ist rein informativ. + +### K6 — Chat-Historie wächst unbegrenzt +`_chatHistories` und die zugehörigen JSON-Dateien werden nie rotiert oder archiviert. + +--- + +## 4. Token-Optimierung + +Das ist der Bereich mit dem größten unmittelbaren Hebel. Nach Wirkung sortiert: + +### T1 — Prompt-Caching einführen (Einsparung: 60–90 % der Input-Tokens) ★★★ + +Der wichtigste Punkt. Aktuell wird bei **jedem** Schritt der gesamte Prompt neu gesendet und +voll berechnet — inklusive System-Prompt (Identity + Soul + SystemPrompt) und aller +Tool-Definitionen. Das ist ein stabiler Präfix, der sich innerhalb eines Runs nie ändert. + +Anthropic-Modelle über OpenRouter unterstützen `cache_control`-Breakpoints: gecachte +Input-Tokens kosten nur ~10 % des Normalpreises (Schreiben in den Cache kostet einmalig ~125 %). + +**Rechenbeispiel** — 10-Schritt-Run, Präfix 15k Tokens (System + 10 Tool-Definitionen): + +| | Input-Tokens (Präfix) | relative Kosten | +|---|---|---| +| heute | 10 × 15.000 = 150.000 | 100 % | +| mit Caching | 15.000 (×1,25) + 9 × 15.000 (×0,1) ≈ 32.250 effektiv | **~21 %** | + +Umsetzung: +1. `ChatMessage.Content` muss das Array-Format unterstützen + (`[{ "type": "text", "text": "...", "cache_control": { "type": "ephemeral" } }]`). + Aktuell ist es ein reiner `string` ([`ChatMessage.cs:12`](../src/ClawdDotNet.Core/Api/Models/ChatMessage.cs#L12)). +2. Cache-Breakpoint ans Ende des System-Prompts und ans Ende der Tool-Definitionen setzen. +3. Optional einen dritten Breakpoint nach der letzten stabilen Konversationsgrenze (rollierend). +4. `Usage` um `prompt_tokens_details.cached_tokens` erweitern, damit der Effekt messbar wird. + +Wichtig: Die Reihenfolge der Nachrichten vor dem Breakpoint muss **byte-identisch** bleiben — +d.h. keine Zeitstempel oder wechselnden Hinweise im System-Prompt. + +### T2 — Tool-Ergebnisse beim Einfügen kappen (Einsparung: stark schwankend, bis 90 %) ★★★ +Siehe B5. Ein einziger ungekappter `WebFetch` kann mehr Tokens erzeugen als der gesamte +restliche Run. Zentral in `ExecuteToolCallAsync` lösen, nicht pro Tool. + +Sinnvolle Defaults: 4.000 Tokens pro Tool-Ergebnis, mit Kürzungshinweis und der Möglichkeit, +gezielt weiterzulesen (Offset-Parameter bei `FileRW.read`, `LIMIT`/`OFFSET` bei `Database.query`). + +### T3 — Günstiges Modell für die Compaction (Einsparung: ~95 % der Compaction-Kosten) ★★ +[`ContextCompactor.cs:114`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L114) nutzt +`model` — also das teure Modell des Agenten — um bis zu 30k Zeichen zusammenzufassen. +Bei Opus kostet eine einzige Compaction so mehr als der halbe Run. + +**Fix:** `summaryModel` konfigurierbar machen, Default z.B. `google/gemini-2.5-flash` oder +`anthropic/claude-haiku-4.5`. Zusammenfassen ist eine anspruchslose Aufgabe. + +### T4 — Proaktiv statt reaktiv kompaktieren ★★ +`CompactIfNeededAsync` läuft **nach** dem API-Call +([`AgentEngine.cs:123`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L123)) und nutzt die +`promptTokens` der gerade bezahlten Anfrage. Der überfüllte Prompt wurde also bereits berechnet. + +**Fix:** Vor dem Senden prüfen (mit `EstimateTokens`, das es schon gibt) und erst dann den +Request bauen. Die gemessenen `promptTokens` dienen zur Kalibrierung der Schätzung. + +### T5 — `maxTokens`-Semantik korrigieren ★★ +Siehe B3. Aktuell führt der Fehler dazu, dass Läufe abbrechen und **neu gestartet** werden — +was die Tokenkosten verdoppelt statt sie zu begrenzen. + +### T6 — Tool-Definitionen verschlanken ★ +Die Beschreibungen sind großzügig: `FileRW` allein hat ~500 Zeichen Description plus ein +Schema mit 11 Properties ≈ 900 Tokens +([`FileRWTool.cs:26`](../src/ClawdDotNet.Tools.FileRW/FileRWTool.cs#L26)). Bei 8 zugewiesenen +Tools sind das schnell 4–6k Tokens — bei **jedem** Schritt (ohne Caching). + +Maßnahmen: +- Beschreibungen auf das Nötige kürzen, Beispiele in den System-Prompt statt ins Schema. +- `stock_add` aus `FileRW` herauslösen — es ist ein fachfremdes Feature, das das Schema aufbläht, + auch für Agenten, die es nie nutzen. +- Für Agenten mit vielen Tools: zweistufiges Laden (`list_toolsets` → `load_toolset`). + +### T7 — `RunAsync` statt `ChatAsync` für Job-Wakeups prüfen ★ +[`ToolJobScheduler.cs:186`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs#L186) +entscheidet über `UseChatContext`. Mit `true` wird die komplette (potenziell riesige) +Chat-Historie in einen "Prüfe neue Mails"-Tick gezogen. Für zustandslose Ticks ist `RunAsync` +um Größenordnungen günstiger — der Default sollte bewusst gesetzt und im UI erklärt sein. + +### T8 — `max_tokens` setzen ★ +Siehe B11. Verhindert Ausreißer bei der Ausgabe (die teuerste Token-Art). + +### T9 — Caching-Wirkung messbar machen ★ +Ohne `cached_tokens` im `Usage`-Modell lässt sich der Erfolg von T1 nicht belegen. Sollte +zusammen mit T1 kommen und im Token-Dashboard auftauchen. + +### Erwartete Gesamtwirkung +T1 + T2 + T3 + T4 zusammen sollten die Kosten eines typischen Multi-Step-Runs auf +**etwa ein Viertel bis ein Fünftel** senken — ohne Qualitätsverlust, da nur Redundanz und +Ballast entfernt werden. + +--- + +## 5. Vorschläge für Erweiterungen + +### Neue Tools + +| Tool | Nutzen | +|---|---| +| **Memory** | Strukturiertes Langzeitgedächtnis (Fakten, Entscheidungen, Beobachtungen mit Zeitstempel) auf dem vorhandenen SQLite-Store. Löst K1 und spart massiv Tokens, weil Wissen nicht neu hergeleitet wird. **Höchste Priorität.** | +| **WebSearch** | Aktuell können Agenten nur bekannte Domains abrufen (`WebFetch`), aber nichts finden. Brave/Tavily/SearXNG-Anbindung. | +| **Http** | Generisches REST-Tool mit Allowlist pro Agent. `DirectAPI` ist fest auf Finanz-Provider verdrahtet — jede neue API erfordert heute Code. | +| **Shell** | Sandboxed, mit Kommando-Allowlist. Ersetzt die hartcodierten yt-dlp/ffmpeg-Aufrufe (und behebt S2 gleich mit). | +| **Git** | Für den "Senior Developer"-Agenten — jetzt, wo das Projekt in Git liegt. | +| **Vision** | Charts/Screenshots analysieren — für das Trading-Team naheliegend. | +| **Notify** | Einheitliche Benachrichtigung (Telegram existiert; ntfy/Discord/Push ergänzen). | + +### Neue Konzepte + +**F-A1 — Freigabe-Workflow (Human-in-the-Loop)** +Irreversible Aktionen (Mail senden, X posten, DB-Schreibzugriff, Datei löschen) landen in einer +Freigabe-Queue im Hauptfenster, statt sofort ausgeführt zu werden. Pro Tool/Aktion +konfigurierbar: `auto` | `approve` | `deny`. Das entschärft K2 und S1/S2 erheblich. + +**F-A2 — Audit-Log** +Jeder Tool-Aufruf mit Agent, Zeitstempel, Argumenten, Ergebnis-Status in eine eigene +SQLite-Tabelle. Aktuell existiert nur `LogDebug` — nach einem Zwischenfall ist nicht +rekonstruierbar, was ein Agent getan hat. + +**F-A3 — Budget-Guard** +Hartes USD-Limit pro Agent und pro Instanz und Tag. Bei Überschreitung wird der Agent pausiert +und im UI markiert. Setzt B4 (korrekte Kostenerfassung) voraus. + +**F-A4 — Run-Historie** +`AgentRunResult` wird aktuell nur im Speicher als "letztes Ergebnis" gehalten +([`AgentScheduler.cs:14`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs#L14)). +Persistierte Runs (mit Schritten, Tokens, Kosten, Fehlern) wären die Grundlage für Diagnose +und Kostenanalyse. + +**F-A5 — Task-Queue statt rekursivem `send_message`** +Agent-zu-Agent-Kommunikation läuft heute als synchrone Rekursion (B8). Eine echte +Aufgaben-Queue mit Zustand (`pending`/`running`/`done`) wäre robuster, nachvollziehbar und +würde Zyklen strukturell verhindern. + +**F-A6 — Secret-Store** +DPAPI-verschlüsselte Secrets (S7), mit UI zum Setzen/Rotieren. + +**F-A7 — Testprojekt** +Siehe K3. + +--- + +## 6. Vorgeschlagene Reihenfolge + +**Sofort — es blockiert oder gefährdet den Betrieb** +1. B1 Compaction-Paarung (bricht produktiv ab) +2. B3 `maxTokens`-Semantik (bricht produktiv ab) +3. B2 Race Condition im Chat-Kontext +4. S2 yt-dlp-Injection +5. S3 API-Key-Leak + +**Kurzfristig — größter Nutzen pro Aufwand** +6. T1 Prompt-Caching +7. T2 Tool-Ergebnisse kappen (= B5) +8. T3 Günstiges Compaction-Modell +9. B4 Kostenerfassung korrigieren +10. B12 Retry/Backoff + +**Mittelfristig — Fundament** +11. S1 DatabaseTool absichern +12. Memory-Tool (K1) +13. F-A1 Freigabe-Workflow + F-A2 Audit-Log +14. K3 Testprojekt +15. S4 PermissionGate ausbauen + +**Danach** +16. Restliche Bugs (B6–B10, B13), S5–S7, weitere Tools, Streaming diff --git a/docs/Teststrategie.md b/docs/Teststrategie.md new file mode 100644 index 0000000..9194dff --- /dev/null +++ b/docs/Teststrategie.md @@ -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 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> _responses = new(); + public List ReceivedRequests { get; } = new(); + + public FakeChatClient RespondsWithText(string text) { … } + public FakeChatClient RespondsWithToolCall(string tool, string argsJson) { … } + public FakeChatClient Throws(Exception ex) { … } + + public Task 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`-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 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 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" ` (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. diff --git a/src/ClawdDotNet.Core/Api/IChatCompletionClient.cs b/src/ClawdDotNet.Core/Api/IChatCompletionClient.cs new file mode 100644 index 0000000..1dd5552 --- /dev/null +++ b/src/ClawdDotNet.Core/Api/IChatCompletionClient.cs @@ -0,0 +1,12 @@ +using ClawdDotNet.Core.Api.Models; + +namespace ClawdDotNet.Core.Api; + +/// +/// Abstraktion über den Chat-Completion-Endpunkt. +/// Erlaubt es, Engine und Compactor ohne echte API-Aufrufe zu testen. +/// +public interface IChatCompletionClient +{ + Task CompleteAsync(ChatRequest request, CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Api/OpenRouterClient.cs b/src/ClawdDotNet.Core/Api/OpenRouterClient.cs index a73389c..2ff682d 100644 --- a/src/ClawdDotNet.Core/Api/OpenRouterClient.cs +++ b/src/ClawdDotNet.Core/Api/OpenRouterClient.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Api; -public sealed class OpenRouterClient : IDisposable +public sealed class OpenRouterClient : IChatCompletionClient, IDisposable { private const string BaseUrl = "https://openrouter.ai/api/v1/"; diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 36437c1..cbf5376 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -12,7 +12,7 @@ namespace ClawdDotNet.Core.Engine; public sealed class AgentEngine : IAgentMessageRouter { - private readonly OpenRouterClient _client; + private readonly IChatCompletionClient _client; private readonly ToolRegistry _toolRegistry; private readonly PermissionGate _permissionGate; private readonly IStateStore _stateStore; @@ -35,7 +35,7 @@ public sealed class AgentEngine : IAgentMessageRouter }; public AgentEngine( - OpenRouterClient client, + IChatCompletionClient client, ToolRegistry toolRegistry, PermissionGate permissionGate, IStateStore stateStore, diff --git a/src/ClawdDotNet.Core/Engine/ContextCompactor.cs b/src/ClawdDotNet.Core/Engine/ContextCompactor.cs index 0735e17..604597c 100644 --- a/src/ClawdDotNet.Core/Engine/ContextCompactor.cs +++ b/src/ClawdDotNet.Core/Engine/ContextCompactor.cs @@ -7,14 +7,14 @@ namespace ClawdDotNet.Core.Engine; public sealed class ContextCompactor { - private readonly OpenRouterClient _client; + private readonly IChatCompletionClient _client; private readonly ILogger _logger; private const int ProtectedTailMessages = 6; private const int MaxToolResultChars = 2000; private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]"; - public ContextCompactor(OpenRouterClient client, ILoggerFactory loggerFactory) + public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory) { _client = client; _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor"); diff --git a/tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj b/tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj new file mode 100644 index 0000000..1735c99 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + ClawdDotNet.Core.Tests + false + true + + + + + + + + + + + + + + + + diff --git a/tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorPropertyTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorPropertyTests.cs new file mode 100644 index 0000000..26e9b8e --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorPropertyTests.cs @@ -0,0 +1,150 @@ +using ClawdDotNet.Core.Api.Models; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Tests.Infrastructure; +using FsCheck; +using FsCheck.Xunit; + +namespace ClawdDotNet.Core.Tests.Engine; + +/// +/// Property-Based Tests: Statt einzelner Beispiele werden hunderte zufällige, +/// aber gültige Konversationen erzeugt und geprüft, dass die Compaction die +/// API-Invarianten in JEDEM Fall erhält. +/// +/// Genau diese Grenzfälle — wo die Tail-Grenze zufällig mitten in einen +/// Tool-Zyklus fällt — schreibt von Hand niemand auf. Bug B1 fällt in diese Klasse. +/// +public sealed class ContextCompactorPropertyTests +{ + private const string Model = "test/model"; + + private static readonly LoopGuardConfig AlwaysCompact = new() + { + MaxContextTokens = 1_000, + CompactionThreshold = 0.5 + }; + + [Property(MaxTest = 300, Arbitrary = [typeof(ConversationArbitrary)])] + public Property Compaction_erhaelt_immer_eine_gueltige_Nachrichtenfolge(ConversationShape shape) + { + var messages = shape.ToMessages(); + + // Vorbedingung: Der Generator erzeugt nur gültige Ausgangsfolgen. + if (!ContextInvariants.IsValid(messages)) + return false.ToProperty().Label("Generator hat eine ungültige Folge erzeugt"); + + var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung des Verlaufs."); + var compactor = new ContextCompactor(client, TestLogging.Factory); + + compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default) + .GetAwaiter().GetResult(); + + var valid = ContextInvariants.IsValid(messages); + + return valid.ToProperty() + .Label($"Nach Compaction ungültig. Ausgangsform: {shape}\n{ContextInvariants.Describe(messages)}"); + } + + [Property(MaxTest = 300, Arbitrary = [typeof(ConversationArbitrary)])] + public Property Compaction_behaelt_den_SystemPrompt_genau_einmal(ConversationShape shape) + { + var messages = shape.ToMessages(); + var hadSystem = messages.Any(m => m.Role == "system"); + + var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung."); + var compactor = new ContextCompactor(client, TestLogging.Factory); + + compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default) + .GetAwaiter().GetResult(); + + var systemCount = messages.Count(m => m.Role == "system"); + var expected = hadSystem ? 1 : 0; + + return (systemCount == expected).ToProperty() + .Label($"system-Nachrichten: erwartet {expected}, gefunden {systemCount}"); + } +} + +// ═══════════════════════════════════════════════════════════════ +// Generator +// ═══════════════════════════════════════════════════════════════ + +/// +/// Beschreibt die Form einer Konversation. FsCheck erzeugt davon zufällige +/// Varianten und schrumpft sie im Fehlerfall auf das minimale Gegenbeispiel. +/// +public sealed record ConversationShape(bool HasSystemPrompt, IReadOnlyList Turns) +{ + public List ToMessages() + { + var c = Conversation.Start(HasSystemPrompt ? "Du bist ein Testagent." : null); + + foreach (var turn in Turns) + { + c.User($"Anfrage mit {turn.ToolCalls} Tool-Aufrufen"); + + if (turn.ToolCalls > 0) + c.ToolCycle(turn.ToolCalls, turn.ResultLength); + + if (turn.EndsWithText) + c.Assistant("Abschließende Antwort des Agenten."); + } + + return c.Build(); + } + + public override string ToString() + => $"system={HasSystemPrompt}, turns=[{string.Join(", ", Turns)}]"; +} + +public sealed record TurnShape(int ToolCalls, int ResultLength, bool EndsWithText) +{ + public override string ToString() => $"{ToolCalls}tc/{ResultLength}b/{(EndsWithText ? "text" : "offen")}"; +} + +public static class ConversationArbitrary +{ + public static Arbitrary Conversations() + { + var turnGen = + from toolCalls in Gen.Choose(0, 4) + from resultLength in Gen.Elements(20, 200, 2_500, 9_000) + from endsWithText in Gen.Elements(true, false) + select new TurnShape(toolCalls, resultLength, endsWithText); + + var gen = + from hasSystem in Gen.Elements(true, false) + from turnCount in Gen.Choose(1, 12) + from turns in Gen.ListOf(turnCount, turnGen) + select new ConversationShape(hasSystem, turns.ToList()); + + return Arb.From(gen, Shrink); + } + + /// Im Fehlerfall auf das kleinste Gegenbeispiel reduzieren. + private static IEnumerable Shrink(ConversationShape shape) + { + // Weniger Turns + for (var i = 0; i < shape.Turns.Count; i++) + { + var reduced = shape.Turns.Where((_, idx) => idx != i).ToList(); + if (reduced.Count > 0) + yield return shape with { Turns = reduced }; + } + + // Weniger Tool-Calls pro Turn + for (var i = 0; i < shape.Turns.Count; i++) + { + var turn = shape.Turns[i]; + if (turn.ToolCalls <= 0) continue; + + var reduced = shape.Turns.ToList(); + reduced[i] = turn with { ToolCalls = turn.ToolCalls - 1 }; + yield return shape with { Turns = reduced }; + } + + if (shape.HasSystemPrompt) + yield return shape with { HasSystemPrompt = false }; + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorTests.cs new file mode 100644 index 0000000..0f20104 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Engine/ContextCompactorTests.cs @@ -0,0 +1,239 @@ +using ClawdDotNet.Core.Api.Models; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Tests.Infrastructure; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Engine; + +/// +/// Regressionstests für Bug B1: Die Compaction behält blind die letzten N Nachrichten. +/// Liegt diese Grenze mitten in einer Tool-Sequenz, entsteht eine tool-Antwort ohne +/// zugehörigen assistant-tool_call — die API lehnt das mit HTTP 400 ab. +/// +public sealed class ContextCompactorTests +{ + private const string Model = "test/model"; + + /// Schwelle so gesetzt, dass Compaction sicher ausgelöst wird. + private static LoopGuardConfig AlwaysCompact => new() + { + MaxContextTokens = 1_000, + CompactionThreshold = 0.5 // Schwelle = 500 Tokens + }; + + private static LoopGuardConfig NeverCompact => new() + { + MaxContextTokens = 10_000_000, + CompactionThreshold = 0.9 + }; + + private static (ContextCompactor Compactor, FakeChatClient Client) CreateCompactor( + string summary = "- Der Agent hat Daten geprüft.\n- Ergebnis war unauffällig.") + { + var client = new FakeChatClient().AlwaysRespondsWithText(summary); + return (new ContextCompactor(client, TestLogging.Factory), client); + } + + // ═══════════════════════════════════════════════════════════ + // C1/C2 — Die eigentliche Bug-Reproduktion + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + public async Task Compaction_erhaelt_gueltige_Sequenz_bei_beliebiger_Toolzyklus_Laenge(int toolCallsPerCycle) + { + // Je nach Anzahl der Tool-Calls fällt die "letzte 6 Nachrichten"-Grenze + // an eine andere Stelle im Zyklus. Mindestens eine Variante trifft mitten hinein. + var (compactor, _) = CreateCompactor(); + var messages = Conversation.Start() + .Repeat(times: 4, toolCallsPerCycle: toolCallsPerCycle, resultLength: 200) + .Build(); + + ContextInvariants.AssertValid(messages); // Vorbedingung: Ausgangslage ist gültig + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + ContextInvariants.AssertValid(messages); + } + + [Fact] + public async Task Compaction_trennt_Toolantwort_nie_von_ihrem_Aufruf() + { + // Gezielt konstruiert: Der Tail von 6 Nachrichten beginnt genau bei einer tool-Antwort. + var (compactor, _) = CreateCompactor(); + var messages = Conversation.Start() + .User("Erste Anfrage") + .ToolCycle(count: 3, resultLength: 300) + .Assistant("Zwischenergebnis") + .User("Zweite Anfrage") + .ToolCycle(count: 3, resultLength: 300) + .Build(); + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + ContextInvariants.AssertValid(messages); + + // Keine verwaiste tool-Antwort + for (var i = 0; i < messages.Count; i++) + { + if (messages[i].Role != "tool") continue; + + var hasParent = messages + .Take(i) + .Any(m => m.ToolCalls?.Any(tc => tc.Id == messages[i].ToolCallId) == true); + + hasParent.ShouldBeTrue( + $"tool-Antwort an Position {i} (id={messages[i].ToolCallId}) hat keinen Aufruf mehr.\n" + + ContextInvariants.Describe(messages)); + } + } + + // ═══════════════════════════════════════════════════════════ + // C3/C4 — System-Prompt + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Compaction_behaelt_den_SystemPrompt_an_Position_0() + { + const string systemPrompt = "Du bist ein sehr spezifischer Testagent."; + var (compactor, _) = CreateCompactor(); + var messages = Conversation.Start(systemPrompt).Repeat(6).Build(); + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + messages[0].Role.ShouldBe("system"); + messages[0].Content.ShouldBe(systemPrompt); + messages.Count(m => m.Role == "system").ShouldBe(1); + } + + [Fact] + public async Task Compaction_erfindet_keinen_SystemPrompt_wenn_keiner_da_war() + { + var (compactor, _) = CreateCompactor(); + var messages = Conversation.Start(systemPrompt: null).Repeat(6).Build(); + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + messages.ShouldNotContain(m => m.Role == "system"); + } + + // ═══════════════════════════════════════════════════════════ + // C5/C6 — Fehlerfälle der Zusammenfassung + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Compaction_laesst_Nachrichten_unveraendert_wenn_die_Zusammenfassung_fehlschlaegt() + { + var client = new FakeChatClient().Throws(new HttpRequestException("API nicht erreichbar")); + var compactor = new ContextCompactor(client, TestLogging.Factory); + var messages = Conversation.Start().Repeat(6).Build(); + var before = messages.Count; + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + ContextInvariants.AssertValid(messages); + messages.Count.ShouldBe(before, "bei fehlgeschlagener Zusammenfassung darf nichts verloren gehen"); + } + + [Fact] + public async Task Compaction_laesst_Nachrichten_unveraendert_wenn_die_Zusammenfassung_leer_ist() + { + var client = new FakeChatClient().AlwaysRespondsWithText(" "); + var compactor = new ContextCompactor(client, TestLogging.Factory); + var messages = Conversation.Start().Repeat(6).Build(); + var before = messages.Count; + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + ContextInvariants.AssertValid(messages); + messages.Count.ShouldBe(before); + } + + // ═══════════════════════════════════════════════════════════ + // C7/C8 — Tool-Result-Pruning + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Pruning_kuerzt_lange_Toolergebnisse_ausserhalb_des_geschuetzten_Endes() + { + var (compactor, _) = CreateCompactor(); + var messages = Conversation.Start() + .Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 8_000) + .Build(); + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + ContextInvariants.AssertValid(messages); + messages.Where(m => m.Role == "tool") + .ShouldAllBe(m => m.Content!.Length <= 8_000, + "kein Tool-Ergebnis darf nach dem Pruning gewachsen sein"); + } + + [Fact] + public async Task Pruning_laesst_kurze_Toolergebnisse_unveraendert() + { + var (compactor, _) = CreateCompactor(); + var messages = Conversation.Start() + .Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 100) + .Build(); + + await compactor.CompactIfNeededAsync(messages, 5_000, AlwaysCompact, Model, default); + + foreach (var content in messages.Where(m => m.Role == "tool").Select(m => m.Content)) + (content ?? "").ShouldNotContain("gekürzt", Case.Insensitive); + } + + // ═══════════════════════════════════════════════════════════ + // C9/C10 — Schätzung und Schwelle + // ═══════════════════════════════════════════════════════════ + + [Fact] + public void EstimateTokens_waechst_monoton_mit_dem_Inhalt() + { + var klein = Conversation.Start().Repeat(1).Build(); + var mittel = Conversation.Start().Repeat(5).Build(); + var gross = Conversation.Start().Repeat(20).Build(); + + var a = ContextCompactor.EstimateTokens(klein); + var b = ContextCompactor.EstimateTokens(mittel); + var c = ContextCompactor.EstimateTokens(gross); + + a.ShouldBeLessThan(b); + b.ShouldBeLessThan(c); + } + + [Fact] + public async Task Unterhalb_der_Schwelle_wird_kein_ApiCall_ausgeloest() + { + // Wichtig fürs Budget: Compaction darf nicht unnötig ein Modell anwerfen. + var (compactor, client) = CreateCompactor(); + var messages = Conversation.Start().Repeat(2).Build(); + + var compacted = await compactor.CompactIfNeededAsync(messages, 10, NeverCompact, Model, default); + + compacted.ShouldBeFalse(); + client.CallCount.ShouldBe(0); + } + + [Fact] + public async Task Compaction_reduziert_die_Nachrichtenzahl_deutlich() + { + var (compactor, _) = CreateCompactor(); + var messages = Conversation.Start().Repeat(15).Build(); + var before = messages.Count; + + var compacted = await compactor.CompactIfNeededAsync(messages, 50_000, AlwaysCompact, Model, default); + + compacted.ShouldBeTrue(); + messages.Count.ShouldBeLessThan(before); + ContextInvariants.AssertValid(messages); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs new file mode 100644 index 0000000..48cddcd --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Engine/LoopGuardTests.cs @@ -0,0 +1,100 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Engine; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Engine; + +public sealed class LoopGuardTests +{ + // ═══════════════════════════════════════════════════════════ + // L1 — Schrittzählung + // ═══════════════════════════════════════════════════════════ + + [Fact] + public void RecordStep_erlaubt_genau_MaxSteps_Schritte() + { + var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = 5 }); + + for (var i = 0; i < 5; i++) + guard.RecordStep(); + + guard.Steps.ShouldBe(5); + Should.Throw(() => guard.RecordStep()); + } + + [Fact] + public void RecordStep_nennt_das_erreichte_Limit_in_der_Meldung() + { + var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = 3 }); + for (var i = 0; i < 3; i++) guard.RecordStep(); + + var ex = Should.Throw(() => guard.RecordStep()); + ex.Message.ShouldContain("3"); + } + + // ═══════════════════════════════════════════════════════════ + // L2 — Die maxTokens-Semantik (Bug B3) + // ═══════════════════════════════════════════════════════════ + + /// + /// Kernproblem B3: RecordTokens summiert die TotalTokens jedes Schritts. + /// Da jeder Schritt den kompletten Kontext erneut sendet, wächst diese Summe + /// quadratisch — ein völlig normaler Chat bricht dadurch nach wenigen Schritten ab. + /// + /// Dieser Test beschreibt das GEWÜNSCHTE Verhalten: Ein Agent mit einem stabilen + /// 20k-Kontext muss 10 Schritte durchhalten können. + /// + /// Er schlägt mit den aktuellen Defaults fehl und wird grün, sobald + /// maxCumulativeTokens (Kostenbudget) und maxContextTokens (Kontextgröße) + /// getrennt sind. + /// + [Fact] + public void Ein_stabiler_Kontext_ueberlebt_zehn_Schritte() + { + var config = new LoopGuardConfig(); // bewusst die Produktiv-Defaults + var guard = new LoopGuard(config); + + Should.NotThrow(() => + { + for (var step = 0; step < 10; step++) + { + guard.RecordStep(); + // Realistisch: 20k Kontext geht rein, ~500 Tokens kommen raus. + guard.RecordTokens(20_000 + 500); + } + }, $"Ein Kontext von 20k über 10 Schritte ist normal und darf nicht am " + + $"Limit maxTokens={config.MaxTokens} scheitern."); + } + + [Fact] + public void RecordTokens_greift_wenn_das_Kostenbudget_wirklich_erschoepft_ist() + { + var guard = new LoopGuard(new LoopGuardConfig { MaxTokens = 1_000 }); + + Should.Throw(() => guard.RecordTokens(1_001)); + } + + // ═══════════════════════════════════════════════════════════ + // L3 — Thread-Sicherheit + // ═══════════════════════════════════════════════════════════ + + [Fact] + public void RecordStep_zaehlt_unter_Parallelzugriff_korrekt() + { + var guard = new LoopGuard(new LoopGuardConfig { MaxSteps = int.MaxValue }); + + Parallel.For(0, 1_000, _ => guard.RecordStep()); + + guard.Steps.ShouldBe(1_000); + } + + [Fact] + public void RecordTokens_summiert_unter_Parallelzugriff_korrekt() + { + var guard = new LoopGuard(new LoopGuardConfig { MaxTokens = int.MaxValue }); + + Parallel.For(0, 1_000, _ => guard.RecordTokens(10)); + + guard.Tokens.ShouldBe(10_000); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/GlobalUsings.cs b/tests/ClawdDotNet.Core.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/ContextInvariants.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/ContextInvariants.cs new file mode 100644 index 0000000..318a062 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/ContextInvariants.cs @@ -0,0 +1,126 @@ +using System.Text; +using ClawdDotNet.Core.Api.Models; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +/// +/// Prüft, ob eine Nachrichtenfolge von der Chat-Completions-API akzeptiert würde. +/// +/// Die Regeln entsprechen dem, was OpenRouter/Anthropic/OpenAI verlangen: +/// Eine tool-Antwort ist nur gültig, wenn ihr eine assistant-Nachricht mit einem +/// passenden tool_call vorausgeht — und jeder tool_call braucht seine Antwort. +/// +/// Wird eine Regel verletzt, antwortet die API mit HTTP 400 und der laufende +/// Agent bricht ab. Genau das ist Bug B1 aus der Bestandsaufnahme. +/// +internal static class ContextInvariants +{ + public static void AssertValid(IReadOnlyList messages) + { + var violations = Validate(messages).ToList(); + if (violations.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine($"Ungültige Nachrichtenfolge ({violations.Count} Verstoß/Verstöße):"); + foreach (var v in violations) + sb.AppendLine($" • {v}"); + sb.AppendLine(); + sb.AppendLine("Sequenz:"); + sb.Append(Describe(messages)); + + throw new ContextInvariantViolationException(sb.ToString()); + } + + public static bool IsValid(IReadOnlyList messages) + => !Validate(messages).Any(); + + private static IEnumerable Validate(IReadOnlyList messages) + { + // ── Regel 1: höchstens eine system-Nachricht, und zwar ganz vorne ── + for (var i = 1; i < messages.Count; i++) + { + if (messages[i].Role == "system") + yield return $"[{i}] system-Nachricht steht nicht an Position 0"; + } + + // ── Regel 2: jede tool-Nachricht braucht eine ToolCallId ── + for (var i = 0; i < messages.Count; i++) + { + if (messages[i].Role == "tool" && string.IsNullOrEmpty(messages[i].ToolCallId)) + yield return $"[{i}] tool-Nachricht ohne tool_call_id"; + } + + // ── Regel 3: jede tool-Nachricht gehört zum unmittelbar vorausgehenden + // assistant-Block mit passender tool_call-Id ── + var openCalls = new HashSet(); + var answered = new HashSet(); + + for (var i = 0; i < messages.Count; i++) + { + var msg = messages[i]; + + if (msg.Role == "assistant" && msg.ToolCalls is { Count: > 0 }) + { + // Vorheriger Block muss vollständig beantwortet sein + var unanswered = openCalls.Except(answered).ToList(); + if (unanswered.Count > 0) + yield return $"[{i}] neuer assistant-Block, aber tool_call(s) noch unbeantwortet: {string.Join(", ", unanswered)}"; + + openCalls.Clear(); + answered.Clear(); + foreach (var tc in msg.ToolCalls) + openCalls.Add(tc.Id); + } + else if (msg.Role == "tool") + { + var id = msg.ToolCallId ?? ""; + if (!openCalls.Contains(id)) + { + yield return $"[{i}] tool-Antwort '{id}' ohne vorausgehenden assistant-tool_call " + + "(die API lehnt das mit HTTP 400 ab)"; + } + else if (!answered.Add(id)) + { + yield return $"[{i}] tool_call '{id}' wurde doppelt beantwortet"; + } + } + else + { + // user/system/assistant-ohne-tool_calls beenden den Block + var unanswered = openCalls.Except(answered).ToList(); + if (unanswered.Count > 0) + yield return $"[{i}] {msg.Role}-Nachricht, aber tool_call(s) unbeantwortet: {string.Join(", ", unanswered)}"; + + openCalls.Clear(); + answered.Clear(); + } + } + + // ── Regel 4: am Ende darf kein tool_call offen sein ── + var stillOpen = openCalls.Except(answered).ToList(); + if (stillOpen.Count > 0) + yield return $"[Ende] unbeantwortete tool_call(s): {string.Join(", ", stillOpen)}"; + } + + /// Kompakte, lesbare Darstellung für Fehlermeldungen. + public static string Describe(IReadOnlyList messages) + { + var sb = new StringBuilder(); + for (var i = 0; i < messages.Count; i++) + { + var m = messages[i]; + var detail = m.Role switch + { + "tool" => $"tool_call_id={m.ToolCallId}, len={m.Content?.Length ?? 0}", + "assistant" when m.ToolCalls is { Count: > 0 } + => $"tool_calls=[{string.Join(", ", m.ToolCalls.Select(t => $"{t.Function.Name}#{t.Id}"))}]", + _ => $"len={m.Content?.Length ?? 0}" + }; + sb.AppendLine($" [{i,2}] {m.Role,-9} {detail}"); + } + return sb.ToString(); + } +} + +internal sealed class ContextInvariantViolationException(string message) : Exception(message); diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/Conversation.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/Conversation.cs new file mode 100644 index 0000000..8efee74 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/Conversation.cs @@ -0,0 +1,68 @@ +using ClawdDotNet.Core.Api.Models; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +/// +/// Baut gültige Nachrichtenfolgen für Tests — lesbar und garantiert API-konform. +/// +internal sealed class Conversation +{ + private readonly List _messages = new(); + private int _callCounter; + + public static Conversation Start(string? systemPrompt = "Du bist ein Testagent.") + { + var c = new Conversation(); + if (!string.IsNullOrEmpty(systemPrompt)) + c._messages.Add(ChatMessage.System(systemPrompt)); + return c; + } + + public Conversation User(string text) + { + _messages.Add(ChatMessage.User(text)); + return this; + } + + public Conversation Assistant(string text) + { + _messages.Add(ChatMessage.Assistant(text)); + return this; + } + + /// + /// Fügt einen vollständigen Tool-Zyklus hinzu: assistant mit N tool_calls, + /// gefolgt von genau N passenden tool-Antworten. + /// + public Conversation ToolCycle(int count = 1, int resultLength = 50, string toolName = "TestTool") + { + var calls = new List(); + for (var i = 0; i < count; i++) + { + calls.Add(new ToolCall + { + Id = $"call_{++_callCounter}", + Type = "function", + Function = new ToolCallFunction { Name = toolName, Arguments = """{"action":"test"}""" } + }); + } + + _messages.Add(ChatMessage.AssistantWithToolCalls(calls)); + foreach (var call in calls) + _messages.Add(ChatMessage.ToolResponse(call.Id, new string('x', resultLength))); + + return this; + } + + /// Wiederholt ein Muster aus User-Nachricht und Tool-Zyklus. + public Conversation Repeat(int times, int toolCallsPerCycle = 1, int resultLength = 50) + { + for (var i = 0; i < times; i++) + User($"Anfrage {i}").ToolCycle(toolCallsPerCycle, resultLength).Assistant($"Antwort {i}"); + return this; + } + + public List Build() => _messages; + + public static implicit operator List(Conversation c) => c._messages; +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs new file mode 100644 index 0000000..62ee79e --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using ClawdDotNet.Core.Api; +using ClawdDotNet.Core.Api.Models; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +/// +/// Ersetzt den OpenRouterClient in Tests. Liefert eine vorprogrammierte Antwortfolge +/// und schreibt jeden empfangenen Request mit. +/// +/// Wichtig: Requests werden tief kopiert. Die Engine reicht dieselbe List<ChatMessage> +/// weiter und verändert sie danach — ohne Kopie würden Tests den Endzustand prüfen +/// statt dessen, was tatsächlich gesendet wurde. +/// +internal sealed class FakeChatClient : IChatCompletionClient +{ + private readonly Queue> _responses = new(); + private Func? _fallback; + + /// Alle empfangenen Requests, als tiefe Kopien. + public List ReceivedRequests { get; } = new(); + + public int CallCount => ReceivedRequests.Count; + + // ─── Programmierung der Antworten ─── + + public FakeChatClient RespondsWithText(string text, Usage? usage = null) + { + _responses.Enqueue(_ => TextResponse(text, usage)); + return this; + } + + public FakeChatClient RespondsWithToolCall(string toolName, string argumentsJson = "{}", string? id = null) + => RespondsWithToolCalls((toolName, argumentsJson, id)); + + public FakeChatClient RespondsWithToolCalls(params (string Tool, string Args, string? Id)[] calls) + { + var toolCalls = calls.Select((c, i) => new ToolCall + { + Id = c.Id ?? $"call_{Guid.NewGuid():N}"[..12], + Type = "function", + Function = new ToolCallFunction { Name =c.Tool, Arguments = c.Args } + }).ToList(); + + _responses.Enqueue(_ => new ChatResponse + { + Id = "resp_" + ReceivedRequests.Count, + Choices = [new Choice { Index = 0, Message = new ChatMessage { Role = "assistant", ToolCalls = toolCalls } }], + Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 } + }); + return this; + } + + /// Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen. + public FakeChatClient RespondsWithTokens(int promptTokens, string text = "fertig") + { + _responses.Enqueue(_ => TextResponse(text, new Usage + { + PromptTokens = promptTokens, + CompletionTokens = 10, + TotalTokens = promptTokens + 10 + })); + return this; + } + + public FakeChatClient Throws(Exception ex) + { + _responses.Enqueue(_ => throw ex); + return this; + } + + /// Antwort für alle Aufrufe, die über die programmierte Folge hinausgehen. + public FakeChatClient AlwaysRespondsWithText(string text) + { + _fallback = _ => TextResponse(text); + return this; + } + + // ─── IChatCompletionClient ─── + + public Task CompleteAsync(ChatRequest request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + ReceivedRequests.Add(DeepClone(request)); + + if (_responses.Count > 0) + return Task.FromResult(_responses.Dequeue()(request)); + + if (_fallback is not null) + return Task.FromResult(_fallback(request)); + + throw new InvalidOperationException( + $"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " + + "keine Antwort mehr programmiert."); + } + + // ─── Helfer ─── + + private static ChatResponse TextResponse(string text, Usage? usage = null) => new() + { + Id = "resp", + Choices = [new Choice { Index = 0, Message = new ChatMessage { Role = "assistant", Content = text }, FinishReason = "stop" }], + Usage = usage ?? new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 } + }; + + private static ChatRequest DeepClone(ChatRequest request) => new() + { + Model = request.Model, + Stream = request.Stream, + Temperature = request.Temperature, + MaxTokens = request.MaxTokens, + ToolChoice = request.ToolChoice, + Tools = request.Tools?.ToList(), + Messages = request.Messages.Select(CloneMessage).ToList() + }; + + private static ChatMessage CloneMessage(ChatMessage m) => new() + { + Role = m.Role, + Content = m.Content, + ToolCallId = m.ToolCallId, + ToolCalls = m.ToolCalls?.Select(tc => new ToolCall + { + Id = tc.Id, + Type = tc.Type, + Function = new ToolCallFunction { Name =tc.Function.Name, Arguments = tc.Function.Arguments } + }).ToList() + }; +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/TestLogging.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/TestLogging.cs new file mode 100644 index 0000000..a030801 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/TestLogging.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +internal static class TestLogging +{ + public static ILoggerFactory Factory { get; } = NullLoggerFactory.Instance; +}