using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; namespace ClawdDotNet.Core.Tests.Infrastructure; /// /// Replaces the OpenRouterClient in tests. Delivers a preprogrammed sequence of answers /// and records every request received. /// /// Important: requests are deep-copied. The engine passes on the same List<ChatMessage> /// and changes it afterwards — without a copy tests would check the final state /// instead of what was actually sent. /// internal sealed class FakeChatClient : IChatCompletionClient { private readonly Queue> _responses = new(); private Func? _fallback; private readonly Lock _gate = new(); /// All requests received, as deep copies. public List ReceivedRequests { get; } = new(); public int CallCount => ReceivedRequests.Count; // ─── Programming the answers ─── 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 must be unique per answer, otherwise parallel runs collide. 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 } }; } /// Answers with a particular prompt token count — for compaction thresholds. 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; } /// /// Answers with a text cut off at the output limit (#548). /// 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; } /// /// Answers with tool calls AND accompanying text or reasoning — the case /// #547 and #549 concern. /// 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; } /// Answer for all calls that go beyond the programmed sequence. public FakeChatClient AlwaysRespondsWithText(string text) { _fallback = _ => TextResponse(text); return this; } public FakeChatClient AlwaysRespondsWithToolCall(string toolName = "TestTool") { _fallback = _ => ToolCallResponse([(toolName, "{}", null)]); return this; } /// /// Answers matching the respective state of the conversation: after a tool answer /// comes text, otherwise a tool call. That way the fake behaves sensibly /// even when several conversations run interleaved. /// 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(); // Copy first, then evaluate: the engine keeps mutating the original list, // and in concurrency tests the fake should not fail because of that. 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)); } // ─── Helpers ─── 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(), // The gateway fields must be copied too, otherwise a test checks the hardening // past the wire format — the same trap as with 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, // Must be copied too, otherwise caching tests check past the wire format. 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() }; }