Testfundament aufbauen und Bestandsaufnahme dokumentieren

IChatCompletionClient aus OpenRouterClient extrahiert, damit AgentEngine und
ContextCompactor ohne echte API-Aufrufe testbar sind.

Neues Testprojekt tests/ClawdDotNet.Core.Tests (xUnit, Shouldly, NSubstitute,
FsCheck) mit:
- FakeChatClient (programmierbare Antwortfolgen, Deep-Copy der Requests)
- ContextInvariants (prueft die API-Regeln fuer tool_call-Paarung)
- Conversation-Builder fuer gueltige Testkonversationen
- 26 Tests: Compaction, LoopGuard, 2 Property-Tests

10 Tests sind bewusst rot — sie reproduzieren die Bugs B1, B3 und B14 aus der
Bestandsaufnahme und werden mit den Fixes gruen.

Ausserdem: fehlende Tool-Projekte in slnx ergaenzt, Test-Pakete im
packageSourceMapping der NuGet.Config eingetragen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-27 10:07:54 +02:00
co-authored by Claude Opus 4.8
parent 92e50d3ac4
commit 667cecce25
17 changed files with 1887 additions and 5 deletions
@@ -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);