using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; /// /// Regression tests for bug B2: _chatContexts[agentId] is a shared /// List<ChatMessage>. Only the lookup runs under lock — all Add calls in the /// loop body are unprotected. /// /// The same agent can be triggered from several sides at the same time: /// Telegram/mail tool job, the user's WebView message, send_message of /// another agent. Result: corrupt list, interleaved tool sequences and /// thus HTTP 400 errors. /// public sealed class AgentEngineConcurrencyTests { // ═══════════════════════════════════════════════════════════ // E1 — two parallel chats on the same agent // ═══════════════════════════════════════════════════════════ [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"); // Every run: one tool step, then a text answer — regardless of // how the two conversations get interleaved. 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 — stress test // ═══════════════════════════════════════════════════════════ [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); // Each of the 30 requests must be in the context exactly once — nothing lost, // nothing doubled through lost writes to the list. 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); // Wait until both have actually started. 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); // Crucial: _runningChats[agentId] used to hold only ONE CancellationTokenSource — // the second run overwrote the first. AbortChat then only aborted one, the // other ran until the run timeout. Without this time check the test would be green // although the abort did not take effect at all. sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5), "AbortChat muss beide Läufe sofort beenden, nicht erst über das Timeout"); } // ═══════════════════════════════════════════════════════════ // E4 — counter-check: different agents may run in parallel // ═══════════════════════════════════════════════════════════ [Fact] public async Task Verschiedene_Agenten_laufen_wirklich_parallel() { // Important counter-check: the fix for B2 must not accidentally lock all agents // against each other. // // Until 2026-09-24 the fake here only answered with text: the slow // tool never ran, the runs were finished immediately, and all that was checked was whether three // empty runs stay below 450 ms. That passed even with agents locking each other // - and failed under load, because it measured the machine instead of the engine. // Now every agent really calls the tool, and the overlap is proven. var tool = FakeTool.Slow(TimeSpan.FromMilliseconds(500)); var fixture = new EngineFixture().WithTool(tool); var a = fixture.AddAgent("agent-1", "TestTool"); var b = fixture.AddAgent("agent-2", "TestTool"); var c = fixture.AddAgent("agent-3", "TestTool"); fixture.Client.RespondsContextually("TestTool"); 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)); tool.MaxConcurrent.ShouldBeGreaterThan(1, "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); } }