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>
149 lines
6.9 KiB
C#
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<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.
|
|
/// </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);
|
|
}
|
|
}
|