using System.Text; using ClawdDotNet.Core.Api.Models; namespace ClawdDotNet.Core.Tests.Infrastructure; /// /// Checks whether a message sequence would be accepted by the chat completions API. /// /// The rules correspond to what OpenRouter/Anthropic/OpenAI demand: /// a tool answer is only valid if it is preceded by an assistant message with a /// matching tool_call — and every tool_call needs its answer. /// /// If a rule is violated, the API answers with HTTP 400 and the running /// agent aborts. That is exactly bug B1 from the inventory. /// 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) { // ── Rule 1: at most one system message, and at the very front ── for (var i = 1; i < messages.Count; i++) { if (messages[i].Role == "system") yield return $"[{i}] system-Nachricht steht nicht an Position 0"; } // ── Rule 2: every tool message needs a 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"; } // ── Rule 3: every tool message belongs to the immediately preceding // assistant block with a matching 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 }) { // The previous block must be completely answered 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-without-tool_calls end the 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(); } } // ── Rule 4: no tool_call may be open at the end ── var stillOpen = openCalls.Except(answered).ToList(); if (stillOpen.Count > 0) yield return $"[Ende] unbeantwortete tool_call(s): {string.Join(", ", stillOpen)}"; } /// Compact, readable representation for error messages. 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);