using System.Text; using ClawdDotNet.Core.Api.Models; namespace ClawdDotNet.Core.Tests.Infrastructure; /// /// Prüft, ob eine Nachrichtenfolge von der Chat-Completions-API akzeptiert würde. /// /// Die Regeln entsprechen dem, was OpenRouter/Anthropic/OpenAI verlangen: /// Eine tool-Antwort ist nur gültig, wenn ihr eine assistant-Nachricht mit einem /// passenden tool_call vorausgeht — und jeder tool_call braucht seine Antwort. /// /// Wird eine Regel verletzt, antwortet die API mit HTTP 400 und der laufende /// Agent bricht ab. Genau das ist Bug B1 aus der Bestandsaufnahme. /// internal static class ContextInvariants { public static void AssertValid(IReadOnlyList messages) { var violations = Validate(messages).ToList(); if (violations.Count == 0) return; var sb = new StringBuilder(); sb.AppendLine($"Ungültige Nachrichtenfolge ({violations.Count} Verstoß/Verstöße):"); foreach (var v in violations) sb.AppendLine($" • {v}"); sb.AppendLine(); sb.AppendLine("Sequenz:"); sb.Append(Describe(messages)); throw new ContextInvariantViolationException(sb.ToString()); } public static bool IsValid(IReadOnlyList messages) => !Validate(messages).Any(); private static IEnumerable Validate(IReadOnlyList messages) { // ── Regel 1: höchstens eine system-Nachricht, und zwar ganz vorne ── for (var i = 1; i < messages.Count; i++) { if (messages[i].Role == "system") yield return $"[{i}] system-Nachricht steht nicht an Position 0"; } // ── Regel 2: jede tool-Nachricht braucht eine ToolCallId ── for (var i = 0; i < messages.Count; i++) { if (messages[i].Role == "tool" && string.IsNullOrEmpty(messages[i].ToolCallId)) yield return $"[{i}] tool-Nachricht ohne tool_call_id"; } // ── Regel 3: jede tool-Nachricht gehört zum unmittelbar vorausgehenden // assistant-Block mit passender tool_call-Id ── var openCalls = new HashSet(); var answered = new HashSet(); for (var i = 0; i < messages.Count; i++) { var msg = messages[i]; if (msg.Role == "assistant" && msg.ToolCalls is { Count: > 0 }) { // Vorheriger Block muss vollständig beantwortet sein var unanswered = openCalls.Except(answered).ToList(); if (unanswered.Count > 0) yield return $"[{i}] neuer assistant-Block, aber tool_call(s) noch unbeantwortet: {string.Join(", ", unanswered)}"; openCalls.Clear(); answered.Clear(); foreach (var tc in msg.ToolCalls) openCalls.Add(tc.Id); } else if (msg.Role == "tool") { var id = msg.ToolCallId ?? ""; if (!openCalls.Contains(id)) { yield return $"[{i}] tool-Antwort '{id}' ohne vorausgehenden assistant-tool_call " + "(die API lehnt das mit HTTP 400 ab)"; } else if (!answered.Add(id)) { yield return $"[{i}] tool_call '{id}' wurde doppelt beantwortet"; } } else { // user/system/assistant-ohne-tool_calls beenden den Block var unanswered = openCalls.Except(answered).ToList(); if (unanswered.Count > 0) yield return $"[{i}] {msg.Role}-Nachricht, aber tool_call(s) unbeantwortet: {string.Join(", ", unanswered)}"; openCalls.Clear(); answered.Clear(); } } // ── Regel 4: am Ende darf kein tool_call offen sein ── var stillOpen = openCalls.Except(answered).ToList(); if (stillOpen.Count > 0) yield return $"[Ende] unbeantwortete tool_call(s): {string.Join(", ", stillOpen)}"; } /// Kompakte, lesbare Darstellung für Fehlermeldungen. public static string Describe(IReadOnlyList messages) { var sb = new StringBuilder(); for (var i = 0; i < messages.Count; i++) { var m = messages[i]; var detail = m.Role switch { "tool" => $"tool_call_id={m.ToolCallId}, len={m.Content?.Length ?? 0}", "assistant" when m.ToolCalls is { Count: > 0 } => $"tool_calls=[{string.Join(", ", m.ToolCalls.Select(t => $"{t.Function.Name}#{t.Id}"))}]", _ => $"len={m.Content?.Length ?? 0}" }; sb.AppendLine($" [{i,2}] {m.Role,-9} {detail}"); } return sb.ToString(); } } internal sealed class ContextInvariantViolationException(string message) : Exception(message);