Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.9 KiB
C#
127 lines
4.9 KiB
C#
using System.Text;
|
|
using ClawdDotNet.Core.Api.Models;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Infrastructure;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </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)
|
|
{
|
|
// ── 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<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 })
|
|
{
|
|
// 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)}";
|
|
}
|
|
|
|
/// <summary>Compact, readable representation for error messages.</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);
|