Fuenf Befunde mit einem gemeinsamen Fehlermodus: Keiner erzeugt eine Fehlermeldung. Der Lauf wird fertig, das Ergebnis sieht plausibel aus, und nur die Qualitaet ist schlechter. #547 Der Begleittext zum Werkzeugaufruf ging verloren. AssistantWithToolCalls baute eine neue Nachricht aus nur den Aufrufen; der Text, den das Modell daneben geschrieben hatte, fiel weg. Damit verlor das Modell zwischen zwei Schritten seinen eigenen Plan, und im Audit stand, dass ein Werkzeug lief, nicht warum. #548 finish_reason wurde nirgends gelesen. Eine an der Ausgabegrenze abgeschnittene Antwort galt als fertiges Ergebnis. Das trifft besonders Laeufe im Hintergrund, wo niemand zusieht. Neuer Status Truncated: Das Teilergebnis geht weiter, aber gekennzeichnet. #549 reasoning_details wurde verworfen. Bei Werkzeugnutzung muss der Denkverlauf unveraendert zurueck, sonst beginnt ein Thinking-Modell jedes Mal von vorn. Wird jetzt als rohes JSON durchgereicht - ohne Auslegung, weil Reihenfolge und Signatur daran haengen. #550 Die Anfrage verlangt jetzt einen Anbieter, der alle mitgeschickten Parameter beherrscht. Ohne das durfte ein Anbieter cache_control, reasoning oder sogar tools stillschweigend uebergehen - der Lauf lieferte dann eine gewoehnliche Textantwort, wo ein Werkzeugaufruf hingehoert haette. Dazu usage.include: der abgerechnete Betrag schlaegt die Eigenrechnung, die als Rueckfallebene bleibt. Denktiefe je Agent einstellbar, Vorgabe off. #551 Werkzeuge eines Zuges laufen gleichzeitig, sofern sie sich dafuer freigeben. Die Vorgabe von IsParallelSafe ist false, weil die falsche Antwort hier nicht auffaellt: Zwei schreibende Aufrufe auf derselben Datei scheitern nicht zuverlaessig, sondern gelegentlich. Freigegeben ist bisher nur WebFetch - es liest ausschliesslich. Die Reihenfolge der Ergebnisse folgt den Aufrufen, nicht der Fertigstellung; daran haengen die tool_call_id-Zuordnung und der Cache-Praefix. 27 neue Tests, 887 gesamt, keine Regression. Build ohne Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
7.8 KiB
C#
217 lines
7.8 KiB
C#
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;
|
|
private readonly Lock _gate = new();
|
|
|
|
/// <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)
|
|
{
|
|
_responses.Enqueue(_ => ToolCallResponse(calls));
|
|
return this;
|
|
}
|
|
|
|
private static ChatResponse ToolCallResponse((string Tool, string Args, string? Id)[] calls)
|
|
{
|
|
// Ids müssen pro Antwort eindeutig sein, sonst kollidieren parallele Läufe.
|
|
var toolCalls = calls.Select(c => new ToolCall
|
|
{
|
|
Id = c.Id ?? "call_" + Guid.NewGuid().ToString("N")[..12],
|
|
Type = "function",
|
|
Function = new ToolCallFunction { Name = c.Tool, Arguments = c.Args }
|
|
}).ToList();
|
|
|
|
return new ChatResponse
|
|
{
|
|
Id = "resp",
|
|
Choices = [new Choice { Index = 0, Message = new ChatMessage { Role = "assistant", ToolCalls = toolCalls } }],
|
|
Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 }
|
|
};
|
|
}
|
|
|
|
/// <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>
|
|
/// Antwortet mit einem an der Ausgabegrenze abgeschnittenen Text (#548).
|
|
/// </summary>
|
|
public FakeChatClient RespondsWithTruncatedText(string text, string finishReason = "length")
|
|
{
|
|
_responses.Enqueue(_ => new ChatResponse
|
|
{
|
|
Id = "resp",
|
|
Choices =
|
|
[
|
|
new Choice
|
|
{
|
|
Index = 0,
|
|
Message = new ChatMessage { Role = "assistant", Content = text },
|
|
FinishReason = finishReason
|
|
}
|
|
],
|
|
Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 }
|
|
});
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Antwortet mit Werkzeugaufrufen UND Begleittext bzw. Denkverlauf — der Fall, den
|
|
/// #547 und #549 betreffen.
|
|
/// </summary>
|
|
public FakeChatClient RespondsWithToolCallAndContent(
|
|
string toolName,
|
|
string content,
|
|
string? reasoning = null,
|
|
string? reasoningDetailsJson = null,
|
|
string argumentsJson = "{}")
|
|
{
|
|
_responses.Enqueue(_ =>
|
|
{
|
|
var response = ToolCallResponse([(toolName, argumentsJson, null)]);
|
|
var message = response.Choices[0].Message;
|
|
message.Content = content;
|
|
message.Reasoning = reasoning;
|
|
if (reasoningDetailsJson is not null)
|
|
message.ReasoningDetails = JsonDocument.Parse(reasoningDetailsJson).RootElement.Clone();
|
|
return response;
|
|
});
|
|
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;
|
|
}
|
|
|
|
public FakeChatClient AlwaysRespondsWithToolCall(string toolName = "TestTool")
|
|
{
|
|
_fallback = _ => ToolCallResponse([(toolName, "{}", null)]);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Antwortet passend zum jeweiligen Gesprächsstand: nach einer Tool-Antwort
|
|
/// kommt Text, sonst ein Tool-Aufruf. Damit verhält sich der Fake auch dann
|
|
/// sinnvoll, wenn mehrere Konversationen ineinander verschränkt laufen.
|
|
/// </summary>
|
|
public FakeChatClient RespondsContextually(string toolName = "TestTool")
|
|
{
|
|
_fallback = req => req.Messages.LastOrDefault()?.Role == "tool"
|
|
? TextResponse("Fertig")
|
|
: ToolCallResponse([(toolName, "{}", null)]);
|
|
return this;
|
|
}
|
|
|
|
// ─── IChatCompletionClient ───
|
|
|
|
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// Erst kopieren, dann auswerten: Die Engine mutiert die Original-Liste
|
|
// weiter, und bei Nebenläufigkeitstests soll der Fake daran nicht scheitern.
|
|
var snapshot = DeepClone(request);
|
|
lock (_gate)
|
|
ReceivedRequests.Add(snapshot);
|
|
|
|
Func<ChatRequest, ChatResponse>? responder;
|
|
lock (_gate)
|
|
responder = _responses.Count > 0 ? _responses.Dequeue() : _fallback;
|
|
|
|
if (responder is null)
|
|
throw new InvalidOperationException(
|
|
$"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " +
|
|
"keine Antwort mehr programmiert.");
|
|
|
|
return Task.FromResult(responder(snapshot));
|
|
}
|
|
|
|
// ─── 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(),
|
|
// Die Gateway-Felder gehören mitkopiert, sonst prüft ein Test die Härtung
|
|
// am Wire-Format vorbei — dieselbe Falle wie bei CacheBreakpoint.
|
|
Provider = request.Provider,
|
|
Usage = request.Usage,
|
|
Reasoning = request.Reasoning,
|
|
Messages = request.Messages.Select(CloneMessage).ToList()
|
|
};
|
|
|
|
private static ChatMessage CloneMessage(ChatMessage m) => new()
|
|
{
|
|
Role = m.Role,
|
|
Content = m.Content,
|
|
ToolCallId = m.ToolCallId,
|
|
// Muss mitkopiert werden, sonst prüfen Caching-Tests am Wire-Format vorbei.
|
|
CacheBreakpoint = m.CacheBreakpoint,
|
|
Reasoning = m.Reasoning,
|
|
ReasoningDetails = m.ReasoningDetails,
|
|
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()
|
|
};
|
|
}
|