Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs
T
RichardandClaude Opus 5.5 837a634a22 Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig
erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner
Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft.
Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch.

WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b
umbenannten Parameter angeglichen.

Dabei behoben:
- 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue"
  gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day.
- Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine
  CSS-Klasse gab - jetzt "success" wie laufende Instanzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:45:03 +02:00

149 lines
7.0 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 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<bool> condition, int timeoutMs = 2_000)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
await Task.Delay(10);
}
}