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;
+}