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()
};
}