Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs
T
RichardandClaude Opus 4.8 6bbe9f9a80 B2 beheben: Chat-Laeufe pro Agent serialisieren
Der Konversationskontext _chatContexts[agentId] ist eine geteilte List<ChatMessage>.
Nur der Lookup lief unter Lock, alle Add-Aufrufe im Schleifenkoerper waren
ungeschuetzt. Da WebView, ToolJob-Wakeups und AgentComm denselben Agenten
gleichzeitig ansprechen koennen, verschraenkten sich ihre Nachrichten zu einer
ungueltigen Tool-Sequenz, die die API mit HTTP 400 ablehnt.

ChatAsync laeuft jetzt hinter einem SemaphoreSlim(1,1) pro Agent; verschiedene
Agenten bleiben unabhaengig. Die Timeout-Uhr startet erst nach dem Eintritt,
damit Wartezeit in der Warteschlange den Lauf nicht aufzehrt.

Zwei Folgeprobleme mit demselben Ursprung:

- _runningChats hielt nur EINE CancellationTokenSource je Agent; der zweite Lauf
  ueberschrieb den ersten. AbortChat brach dadurch nur einen ab, der andere lief
  bis ins Run-Timeout. Jetzt eine Liste, die auch wartende Laeufe erfasst.
- ExecuteToolCallAsync fing OperationCanceledException mit ab und gab sie als
  Tool-Ergebnis zurueck, wodurch der Abbruch erst einen Schritt spaeter griff.
  Cancellation wird nun durchgereicht.

Ausserdem: send_message an den eigenen Agenten wird abgelehnt — es waere mit dem
neuen Gate in einen Deadlock gelaufen.

Neu: GetChatContext(agentId) als Momentaufnahme des Kontexts, fuer Diagnose und
Kontextgroessen-Anzeige.

Build-Fix: Das WinForms-Projekt globbt **/*.cs und kompilierte dadurch die
Test-Quellen mit. tests\** wird jetzt wie src\** ausgeschlossen.

Alle 49 Tests gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:55:04 +02:00

162 lines
5.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&lt;ChatMessage&gt;
/// 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>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(),
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()
};
}