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
@@ -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