Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs
T
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:18:43 +02:00

149 lines
6.9 KiB
C#

using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// Regression tests for bug B2: _chatContexts[agentId] is a shared
/// List&lt;ChatMessage&gt;. 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.
/// </summary>
public sealed class AgentEngineConcurrencyTests
{
// ═══════════════════════════════════════════════════════════
// E1 — two parallel chats on the same agent
// ═══════════════════════════════════════════════════════════
[Theory]
[Repeat(15)]
public async Task Two_parallel_chats_leave_a_valid_context(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 Many_parallel_chats_do_not_corrupt_the_context()
{
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_aborts_all_running_chats_of_the_agent()
{
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 Different_agents_really_run_in_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<bool> condition, int timeoutMs = 2_000)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
await Task.Delay(10);
}
}