using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; namespace ClawdDotNet.Core.Tests.Infrastructure; /// /// 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. /// internal sealed class FakeChatClient : IChatCompletionClient { private readonly Queue> _responses = new(); private Func? _fallback; private readonly Lock _gate = new(); /// Alle empfangenen Requests, als tiefe Kopien. public List 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 } }; } /// Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen. 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; } /// Antwort für alle Aufrufe, die über die programmierte Folge hinausgehen. public FakeChatClient AlwaysRespondsWithText(string text) { _fallback = _ => TextResponse(text); return this; } public FakeChatClient AlwaysRespondsWithToolCall(string toolName = "TestTool") { _fallback = _ => ToolCallResponse([(toolName, "{}", null)]); return this; } /// /// 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. /// public FakeChatClient RespondsContextually(string toolName = "TestTool") { _fallback = req => req.Messages.LastOrDefault()?.Role == "tool" ? TextResponse("Fertig") : ToolCallResponse([(toolName, "{}", null)]); return this; } // ─── IChatCompletionClient ─── public Task 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? 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(), 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() }; }