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