From 6bbe9f9a809e6e80a725c0ec5b8c75ac8c2cf3d6 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 27 Jul 2026 10:55:04 +0200 Subject: [PATCH] B2 beheben: Chat-Laeufe pro Agent serialisieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Konversationskontext _chatContexts[agentId] ist eine geteilte List. 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 --- ClawdDotNet.csproj | 5 + docs/Bestandsaufnahme-2026-07.md | 8 +- src/ClawdDotNet.Core/Engine/AgentEngine.cs | 145 +++++++++++++++-- .../Engine/AgentEngineConcurrencyTests.cs | 146 ++++++++++++++++++ .../Infrastructure/EngineFixture.cs | 63 ++++++++ .../Infrastructure/FakeChatClient.cs | 62 ++++++-- .../Infrastructure/FakeTool.cs | 70 +++++++++ .../Infrastructure/RepeatAttribute.cs | 17 ++ 8 files changed, 489 insertions(+), 27 deletions(-) create mode 100644 tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/RepeatAttribute.cs diff --git a/ClawdDotNet.csproj b/ClawdDotNet.csproj index 6e1f679..dde355d 100644 --- a/ClawdDotNet.csproj +++ b/ClawdDotNet.csproj @@ -19,10 +19,15 @@ false + + + + diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md index 0d8d49d..f2282d2 100644 --- a/docs/Bestandsaufnahme-2026-07.md +++ b/docs/Bestandsaufnahme-2026-07.md @@ -178,6 +178,12 @@ sodass `AbortChat` nur den zuletzt gestarteten Lauf abbricht. **Fix:** Pro Agent ein `SemaphoreSlim(1,1)`, das den gesamten `ChatAsync`-Durchlauf serialisiert. Wartende Nachrichten in eine Queue statt parallel starten. +Beim Umsetzen kamen zwei Folgeprobleme dazu, die denselben Ursprung haben: +- `ExecuteToolCallAsync` fing `OperationCanceledException` mit ab und gab sie als + Tool-Fehlerergebnis zurück. Der Abbruch griff dadurch erst einen Schritt später. +- `send_message` an den eigenen Agenten wäre mit dem neuen Gate in einen Deadlock gelaufen + (der laufende Chat hält es bereits) — wird jetzt abgelehnt. + ### B3 — `maxTokens` vermischt Abrechnungs-Budget und Kontextgröße ⚠️ [`LoopGuard.cs:22`](../src/ClawdDotNet.Core/Engine/LoopGuard.cs#L22), Defaults in [`AgentConfig.cs:105`](../src/ClawdDotNet.Core/Config/AgentConfig.cs#L105) @@ -521,7 +527,7 @@ Siehe K3. 1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben 2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben 2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben -3. B2 Race Condition im Chat-Kontext +3. ~~B2 Race Condition im Chat-Kontext~~ ✅ behoben 4. S2 yt-dlp-Injection 5. S3 API-Key-Leak diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index cbf5376..4f2d993 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -21,9 +21,23 @@ public sealed class AgentEngine : IAgentMessageRouter private readonly Dictionary> _chatHistories = new(); private readonly Dictionary> _chatContexts = new(); - private readonly Dictionary _runningChats = new(); private readonly Lock _lock = new(); + /// + /// Alle aktiven Chat-Läufe je Agent — laufende wie wartende. Mehrere Quellen können + /// denselben Agenten gleichzeitig ansprechen (WebView, ToolJob, AgentComm), deshalb + /// eine Liste: AbortChat muss jeden davon erreichen. + /// + private readonly Dictionary> _runningChats = new(); + + /// + /// Serialisiert ChatAsync pro Agent. Der Konversationskontext ist eine geteilte + /// Liste — liefen zwei Chats desselben Agenten gleichzeitig, verschränkten sich ihre + /// Nachrichten zu einer ungültigen Tool-Sequenz, die die API mit HTTP 400 ablehnt. + /// Verschiedene Agenten bleiben unabhängig voneinander. + /// + private readonly Dictionary _agentGates = new(); + private Func>? _agentConfigProvider; private Func? _agentDirResolver; private string _instanceId = ""; @@ -227,18 +241,53 @@ public sealed class AgentEngine : IAgentMessageRouter string instanceId, CancellationToken externalCt, string? source = null) + { + // Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine + // wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat. + using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); + RegisterRun(agentConfig.AgentId, runCts); + + var gate = GetAgentGate(agentConfig.AgentId); + try + { + await gate.WaitAsync(runCts.Token); + } + catch (OperationCanceledException) + { + UnregisterRun(agentConfig.AgentId, runCts); + return new AgentRunResult( + agentConfig.AgentId, AgentRunStatus.Cancelled, "[Chat abgebrochen]", + 0, 0, TimeSpan.Zero); + } + + try + { + return await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source); + } + finally + { + UnregisterRun(agentConfig.AgentId, runCts); + gate.Release(); + } + } + + private async Task ChatCoreAsync( + AgentConfig agentConfig, + string userMessage, + string instanceId, + CancellationToken runCt, + string? source) { var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}"); var loopGuard = new LoopGuard(agentConfig.LoopGuard); var sw = Stopwatch.StartNew(); + // Die Timeout-Uhr läuft erst ab hier — Wartezeit in der Warteschlange + // darf den Lauf nicht aufzehren. using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token); var ct = linkedCts.Token; - lock (_lock) - _runningChats[agentConfig.AgentId] = linkedCts; - try { var tools = _toolRegistry.GetForAgent(agentConfig); @@ -376,10 +425,46 @@ public sealed class AgentEngine : IAgentMessageRouter OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } - finally + } + + // ─── Nebenläufigkeits-Helfer ─── + + private SemaphoreSlim GetAgentGate(string agentId) + { + lock (_lock) { - lock (_lock) - _runningChats.Remove(agentConfig.AgentId); + if (!_agentGates.TryGetValue(agentId, out var gate)) + { + gate = new SemaphoreSlim(1, 1); + _agentGates[agentId] = gate; + } + return gate; + } + } + + private void RegisterRun(string agentId, CancellationTokenSource cts) + { + lock (_lock) + { + if (!_runningChats.TryGetValue(agentId, out var list)) + { + list = new List(); + _runningChats[agentId] = list; + } + list.Add(cts); + } + } + + private void UnregisterRun(string agentId, CancellationTokenSource cts) + { + lock (_lock) + { + if (!_runningChats.TryGetValue(agentId, out var list)) + return; + + list.Remove(cts); + if (list.Count == 0) + _runningChats.Remove(agentId); } } @@ -397,12 +482,38 @@ public sealed class AgentEngine : IAgentMessageRouter return _runningChats.ContainsKey(agentId); } - public void AbortChat(string agentId) + /// + /// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten, + /// die beim nächsten Schritt tatsächlich an das Modell gehen. + /// Nützlich für Diagnose und Kontextgrößen-Anzeige. + /// + public IReadOnlyList GetChatContext(string agentId) { + lock (_lock) + return _chatContexts.TryGetValue(agentId, out var ctx) + ? ctx.ToList() + : []; + } + + /// + /// Bricht ALLE Chat-Läufe des Agenten ab — laufende wie wartende. + /// + public void AbortChat(string agentId) + { + List toCancel; lock (_lock) { - if (_runningChats.TryGetValue(agentId, out var cts)) - cts.Cancel(); + if (!_runningChats.TryGetValue(agentId, out var list)) + return; + toCancel = list.ToList(); + } + + // Außerhalb des Locks abbrechen: Cancel führt Continuations aus, die + // ihrerseits wieder auf _lock zugreifen können. + foreach (var cts in toCancel) + { + try { cts.Cancel(); } + catch (ObjectDisposedException) { /* Lauf war bereits fertig */ } } } @@ -514,6 +625,12 @@ public sealed class AgentEngine : IAgentMessageRouter if (configs is null) return new AgentMessageResult(false, null, "Agent config provider not set."); + // Selbstadressierung würde am Agent-Gate hängen bleiben: Der laufende Chat + // hält es bereits und würde auf sich selbst warten. + if (fromAgentId == toAgentId) + return new AgentMessageResult(false, null, + "Ein Agent kann sich keine Nachricht an sich selbst schicken."); + var targetConfig = configs.FirstOrDefault(a => a.AgentId == toAgentId); if (targetConfig is null) return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found."); @@ -678,6 +795,12 @@ public sealed class AgentEngine : IAgentMessageRouter logger.LogWarning("Tool access denied: {Message}", ex.Message); return JsonSerializer.Serialize(new { error = ex.Message }); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen + // Schritt weiter und der Abbruch greift erst verzögert. + throw; + } catch (Exception ex) { logger.LogError(ex, "Tool {Tool} threw an exception", toolName); diff --git a/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs new file mode 100644 index 0000000..0716be9 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs @@ -0,0 +1,146 @@ +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Tests.Infrastructure; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Engine; + +/// +/// Regressionstests für Bug B2: _chatContexts[agentId] ist eine geteilte +/// List<ChatMessage>. Nur der Lookup läuft unter Lock — alle Add-Aufrufe im +/// Schleifenkörper sind ungeschützt. +/// +/// Derselbe Agent kann gleichzeitig von mehreren Seiten angestoßen werden: +/// Telegram-/Mail-ToolJob, WebView-Nachricht des Benutzers, send_message eines +/// anderen Agenten. Ergebnis: korrupte Liste, verschränkte Tool-Sequenzen und +/// dadurch HTTP-400-Fehler. +/// +public sealed class AgentEngineConcurrencyTests +{ + // ═══════════════════════════════════════════════════════════ + // E1 — Zwei parallele Chats auf demselben Agenten + // ═══════════════════════════════════════════════════════════ + + [Theory] + [Repeat(15)] + public async Task Zwei_parallele_Chats_hinterlassen_einen_gueltigen_Kontext(int iteration) + { + _ = iteration; + + var fixture = new EngineFixture() + .WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(15))); + var agent = fixture.AddAgent("agent-a", "TestTool"); + + // Jeder Lauf: ein Tool-Schritt, dann eine Textantwort — unabhängig davon, + // wie die beiden Konversationen ineinander verschränkt werden. + fixture.Client.RespondsContextually(); + + await Task.WhenAll( + fixture.Engine.ChatAsync(agent, "Anfrage A", "test-instance", default), + fixture.Engine.ChatAsync(agent, "Anfrage B", "test-instance", default)); + + var context = fixture.Engine.GetChatContext(agent.AgentId); + ContextInvariants.AssertValid(context); + } + + // ═══════════════════════════════════════════════════════════ + // E2 — Stresstest + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Viele_parallele_Chats_korrumpieren_den_Kontext_nicht() + { + var fixture = new EngineFixture() + .WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(2))); + var agent = fixture.AddAgent("agent-stress", "TestTool"); + + fixture.Client.RespondsContextually(); + + var tasks = Enumerable.Range(0, 30) + .Select(i => fixture.Engine.ChatAsync(agent, $"Anfrage {i}", "test-instance", default)); + + var results = await Task.WhenAll(tasks); + + results.ShouldAllBe(r => r.Status == AgentRunStatus.Completed); + + var context = fixture.Engine.GetChatContext(agent.AgentId); + ContextInvariants.AssertValid(context); + + // Jede der 30 Anfragen muss genau einmal im Kontext stehen — nichts verloren, + // nichts doppelt durch verlorene Schreibzugriffe auf die Liste. + var userMessages = context.Count(m => m.Role == "user"); + userMessages.ShouldBe(30); + } + + // ═══════════════════════════════════════════════════════════ + // E3 — AbortChat + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task AbortChat_bricht_alle_laufenden_Chats_des_Agenten_ab() + { + var fixture = new EngineFixture() + .WithTool(FakeTool.Slow(TimeSpan.FromSeconds(10))); + var agent = fixture.AddAgent("agent-abort", "TestTool"); + + fixture.Client.AlwaysRespondsWithToolCall(); + + var first = fixture.Engine.ChatAsync(agent, "Erste", "test-instance", default); + var second = fixture.Engine.ChatAsync(agent, "Zweite", "test-instance", default); + + // Warten, bis beide tatsächlich angelaufen sind. + await WaitUntilAsync(() => fixture.Engine.IsRunning(agent.AgentId)); + await Task.Delay(50); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + fixture.Engine.AbortChat(agent.AgentId); + + var results = await Task.WhenAll(first, second); + sw.Stop(); + + results.ShouldAllBe(r => r.Status == AgentRunStatus.Cancelled); + + // Entscheidend: _runningChats[agentId] hielt bisher nur EINE CancellationTokenSource — + // der zweite Lauf überschrieb den ersten. AbortChat brach dann nur einen ab, der + // andere lief bis ins Run-Timeout. Ohne diese Zeitprüfung wäre der Test grün, + // obwohl der Abbruch gar nicht gegriffen hat. + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5), + "AbortChat muss beide Läufe sofort beenden, nicht erst über das Timeout"); + } + + // ═══════════════════════════════════════════════════════════ + // E4 — Gegenprobe: verschiedene Agenten dürfen parallel laufen + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Verschiedene_Agenten_laufen_wirklich_parallel() + { + // Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten + // gegeneinander sperren. + var toolDelay = TimeSpan.FromMilliseconds(200); + var fixture = new EngineFixture().WithTool(FakeTool.Slow(toolDelay)); + + var a = fixture.AddAgent("agent-1", "TestTool"); + var b = fixture.AddAgent("agent-2", "TestTool"); + var c = fixture.AddAgent("agent-3", "TestTool"); + + fixture.Client.AlwaysRespondsWithText("Antwort"); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + await Task.WhenAll( + fixture.Engine.ChatAsync(a, "x", "test-instance", default), + fixture.Engine.ChatAsync(b, "x", "test-instance", default), + fixture.Engine.ChatAsync(c, "x", "test-instance", default)); + sw.Stop(); + + // Seriell wären es mindestens 3 × 200 ms. Parallel deutlich weniger. + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(450), + "Agenten dürfen sich nicht gegenseitig blockieren"); + } + + private static async Task WaitUntilAsync(Func condition, int timeoutMs = 2_000) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!condition() && sw.ElapsedMilliseconds < timeoutMs) + await Task.Delay(10); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs new file mode 100644 index 0000000..3a635d8 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs @@ -0,0 +1,63 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Tools; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +/// +/// Baut eine einsatzbereite AgentEngine mit Fake-Client, Fake-Tools und +/// In-Memory-State zusammen. +/// +internal sealed class EngineFixture +{ + public FakeChatClient Client { get; } = new(); + public ToolRegistry Registry { get; } = new(); + public InMemoryStateStore StateStore { get; } = new(); + public AgentEngine Engine { get; } + + private readonly List _agents = new(); + + public EngineFixture() + { + Engine = new AgentEngine( + Client, + Registry, + new PermissionGate(), + StateStore, + TestLogging.Factory); + + // Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests. + Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null); + } + + public EngineFixture WithTool(IAgentTool tool) + { + Registry.Register(tool); + return this; + } + + public AgentConfig AddAgent(string agentId, params string[] toolNames) + { + var config = new AgentConfig + { + AgentId = agentId, + DisplayName = agentId, + Model = "test/model", + SystemPrompt = $"Du bist {agentId}.", + LoopGuard = new LoopGuardConfig + { + MaxSteps = 50, + MaxCumulativeTokens = 10_000_000, + TimeoutSeconds = 60, + MaxContextTokens = 10_000_000 // Compaction in Engine-Tests ausschalten + } + }; + + foreach (var name in toolNames) + config.Tools[name] = new Dictionary(); + + _agents.Add(config); + return config; + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs index 62ee79e..a70c164 100644 --- a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs @@ -16,6 +16,7 @@ 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(); @@ -35,20 +36,26 @@ internal sealed class FakeChatClient : IChatCompletionClient public FakeChatClient RespondsWithToolCalls(params (string Tool, string Args, string? Id)[] calls) { - var toolCalls = calls.Select((c, i) => new ToolCall + _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():N}"[..12], + Id = c.Id ?? "call_" + Guid.NewGuid().ToString("N")[..12], Type = "function", - Function = new ToolCallFunction { Name =c.Tool, Arguments = c.Args } + Function = new ToolCallFunction { Name = c.Tool, Arguments = c.Args } }).ToList(); - _responses.Enqueue(_ => new ChatResponse + return new ChatResponse { - Id = "resp_" + ReceivedRequests.Count, + Id = "resp", Choices = [new Choice { Index = 0, Message = new ChatMessage { Role = "assistant", ToolCalls = toolCalls } }], Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 } - }); - return this; + }; } /// Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen. @@ -76,22 +83,47 @@ internal sealed class FakeChatClient : IChatCompletionClient 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(); - ReceivedRequests.Add(DeepClone(request)); - if (_responses.Count > 0) - return Task.FromResult(_responses.Dequeue()(request)); + // 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); - if (_fallback is not null) - return Task.FromResult(_fallback(request)); + Func? responder; + lock (_gate) + responder = _responses.Count > 0 ? _responses.Dequeue() : _fallback; - throw new InvalidOperationException( - $"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " + - "keine Antwort mehr programmiert."); + if (responder is null) + throw new InvalidOperationException( + $"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " + + "keine Antwort mehr programmiert."); + + return Task.FromResult(responder(snapshot)); } // ─── Helfer ─── diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs new file mode 100644 index 0000000..1e9c190 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using ClawdDotNet.Core.Tools; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +/// +/// Konfigurierbares Tool für Engine-Tests: liefert feste Ergebnisse, kann verzögern +/// (um Nebenläufigkeit zu provozieren) oder werfen. +/// +internal sealed class FakeTool : IAgentTool +{ + private readonly Func> _handler; + + public string Name { get; } + public string Description => $"Test-Tool {Name}"; + + public JsonElement InputSchema { get; } = JsonDocument.Parse(""" + { "type": "object", "properties": { "action": { "type": "string" } } } + """).RootElement.Clone(); + + /// Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id. + public ConcurrentBag Invocations { get; } = new(); + + private FakeTool(string name, Func> handler) + { + Name = name; + _handler = handler; + } + + public static FakeTool Returning(string result, string name = "TestTool") + => new(name, (_, _, _) => Task.FromResult(ToolResult.Ok(result))); + + /// Verzögert die Ausführung — vergrößert das Zeitfenster für Races. + public static FakeTool Slow(TimeSpan delay, string result = "ok", string name = "TestTool") + => new(name, async (_, _, ct) => + { + await Task.Delay(delay, ct); + return ToolResult.Ok(result); + }); + + public static FakeTool Throwing(Exception ex, string name = "TestTool") + => new(name, (_, _, _) => throw ex); + + public Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) + { + Invocations.Add(context.AgentId); + return _handler(input, context, ct); + } +} + +internal sealed class InMemoryStateStore : ClawdDotNet.Core.State.IStateStore +{ + private readonly ConcurrentDictionary _values = new(); + + public Task GetAsync(string key, CancellationToken ct) + => Task.FromResult(_values.GetValueOrDefault(key)); + + public Task SetAsync(string key, string value, CancellationToken ct) + { + _values[key] = value; + return Task.CompletedTask; + } + + public Task DeleteAsync(string key, CancellationToken ct) + { + _values.TryRemove(key, out _); + return Task.CompletedTask; + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/RepeatAttribute.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/RepeatAttribute.cs new file mode 100644 index 0000000..5903b23 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/RepeatAttribute.cs @@ -0,0 +1,17 @@ +using System.Reflection; +using Xunit.Sdk; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +/// +/// Führt einen Theory-Test mehrfach aus. +/// +/// Race Conditions treten sporadisch auf — ein einmaliger Durchlauf sagt wenig. +/// Der Testparameter ist der Durchlauf-Index und wird üblicherweise ignoriert. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class RepeatAttribute(int count) : DataAttribute +{ + public override IEnumerable GetData(MethodInfo testMethod) + => Enumerable.Range(1, count).Select(i => new object[] { i }); +}