Testfundament aufbauen und Bestandsaufnahme dokumentieren
IChatCompletionClient aus OpenRouterClient extrahiert, damit AgentEngine und ContextCompactor ohne echte API-Aufrufe testbar sind. Neues Testprojekt tests/ClawdDotNet.Core.Tests (xUnit, Shouldly, NSubstitute, FsCheck) mit: - FakeChatClient (programmierbare Antwortfolgen, Deep-Copy der Requests) - ContextInvariants (prueft die API-Regeln fuer tool_call-Paarung) - Conversation-Builder fuer gueltige Testkonversationen - 26 Tests: Compaction, LoopGuard, 2 Property-Tests 10 Tests sind bewusst rot — sie reproduzieren die Bugs B1, B3 und B14 aus der Bestandsaufnahme und werden mit den Fixes gruen. Ausserdem: fehlende Tool-Projekte in slnx ergaenzt, Test-Pakete im packageSourceMapping der NuGet.Config eingetragen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
92e50d3ac4
commit
667cecce25
@@ -10,6 +10,13 @@
|
|||||||
<Project Path="src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj" />
|
<Project Path="src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj" />
|
||||||
<Project Path="src/ClawdDotNet.Tools.WebMonitor/ClawdDotNet.Tools.WebMonitor.csproj" />
|
<Project Path="src/ClawdDotNet.Tools.WebMonitor/ClawdDotNet.Tools.WebMonitor.csproj" />
|
||||||
<Project Path="src/ClawdDotNet.Tools.TelegramClient/ClawdDotNet.Tools.TelegramClient.csproj" />
|
<Project Path="src/ClawdDotNet.Tools.TelegramClient/ClawdDotNet.Tools.TelegramClient.csproj" />
|
||||||
|
<Project Path="src/ClawdDotNet.Tools.AgentComm/ClawdDotNet.Tools.AgentComm.csproj" />
|
||||||
|
<Project Path="src/ClawdDotNet.Tools.AgentSpawn/ClawdDotNet.Tools.AgentSpawn.csproj" />
|
||||||
|
<Project Path="src/ClawdDotNet.Tools.AgentEditor/ClawdDotNet.Tools.AgentEditor.csproj" />
|
||||||
|
<Project Path="src/ClawdDotNet.Tools.SocialMediaManager/ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Project Path="ClawdDotNet.csproj" />
|
<Project Path="ClawdDotNet.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -26,6 +26,17 @@
|
|||||||
<package pattern="FluentFTP" />
|
<package pattern="FluentFTP" />
|
||||||
<package pattern="SQLitePCLRaw.*" />
|
<package pattern="SQLitePCLRaw.*" />
|
||||||
<package pattern="WTelegramClient" />
|
<package pattern="WTelegramClient" />
|
||||||
|
<!-- Test-Pakete -->
|
||||||
|
<package pattern="xunit" />
|
||||||
|
<package pattern="xunit.*" />
|
||||||
|
<package pattern="Shouldly" />
|
||||||
|
<package pattern="NSubstitute" />
|
||||||
|
<package pattern="Castle.Core" />
|
||||||
|
<package pattern="FsCheck" />
|
||||||
|
<package pattern="FsCheck.*" />
|
||||||
|
<package pattern="FSharp.Core" />
|
||||||
|
<package pattern="DiffEngine" />
|
||||||
|
<package pattern="EmptyFiles" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
</packageSourceMapping>
|
</packageSourceMapping>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -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<ChatMessage>`. 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<T>`-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 (`<untrusted_content source="...">`),
|
||||||
|
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
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
# ClawdDotNet — Teststrategie
|
||||||
|
|
||||||
|
Ergänzung zur [Bestandsaufnahme](Bestandsaufnahme-2026-07.md).
|
||||||
|
Ziel: Fehlerklassen wie B1 (Compaction zerstört tool_call-Paarung) und B2 (Race Condition)
|
||||||
|
strukturell unmöglich machen, statt sie im Betrieb zu entdecken.
|
||||||
|
|
||||||
|
**Leitgedanke:** Nicht Coverage maximieren, sondern die *teuren* Fehler abfangen. In diesem
|
||||||
|
Projekt sind das drei Kategorien:
|
||||||
|
1. **Kontext-Invarianten** — ungültige Nachrichtensequenzen kosten API-Fehler mitten im Lauf
|
||||||
|
2. **Sicherheitsgrenzen** — Pfad-, SQL-, URL- und Empfänger-Prüfungen
|
||||||
|
3. **Nebenläufigkeit & Zeit** — Scheduler, paralleler Chat-Zugriff, Cron
|
||||||
|
|
||||||
|
UI-Layout, Designer-Code und Fremdbibliotheken werden bewusst *nicht* getestet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Voraussetzung: Testbarkeit herstellen
|
||||||
|
|
||||||
|
Vier kleine Refactorings, ohne die die wichtigsten Tests nicht schreibbar sind.
|
||||||
|
|
||||||
|
### R1 — `IChatCompletionClient` extrahieren ⚠️ blockiert alles Weitere
|
||||||
|
[`AgentEngine.cs:15`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L15) und
|
||||||
|
[`ContextCompactor.cs:10`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L10) hängen an der
|
||||||
|
konkreten, `sealed`-Klasse `OpenRouterClient`. Ohne Interface ist kein einziger Engine-Test möglich.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IChatCompletionClient
|
||||||
|
{
|
||||||
|
Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpenRouterClient : IChatCompletionClient, IDisposable { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
Dazu ein `FakeChatClient` im Testprojekt, der eine vorprogrammierte Antwortfolge liefert und
|
||||||
|
alle empfangenen Requests mitschreibt:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed class FakeChatClient : IChatCompletionClient
|
||||||
|
{
|
||||||
|
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
|
||||||
|
public List<ChatRequest> ReceivedRequests { get; } = new();
|
||||||
|
|
||||||
|
public FakeChatClient RespondsWithText(string text) { … }
|
||||||
|
public FakeChatClient RespondsWithToolCall(string tool, string argsJson) { … }
|
||||||
|
public FakeChatClient Throws(Exception ex) { … }
|
||||||
|
|
||||||
|
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Tiefe Kopie ablegen — die Engine mutiert die Nachrichtenliste weiter!
|
||||||
|
ReceivedRequests.Add(DeepClone(request));
|
||||||
|
…
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Die tiefe Kopie ist wesentlich: `AgentEngine` reicht dieselbe `List<ChatMessage>`-Instanz
|
||||||
|
> weiter und verändert sie danach. Ohne Kopie prüfen die Tests den Endzustand statt des
|
||||||
|
> tatsächlich gesendeten Requests.
|
||||||
|
|
||||||
|
### R2 — `TimeProvider` statt `DateTime.Now`
|
||||||
|
.NET 10 bringt `TimeProvider` mit; `FakeTimeProvider` steckt in
|
||||||
|
`Microsoft.Extensions.TimeProvider.Testing`. Betrifft
|
||||||
|
[`CronExpression.cs:53`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs#L53),
|
||||||
|
[`AgentScheduler.cs:75`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs#L75),
|
||||||
|
[`ToolJobScheduler.cs:111`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs#L111).
|
||||||
|
|
||||||
|
Damit werden Zeitumstellungs- und Langzeit-Tests deterministisch und laufen in Millisekunden
|
||||||
|
statt in Echtzeit.
|
||||||
|
|
||||||
|
### R3 — `IProcessRunner` für externe Prozesse
|
||||||
|
[`SocialMediaManagerTool.cs:442`](../src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs#L442)
|
||||||
|
startet yt-dlp/ffmpeg direkt. Für den Injection-Test (S2) muss die **Argumentbildung** ohne
|
||||||
|
Prozessstart prüfbar sein:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static string[] BuildYtDlpArgs(string url, …); // rein, testbar
|
||||||
|
public interface IProcessRunner { Task<ProcessResult> RunAsync(string exe, string[] args, CancellationToken ct); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### R4 — Dateisystem **nicht** abstrahieren
|
||||||
|
Bewusste Entscheidung: Die Path-Traversal-Tests sollen echte Windows-Pfadsemantik prüfen
|
||||||
|
(`..`, UNC, Alternate Data Streams, Trailing Dots). Eine `IFileSystem`-Abstraktion würde genau
|
||||||
|
die Fehlerklasse verstecken, um die es geht. Stattdessen ein Temp-Verzeichnis-Fixture:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed class TempWorkspace : IDisposable
|
||||||
|
{
|
||||||
|
public string Personal { get; }
|
||||||
|
public string Shared { get; }
|
||||||
|
public TempWorkspace() { /* unter Path.GetTempPath()/clawd-tests/{guid} anlegen */ }
|
||||||
|
public void Dispose() { /* rekursiv löschen, Fehler schlucken */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Projektstruktur & Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
ClawdDotNet.Core.Tests/ ← Engine, Compaction, LoopGuard, Cron, Config, State
|
||||||
|
ClawdDotNet.Tools.Tests/ ← alle Tool-Projekte, v.a. Sicherheitsgrenzen
|
||||||
|
ClawdDotNet.Integration.Tests/ ← echte Endpunkte, standardmäßig übersprungen
|
||||||
|
```
|
||||||
|
|
||||||
|
| Paket | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `xunit.v3` | Test-Framework |
|
||||||
|
| `Shouldly` | Assertions — lesbar, frei nutzbar. *(FluentAssertions ist ab v8 für kommerzielle Nutzung kostenpflichtig; falls gewünscht ist `AwesomeAssertions` ein freier Fork von v7.)* |
|
||||||
|
| `NSubstitute` | Mocks für `ILogger`, `IStateStore`, `IAgentTool` |
|
||||||
|
| `Microsoft.Extensions.TimeProvider.Testing` | `FakeTimeProvider` |
|
||||||
|
| `FsCheck.Xunit` | Property-Based Tests für die Kontext-Invarianten |
|
||||||
|
| `Verify.Xunit` | Snapshot-Tests des Request-JSON |
|
||||||
|
|
||||||
|
Die drei Projekte in `ClawdDotNet.slnx` unter einem Ordner `/tests/` eintragen.
|
||||||
|
|
||||||
|
> Nebenbefund: In `ClawdDotNet.slnx` fehlen vier Tool-Projekte (AgentComm, AgentSpawn,
|
||||||
|
> AgentEditor, SocialMediaManager), obwohl sie in `ClawdDotNet.csproj` referenziert sind.
|
||||||
|
> Sie sollten mit aufgenommen werden, sonst laufen sie in der IDE-Solution nicht mit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Die Tests
|
||||||
|
|
||||||
|
### Prio 1 — Regressionstests für die gefundenen Bugs
|
||||||
|
|
||||||
|
Vorgehen: **erst den roten Test, dann den Fix.** Diese Tests sind der eigentliche Beleg, dass
|
||||||
|
die Bugs behoben sind — und sie bleiben als Wächter erhalten.
|
||||||
|
|
||||||
|
#### 3.1 ContextCompactor — Kontext-Invarianten (deckt B1 ab)
|
||||||
|
|
||||||
|
Das Herzstück. Ich würde eine wiederverwendbare Prüffunktion bauen und sie überall einsetzen:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static class ContextInvariants
|
||||||
|
{
|
||||||
|
/// Wirft, wenn die Nachrichtenfolge von der API abgelehnt würde.
|
||||||
|
public static void AssertValid(IReadOnlyList<ChatMessage> messages)
|
||||||
|
{
|
||||||
|
// 1. Höchstens eine system-Nachricht, und zwar an Position 0
|
||||||
|
// 2. Jede tool-Nachricht hat eine unmittelbar vorausgehende Gruppe,
|
||||||
|
// deren assistant-Nachricht ein tool_call mit passender Id enthält
|
||||||
|
// 3. Jede assistant-Nachricht mit N tool_calls hat genau N tool-Antworten
|
||||||
|
// 4. Keine tool-Nachricht ohne ToolCallId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 | Compaction bei Tail-Grenze mitten in einer Tool-Sequenz | `AssertValid` besteht — **schlägt heute fehl** |
|
||||||
|
| C2 | Compaction bei `assistant(2 tool_calls) → tool → tool` an der Grenze | Beide tool-Antworten bleiben zusammen mit ihrem Aufruf |
|
||||||
|
| C3 | System-Prompt vorhanden | Bleibt an Position 0 erhalten |
|
||||||
|
| C4 | Kein System-Prompt vorhanden | Kein leerer/erfundener System-Eintrag |
|
||||||
|
| C5 | LLM-Zusammenfassung wirft Exception | Nachrichtenliste bleibt **unverändert und gültig** (kein halb geleerter Zustand) |
|
||||||
|
| C6 | LLM liefert leeren String zurück | dito |
|
||||||
|
| C7 | `PruneToolResults` | Kürzt nur außerhalb der letzten 6 Nachrichten |
|
||||||
|
| C8 | `PruneToolResults` bei Inhalt ≤ 2000 Zeichen | Bleibt unverändert (kein Marker angehängt) |
|
||||||
|
| C9 | `EstimateTokens` | Monoton: mehr Inhalt ⇒ nie weniger Tokens |
|
||||||
|
| C10 | Compaction unterhalb der Schwelle | Kein API-Call an den Fake (spart Geld!) |
|
||||||
|
|
||||||
|
**Property-Based (FsCheck) — der wertvollste Test des ganzen Projekts:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Property(MaxTest = 500)]
|
||||||
|
public Property Compaction_erhaelt_immer_eine_gueltige_Sequenz()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(ValidConversationGenerator(), // 1..200 gültige Nachrichten
|
||||||
|
async conversation =>
|
||||||
|
{
|
||||||
|
await _compactor.CompactIfNeededAsync(conversation, 999_999, _guard, "m", default);
|
||||||
|
ContextInvariants.AssertValid(conversation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Generator für gültige Konversationen (System, dann abwechselnd User/Assistant, gelegentlich
|
||||||
|
Assistant-mit-N-Tool-Calls gefolgt von genau N Tool-Antworten) findet Grenzfälle, die von Hand
|
||||||
|
niemand schreibt — genau die Klasse, in die B1 fällt.
|
||||||
|
|
||||||
|
#### 3.2 LoopGuard (deckt B3 ab)
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| L1 | `MaxSteps` erreicht | Wirft bei Schritt N+1, nicht bei N |
|
||||||
|
| L2 | **Konstanter 20k-Kontext über 10 Schritte** | Bricht **nicht** ab — definiert die Zielsemantik nach dem Fix |
|
||||||
|
| L3 | Kostenbudget überschritten | Wirft mit klarer Meldung, welches Limit griff |
|
||||||
|
| L4 | 100 parallele `RecordStep` | Zähler exakt 100 (Interlocked-Korrektheit) |
|
||||||
|
|
||||||
|
L2 ist der Test, der den heutigen Fehlentwurf festnagelt: Er schlägt mit den aktuellen Defaults
|
||||||
|
fehl und geht nach der Trennung von `maxCumulativeTokens` und `maxContextTokens` durch.
|
||||||
|
|
||||||
|
#### 3.3 AgentEngine — Nebenläufigkeit (deckt B2 ab)
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| E1 | Zwei parallele `ChatAsync` für **denselben** Agenten | Kontext danach `AssertValid`; **schlägt heute fehl** |
|
||||||
|
| E2 | 50 parallele `ChatAsync`, je 2 Tool-Schritte | Keine Exception, Nachrichtenzahl deterministisch |
|
||||||
|
| E3 | `AbortChat` bei zwei laufenden Läufen | **Beide** werden abgebrochen |
|
||||||
|
| E4 | Parallele Läufe für **verschiedene** Agenten | Laufen wirklich parallel (keine globale Serialisierung) |
|
||||||
|
|
||||||
|
E4 ist wichtig als Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
|
||||||
|
gegeneinander sperren.
|
||||||
|
|
||||||
|
#### 3.4 CronExpression & Scheduler (deckt B6, B7 ab)
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | Tabelle: `*/30 * * * *`, `0 3 * * 1-5`, `0 0 29 2 *`, `15,45 * * * *` | Korrekte nächste Zeitpunkte |
|
||||||
|
| R2 | Invariante über viele Ausdrücke | `GetNextOccurrence(t) > t` **und** `Matches(ergebnis)` |
|
||||||
|
| R3 | Ungültig: `"* * *"`, `"99 * * * *"`, `"a b c d e"`, `"*/0 * * * *"` | Saubere `FormatException` — heute wirft teils `int.Parse`/`DivideByZero` |
|
||||||
|
| R4 | Zeitumstellung Oktober, `0 2 * * *` | Feuert genau **einmal** |
|
||||||
|
| R5 | Zeitumstellung März, `0 2 * * *` | Feuert trotzdem (wird nicht übersprungen) |
|
||||||
|
| R6 | `0 3 1 1 *` (jährlich) | Scheduler überlebt Wartezeit > 24 Tage — **schlägt heute fehl** |
|
||||||
|
| R7 | Lauf dauert länger als das Intervall | Keine Überlappung, keine Aufstauung |
|
||||||
|
| R8 | Exception in einem Job-Tick | Scheduler-Schleife läuft weiter, Fehler wird geloggt |
|
||||||
|
|
||||||
|
#### 3.5 Kostenerfassung (deckt B4 ab)
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| K1 | Modell nicht in der Preistabelle | Sichtbar markiert (nicht stillschweigend 0) |
|
||||||
|
| K2 | `anthropic/claude-sonnet-4-5` (der Default!) | Liefert einen Preis ≠ 0 |
|
||||||
|
| K3 | 95k Prompt / 5k Completion | Exakter Betrag gegen handgerechneten Wert — kein 50/50-Split |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prio 2 — Sicherheitstests
|
||||||
|
|
||||||
|
Diese Tests sind gleichzeitig die dauerhafte Dokumentation der Bedrohungsmodelle. Jeder
|
||||||
|
Angriffsvektor aus der Bestandsaufnahme wird ein `[Theory]`-Fall.
|
||||||
|
|
||||||
|
#### 3.6 FileRW — Pfadsicherheit (deckt S6 ab)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Theory]
|
||||||
|
[InlineData("../../../Windows/System32/drivers/etc/hosts")]
|
||||||
|
[InlineData(@"..\..\evil.txt")]
|
||||||
|
[InlineData(@"C:\Windows\System32\config\SAM")]
|
||||||
|
[InlineData(@"\\server\share\evil.txt")]
|
||||||
|
[InlineData("subdir/../../../outside.txt")]
|
||||||
|
[InlineData("file.txt:hidden")] // Alternate Data Stream
|
||||||
|
[InlineData("file.txt.")] // Windows entfernt den Punkt
|
||||||
|
[InlineData(" .. /../evil.txt")]
|
||||||
|
public async Task Read_lehnt_Pfade_ausserhalb_des_Workspace_ab(string path) { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus der Präfix-Angriff, der heute durchgeht:
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | Root `…\Workspace`, Ziel `…\Workspace-Backup\x.txt` | Abgelehnt — **schlägt heute fehl** |
|
||||||
|
| F2 | Endungs-Whitelist: `.exe`, `.ps1`, `.dll` | Abgelehnt |
|
||||||
|
| F3 | `.TXT` bei Whitelist `.txt` | Erlaubt (Groß/Kleinschreibung) |
|
||||||
|
| F4 | `report.txt.exe` | Abgelehnt |
|
||||||
|
| F5 | `copy` mit `destinationPath` außerhalb des Ziel-Workspace | Abgelehnt (eigener Pfad, eigener Test!) |
|
||||||
|
| F6 | `sharedAccessLevel: Denied` | Jede shared-Aktion abgelehnt |
|
||||||
|
| F7 | `ReadWrite` + `delete` in shared | Abgelehnt (nur Admin) |
|
||||||
|
| F8 | protectedPath `stocks/`, Schreiben auf existierende `stocks/x.json` | Abgelehnt |
|
||||||
|
| F9 | protectedPath `stocks/`, Schreiben auf **neue** `stocks/y.json` | Erlaubt |
|
||||||
|
| F10 | protectedPath `stocks/`, Pfad `stocks-alt/x.json` | **Nicht** geschützt (Präfix-Verwechslung) |
|
||||||
|
| F11 | Admin-Level + protectedPath | Umgeht den Schutz |
|
||||||
|
| F12 | 20 parallele `stock_add` auf denselben Ticker | `_index.json` enthält exakt 20 Einträge, gültiges JSON |
|
||||||
|
|
||||||
|
F12 deckt B9 ab und ist ein echter Nebenläufigkeitstest gegen das Dateisystem.
|
||||||
|
|
||||||
|
#### 3.7 DatabaseTool — SQL-Whitelist (deckt S1 ab)
|
||||||
|
|
||||||
|
Die Bypass-Tests. **Alle schlagen heute fehl** und definieren die Zielsemantik:
|
||||||
|
|
||||||
|
| # | SQL | AccessLevel | Erwartung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | `DELETE FROM users -- prices` | ReadWrite | Abgelehnt |
|
||||||
|
| D2 | `SELECT * FROM users WHERE note='prices'` | ReadOnly | Abgelehnt |
|
||||||
|
| D3 | `SELECT * FROM prices; DROP TABLE users` | Admin | Abgelehnt (Mehrfach-Statement) |
|
||||||
|
| D4 | `SELECT * FROM prices` | ReadOnly | **Erlaubt** |
|
||||||
|
| D5 | `SELECT * FROM prices WHERE note='update'` | ReadOnly | **Erlaubt** (heutiger False Positive) |
|
||||||
|
| D6 | `UPDATE prices SET x=1` | ReadOnly | Abgelehnt |
|
||||||
|
| D7 | `UPDATE prices SET x=1` | ReadWrite | Erlaubt |
|
||||||
|
| D8 | `DROP TABLE prices` | ReadWrite | Abgelehnt |
|
||||||
|
| D9 | `allowedTables` fehlt komplett | egal | Abgelehnt (fail closed) |
|
||||||
|
| D10 | `SELECT` mit 10.000 Zeilen Ergebnis | ReadOnly | Ergebnis wird gekappt |
|
||||||
|
|
||||||
|
D4/D5/D7 sind die Gegenproben — sie verhindern, dass der Fix zu restriktiv wird und legitime
|
||||||
|
Abfragen blockiert.
|
||||||
|
|
||||||
|
#### 3.8 WebFetch — SSRF (deckt S5 ab)
|
||||||
|
|
||||||
|
Mit einem `FakeHttpMessageHandler`, der Redirects simuliert:
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| W1 | Erlaubte Domain → Redirect auf nicht erlaubte | Abgelehnt — **schlägt heute fehl** |
|
||||||
|
| W2 | `http://169.254.169.254/latest/meta-data/` | Abgelehnt |
|
||||||
|
| W3 | `http://localhost:8418/` | Abgelehnt (privates Netz) |
|
||||||
|
| W4 | `http://192.168.178.10/` | Abgelehnt |
|
||||||
|
| W5 | `file:///C:/Windows/win.ini` | Abgelehnt |
|
||||||
|
| W6 | Whitelist `example.com`, URL `example.com.attacker.net` | Abgelehnt |
|
||||||
|
| W7 | Whitelist `example.com`, URL `sub.example.com` | Erlaubt |
|
||||||
|
| W8 | Host `mywww.example.com` | Wird nicht zu `myexample.com` verstümmelt |
|
||||||
|
| W9 | Antwort größer als `maxResponseKb` | Sauber gekappt, kein OOM |
|
||||||
|
|
||||||
|
#### 3.9 Mail — Empfängerprüfung
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | Nicht gelistete Adresse | Abgelehnt |
|
||||||
|
| M2 | `ALLOWED@X.DE` bei Whitelist `allowed@x.de` | Erlaubt |
|
||||||
|
| M3 | `allowed@x.de, evil@y.de` (zwei in einem Feld) | Abgelehnt |
|
||||||
|
| M4 | `"allowed@x.de" <evil@y.de>` (Display-Name-Trick) | Abgelehnt |
|
||||||
|
| M5 | `allowedRecipients` fehlt | Abgelehnt (fail closed) |
|
||||||
|
| M6 | `read_inbox` mit 500 Mails | Ergebnis gekappt |
|
||||||
|
|
||||||
|
#### 3.10 SocialMediaManager — Argument-Injection (deckt S2 ab)
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| Y1 | `BuildYtDlpArgs("--exec calc.exe https://youtube.com/x")` | Wirft oder quotet — nie als eigene Option |
|
||||||
|
| Y2 | URL `https://evil.com/x` | Abgelehnt (nur YouTube-Domains) |
|
||||||
|
| Y3 | URL mit Leerzeichen/Anführungszeichen | Erscheint als **ein** Argument in `ArgumentList` |
|
||||||
|
| Y4 | Legitime URL/Handle (`@kanal`, volle URL) | Wird korrekt aufgelöst |
|
||||||
|
|
||||||
|
#### 3.11 DirectAPI — Key-Leak (deckt S3 ab)
|
||||||
|
|
||||||
|
Ein einzelner, sehr einfacher Test mit hohem Wert:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Ergebnis_enthaelt_niemals_den_ApiKey()
|
||||||
|
{
|
||||||
|
const string key = "SECRET-KEY-12345";
|
||||||
|
var result = await _tool.ExecuteAsync(QuoteFor("NVDA"), ContextWithKey(key), default);
|
||||||
|
result.Content.ShouldNotContain(key); // schlägt heute fehl
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Analog für alle Provider (`twelvedata`, `alphavantage`, `massive`) und alle Aktionen — als
|
||||||
|
`[Theory]`. Dieselbe Prüfung sollte es zusätzlich für die Log-Ausgabe geben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prio 3 — Engine-Verhalten (mit `FakeChatClient`)
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 | Antwort ohne tool_calls | Genau 1 API-Call, `FinalMessage` korrekt, Status `Completed` |
|
||||||
|
| A2 | Antwort mit 2 tool_calls, dann Text | 2 Tools ausgeführt, 2 tool-Nachrichten mit passenden Ids, dann 2. Call |
|
||||||
|
| A3 | Tool wirft Exception | Lauf läuft weiter, Fehler kommt als tool-Antwort ins Modell |
|
||||||
|
| A4 | Modell ruft nicht zugewiesenes Tool auf | `PermissionGate` greift, Fehlermeldung als tool-Antwort, kein Absturz |
|
||||||
|
| A5 | Modell ruft unbekanntes Tool auf | Saubere Fehlermeldung |
|
||||||
|
| A6 | Timeout (`FakeTimeProvider`) | Status `Cancelled` |
|
||||||
|
| A7 | Externe Cancellation | Status `Cancelled` |
|
||||||
|
| A8 | Loop-Limit erreicht | Status `LoopLimitExceeded` |
|
||||||
|
| A9 | **`OnRunCompleted` feuert in jedem Endzustand genau einmal** | Parametrisiert über alle 5 Status |
|
||||||
|
| A10 | System-Prompt | Im Request **genau einmal** enthalten |
|
||||||
|
| A11 | `FullSystemPrompt` leer | Kein leerer system-Eintrag im Request |
|
||||||
|
| A12 | Tool-Ergebnis über Limit | Gekappt (nach T2-Fix) |
|
||||||
|
| A13 | `send_message` A→B→A | Nach `maxDelegationDepth` abgebrochen (nach B8-Fix) |
|
||||||
|
| A14 | `spawn` auf bereits laufenden Agenten | Abgelehnt mit Hinweis |
|
||||||
|
| A15 | Snapshot des Request-JSON (Verify) | Wire-Format bleibt stabil |
|
||||||
|
|
||||||
|
A9 und A15 sind Invarianten-Tests mit überproportionalem Nutzen: A9 verhindert, dass beim
|
||||||
|
Erweitern der Fehlerbehandlung die Token-Erfassung stillschweigend ausfällt; A15 schützt beim
|
||||||
|
Caching-Umbau (T1) davor, das Wire-Format versehentlich zu verändern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prio 4 — Konfiguration & Persistenz
|
||||||
|
|
||||||
|
| # | Bereich | Test |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 | `ConfigLoader` | Fehlender `openRouterApiKey` → aussagekräftige Exception |
|
||||||
|
| P2 | `ConfigLoader` | Doppelte `agentId` → Exception mit beiden Namen |
|
||||||
|
| P3 | `ConfigLoader` | JSON mit Kommentaren und trailing commas wird akzeptiert |
|
||||||
|
| P4 | `ConfigLoader` | Datei fehlt → `FileNotFoundException` mit Pfad |
|
||||||
|
| P5 | Chat-Persistenz | Round-Trip History + Context, inkl. tool_calls |
|
||||||
|
| P6 | Chat-Persistenz | Beide Alt-Formate lesbar (Array **und** `{"messages":[…]}`) |
|
||||||
|
| P7 | Chat-Persistenz | Korrupte JSON-Datei → Warnung, Agent startet trotzdem |
|
||||||
|
| P8 | Chat-Persistenz | Umlaute/Emoji überleben den Round-Trip (UTF-8) |
|
||||||
|
| P9 | `SqliteStateStore` | Set/Get/Delete Round-Trip; Überschreiben; unbekannter Key → `null` |
|
||||||
|
| P10 | `SqliteStateStore` | 50 parallele Schreibzugriffe → kein `database is locked` |
|
||||||
|
| P11 | `AgentConfig.FullSystemPrompt` | Reihenfolge Identity → Soul → SystemPrompt |
|
||||||
|
| P12 | `AgentConfig.FullSystemPrompt` | Leere Teile werden ausgelassen, kein doppelter Leerraum |
|
||||||
|
| P13 | `ToolRegistry` | `GetForAgent` liefert nur zugewiesene Tools |
|
||||||
|
|
||||||
|
P10 ist relevant, weil alle Tool-Jobs denselben Store teilen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prio 5 — Token & Caching (nach dem Umbau aus T1)
|
||||||
|
|
||||||
|
Diese Tests sichern ab, dass die Optimierung tatsächlich greift — ein stillschweigend
|
||||||
|
*nicht* funktionierender Cache wäre sonst nicht bemerkbar.
|
||||||
|
|
||||||
|
| # | Test | Erwartung |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | Cache-Breakpoint | Sitzt am Ende von System-Prompt und Tool-Definitionen |
|
||||||
|
| T2 | **Präfix-Stabilität über 10 Schritte** | Serialisierter Präfix ist byte-identisch — der entscheidende Test |
|
||||||
|
| T3 | `cached_tokens` | Wird aus der Antwort gelesen und in `AgentRunResult` weitergereicht |
|
||||||
|
| T4 | Compaction | Nutzt `summaryModel`, nicht `agentConfig.Model` |
|
||||||
|
| T5 | Tool-Ergebnis-Kappung | Greift bei allen Tools zentral, nicht nur bei WebFetch |
|
||||||
|
| T6 | `max_tokens` | Ist im Request gesetzt |
|
||||||
|
|
||||||
|
T2 ist der Test, der den ganzen Caching-Aufwand absichert: Ein einziger Zeitstempel im
|
||||||
|
System-Prompt würde den Cache bei jedem Schritt invalidieren — die Kosten blieben unverändert,
|
||||||
|
ohne dass es irgendwo auffällt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Arbeitsregeln
|
||||||
|
|
||||||
|
**Test-first bei Bugfixes.** Für jeden Punkt aus der Bestandsaufnahme zuerst den roten Test,
|
||||||
|
dann den Fix. Das beweist, dass der Test den Fehler wirklich fängt — ein nachträglich
|
||||||
|
geschriebener Test bestätigt oft nur die Implementierung.
|
||||||
|
|
||||||
|
**Keine echten Netzwerkzugriffe in Unit-Tests.** Sie sind langsam, flaky und kosten bei
|
||||||
|
OpenRouter echtes Geld. Integrationstests kommen ins separate Projekt und werden markiert:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
```
|
||||||
|
|
||||||
|
Standardlauf:
|
||||||
|
```bash
|
||||||
|
dotnet test --filter "Category!=Integration"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zeit immer über `TimeProvider`.** Kein `Thread.Sleep`, kein `Task.Delay` in Tests — sonst
|
||||||
|
dauert die Suite Minuten und wird instabil.
|
||||||
|
|
||||||
|
**Nebenläufigkeitstests wiederholen.** Race Conditions treten sporadisch auf; einmaliges
|
||||||
|
Bestehen sagt wenig:
|
||||||
|
```csharp
|
||||||
|
[Theory]
|
||||||
|
[Repeat(50)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benennung** durchgängig deutsch, im Stil `Methode_Bedingung_Erwartung`:
|
||||||
|
`Compaction_bei_Toolsequenz_an_der_Grenze_erhaelt_gueltige_Paarung`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Startpaket
|
||||||
|
|
||||||
|
Für die erste Sitzung schlage ich ~35 Tests vor, die exakt die fünf Sofort-Punkte abdecken:
|
||||||
|
|
||||||
|
| Reihenfolge | Umfang | Deckt ab |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. R1 (`IChatCompletionClient`) + Testprojekte anlegen | Refactoring | Voraussetzung |
|
||||||
|
| 2. `ContextInvariants.AssertValid` + C1–C10 + Property-Test | ~12 Tests | **B1** |
|
||||||
|
| 3. L1–L4 | 4 Tests | **B3** |
|
||||||
|
| 4. E1–E4 | 4 Tests | **B2** |
|
||||||
|
| 5. Y1–Y4 | 4 Tests | **S2** |
|
||||||
|
| 6. DirectAPI Key-Leak (alle Provider) | ~6 Tests | **S3** |
|
||||||
|
| 7. A9 + A15 (Invarianten) | 6 Tests | Absicherung für den Umbau |
|
||||||
|
|
||||||
|
Danach ist die Basis da, um die Fixes aus der Bestandsaufnahme mit Netz umzusetzen — und die
|
||||||
|
Prio-2-Sicherheitstests lassen sich schrittweise ergänzen, wenn die jeweiligen Tools
|
||||||
|
angefasst werden.
|
||||||
|
|
||||||
|
**Langfristig sinnvoll:** ein Gitea-Actions-Workflow, der bei jedem Push
|
||||||
|
`dotnet test --filter "Category!=Integration"` ausführt.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using ClawdDotNet.Core.Api.Models;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraktion über den Chat-Completion-Endpunkt.
|
||||||
|
/// Erlaubt es, Engine und Compactor ohne echte API-Aufrufe zu testen.
|
||||||
|
/// </summary>
|
||||||
|
public interface IChatCompletionClient
|
||||||
|
{
|
||||||
|
Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace ClawdDotNet.Core.Api;
|
namespace ClawdDotNet.Core.Api;
|
||||||
|
|
||||||
public sealed class OpenRouterClient : IDisposable
|
public sealed class OpenRouterClient : IChatCompletionClient, IDisposable
|
||||||
{
|
{
|
||||||
private const string BaseUrl = "https://openrouter.ai/api/v1/";
|
private const string BaseUrl = "https://openrouter.ai/api/v1/";
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace ClawdDotNet.Core.Engine;
|
|||||||
|
|
||||||
public sealed class AgentEngine : IAgentMessageRouter
|
public sealed class AgentEngine : IAgentMessageRouter
|
||||||
{
|
{
|
||||||
private readonly OpenRouterClient _client;
|
private readonly IChatCompletionClient _client;
|
||||||
private readonly ToolRegistry _toolRegistry;
|
private readonly ToolRegistry _toolRegistry;
|
||||||
private readonly PermissionGate _permissionGate;
|
private readonly PermissionGate _permissionGate;
|
||||||
private readonly IStateStore _stateStore;
|
private readonly IStateStore _stateStore;
|
||||||
@@ -35,7 +35,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
|||||||
};
|
};
|
||||||
|
|
||||||
public AgentEngine(
|
public AgentEngine(
|
||||||
OpenRouterClient client,
|
IChatCompletionClient client,
|
||||||
ToolRegistry toolRegistry,
|
ToolRegistry toolRegistry,
|
||||||
PermissionGate permissionGate,
|
PermissionGate permissionGate,
|
||||||
IStateStore stateStore,
|
IStateStore stateStore,
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ namespace ClawdDotNet.Core.Engine;
|
|||||||
|
|
||||||
public sealed class ContextCompactor
|
public sealed class ContextCompactor
|
||||||
{
|
{
|
||||||
private readonly OpenRouterClient _client;
|
private readonly IChatCompletionClient _client;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
private const int ProtectedTailMessages = 6;
|
private const int ProtectedTailMessages = 6;
|
||||||
private const int MaxToolResultChars = 2000;
|
private const int MaxToolResultChars = 2000;
|
||||||
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
|
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
|
||||||
|
|
||||||
public ContextCompactor(OpenRouterClient client, ILoggerFactory loggerFactory)
|
public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
|
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ClawdDotNet.Core.Tests</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="Shouldly" Version="4.2.1" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
|
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Beschreibt die Form einer Konversation. FsCheck erzeugt davon zufällige
|
||||||
|
/// Varianten und schrumpft sie im Fehlerfall auf das minimale Gegenbeispiel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ConversationShape(bool HasSystemPrompt, IReadOnlyList<TurnShape> Turns)
|
||||||
|
{
|
||||||
|
public List<ChatMessage> 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<ConversationShape> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Im Fehlerfall auf das kleinste Gegenbeispiel reduzieren.</summary>
|
||||||
|
private static IEnumerable<ConversationShape> 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ContextCompactorTests
|
||||||
|
{
|
||||||
|
private const string Model = "test/model";
|
||||||
|
|
||||||
|
/// <summary>Schwelle so gesetzt, dass Compaction sicher ausgelöst wird.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoopLimitExceededException>(() => 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<LoopLimitExceededException>(() => guard.RecordStep());
|
||||||
|
ex.Message.ShouldContain("3");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// L2 — Die maxTokens-Semantik (Bug B3)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<LoopLimitExceededException>(() => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ClawdDotNet.Core.Api.Models;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ContextInvariants
|
||||||
|
{
|
||||||
|
public static void AssertValid(IReadOnlyList<ChatMessage> 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<ChatMessage> messages)
|
||||||
|
=> !Validate(messages).Any();
|
||||||
|
|
||||||
|
private static IEnumerable<string> Validate(IReadOnlyList<ChatMessage> 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<string>();
|
||||||
|
var answered = new HashSet<string>();
|
||||||
|
|
||||||
|
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)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kompakte, lesbare Darstellung für Fehlermeldungen.</summary>
|
||||||
|
public static string Describe(IReadOnlyList<ChatMessage> 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);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using ClawdDotNet.Core.Api.Models;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut gültige Nachrichtenfolgen für Tests — lesbar und garantiert API-konform.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Conversation
|
||||||
|
{
|
||||||
|
private readonly List<ChatMessage> _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fügt einen vollständigen Tool-Zyklus hinzu: assistant mit N tool_calls,
|
||||||
|
/// gefolgt von genau N passenden tool-Antworten.
|
||||||
|
/// </summary>
|
||||||
|
public Conversation ToolCycle(int count = 1, int resultLength = 50, string toolName = "TestTool")
|
||||||
|
{
|
||||||
|
var calls = new List<ToolCall>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wiederholt ein Muster aus User-Nachricht und Tool-Zyklus.</summary>
|
||||||
|
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<ChatMessage> Build() => _messages;
|
||||||
|
|
||||||
|
public static implicit operator List<ChatMessage>(Conversation c) => c._messages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.Api;
|
||||||
|
using ClawdDotNet.Core.Api.Models;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeChatClient : IChatCompletionClient
|
||||||
|
{
|
||||||
|
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
|
||||||
|
private Func<ChatRequest, ChatResponse>? _fallback;
|
||||||
|
|
||||||
|
/// <summary>Alle empfangenen Requests, als tiefe Kopien.</summary>
|
||||||
|
public List<ChatRequest> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Antwort für alle Aufrufe, die über die programmierte Folge hinausgehen.</summary>
|
||||||
|
public FakeChatClient AlwaysRespondsWithText(string text)
|
||||||
|
{
|
||||||
|
_fallback = _ => TextResponse(text);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IChatCompletionClient ───
|
||||||
|
|
||||||
|
public Task<ChatResponse> 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user