B2 beheben: Chat-Laeufe pro Agent serialisieren

Der Konversationskontext _chatContexts[agentId] ist eine geteilte List<ChatMessage>.
Nur der Lookup lief unter Lock, alle Add-Aufrufe im Schleifenkoerper waren
ungeschuetzt. Da WebView, ToolJob-Wakeups und AgentComm denselben Agenten
gleichzeitig ansprechen koennen, verschraenkten sich ihre Nachrichten zu einer
ungueltigen Tool-Sequenz, die die API mit HTTP 400 ablehnt.

ChatAsync laeuft jetzt hinter einem SemaphoreSlim(1,1) pro Agent; verschiedene
Agenten bleiben unabhaengig. Die Timeout-Uhr startet erst nach dem Eintritt,
damit Wartezeit in der Warteschlange den Lauf nicht aufzehrt.

Zwei Folgeprobleme mit demselben Ursprung:

- _runningChats hielt nur EINE CancellationTokenSource je Agent; der zweite Lauf
  ueberschrieb den ersten. AbortChat brach dadurch nur einen ab, der andere lief
  bis ins Run-Timeout. Jetzt eine Liste, die auch wartende Laeufe erfasst.
- ExecuteToolCallAsync fing OperationCanceledException mit ab und gab sie als
  Tool-Ergebnis zurueck, wodurch der Abbruch erst einen Schritt spaeter griff.
  Cancellation wird nun durchgereicht.

Ausserdem: send_message an den eigenen Agenten wird abgelehnt — es waere mit dem
neuen Gate in einen Deadlock gelaufen.

Neu: GetChatContext(agentId) als Momentaufnahme des Kontexts, fuer Diagnose und
Kontextgroessen-Anzeige.

Build-Fix: Das WinForms-Projekt globbt **/*.cs und kompilierte dadurch die
Test-Quellen mit. tests\** wird jetzt wie src\** ausgeschlossen.

Alle 49 Tests gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-27 10:55:04 +02:00
co-authored by Claude Opus 4.8
parent eae13771cf
commit 6bbe9f9a80
8 changed files with 489 additions and 27 deletions
@@ -0,0 +1,146 @@
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// Regressionstests für Bug B2: _chatContexts[agentId] ist eine geteilte
/// List&lt;ChatMessage&gt;. 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.
/// </summary>
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<bool> condition, int timeoutMs = 2_000)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
await Task.Delay(10);
}
}
@@ -0,0 +1,63 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Baut eine einsatzbereite AgentEngine mit Fake-Client, Fake-Tools und
/// In-Memory-State zusammen.
/// </summary>
internal sealed class EngineFixture
{
public FakeChatClient Client { get; } = new();
public ToolRegistry Registry { get; } = new();
public InMemoryStateStore StateStore { get; } = new();
public AgentEngine Engine { get; }
private readonly List<AgentConfig> _agents = new();
public EngineFixture()
{
Engine = new AgentEngine(
Client,
Registry,
new PermissionGate(),
StateStore,
TestLogging.Factory);
// Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests.
Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null);
}
public EngineFixture WithTool(IAgentTool tool)
{
Registry.Register(tool);
return this;
}
public AgentConfig AddAgent(string agentId, params string[] toolNames)
{
var config = new AgentConfig
{
AgentId = agentId,
DisplayName = agentId,
Model = "test/model",
SystemPrompt = $"Du bist {agentId}.",
LoopGuard = new LoopGuardConfig
{
MaxSteps = 50,
MaxCumulativeTokens = 10_000_000,
TimeoutSeconds = 60,
MaxContextTokens = 10_000_000 // Compaction in Engine-Tests ausschalten
}
};
foreach (var name in toolNames)
config.Tools[name] = new Dictionary<string, object?>();
_agents.Add(config);
return config;
}
}
@@ -16,6 +16,7 @@ internal sealed class FakeChatClient : IChatCompletionClient
{
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
private Func<ChatRequest, ChatResponse>? _fallback;
private readonly Lock _gate = new();
/// <summary>Alle empfangenen Requests, als tiefe Kopien.</summary>
public List<ChatRequest> ReceivedRequests { get; } = new();
@@ -35,20 +36,26 @@ internal sealed class FakeChatClient : IChatCompletionClient
public FakeChatClient RespondsWithToolCalls(params (string Tool, string Args, string? Id)[] calls)
{
var toolCalls = calls.Select((c, i) => new ToolCall
_responses.Enqueue(_ => ToolCallResponse(calls));
return this;
}
private static ChatResponse ToolCallResponse((string Tool, string Args, string? Id)[] calls)
{
// Ids müssen pro Antwort eindeutig sein, sonst kollidieren parallele Läufe.
var toolCalls = calls.Select(c => new ToolCall
{
Id = c.Id ?? $"call_{Guid.NewGuid():N}"[..12],
Id = c.Id ?? "call_" + Guid.NewGuid().ToString("N")[..12],
Type = "function",
Function = new ToolCallFunction { Name =c.Tool, Arguments = c.Args }
Function = new ToolCallFunction { Name = c.Tool, Arguments = c.Args }
}).ToList();
_responses.Enqueue(_ => new ChatResponse
return new ChatResponse
{
Id = "resp_" + ReceivedRequests.Count,
Id = "resp",
Choices = [new Choice { Index = 0, Message = new ChatMessage { Role = "assistant", ToolCalls = toolCalls } }],
Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 }
});
return this;
};
}
/// <summary>Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen.</summary>
@@ -76,22 +83,47 @@ internal sealed class FakeChatClient : IChatCompletionClient
return this;
}
public FakeChatClient AlwaysRespondsWithToolCall(string toolName = "TestTool")
{
_fallback = _ => ToolCallResponse([(toolName, "{}", null)]);
return this;
}
/// <summary>
/// Antwortet passend zum jeweiligen Gesprächsstand: nach einer Tool-Antwort
/// kommt Text, sonst ein Tool-Aufruf. Damit verhält sich der Fake auch dann
/// sinnvoll, wenn mehrere Konversationen ineinander verschränkt laufen.
/// </summary>
public FakeChatClient RespondsContextually(string toolName = "TestTool")
{
_fallback = req => req.Messages.LastOrDefault()?.Role == "tool"
? TextResponse("Fertig")
: ToolCallResponse([(toolName, "{}", null)]);
return this;
}
// ─── IChatCompletionClient ───
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ReceivedRequests.Add(DeepClone(request));
if (_responses.Count > 0)
return Task.FromResult(_responses.Dequeue()(request));
// Erst kopieren, dann auswerten: Die Engine mutiert die Original-Liste
// weiter, und bei Nebenläufigkeitstests soll der Fake daran nicht scheitern.
var snapshot = DeepClone(request);
lock (_gate)
ReceivedRequests.Add(snapshot);
if (_fallback is not null)
return Task.FromResult(_fallback(request));
Func<ChatRequest, ChatResponse>? responder;
lock (_gate)
responder = _responses.Count > 0 ? _responses.Dequeue() : _fallback;
throw new InvalidOperationException(
$"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " +
"keine Antwort mehr programmiert.");
if (responder is null)
throw new InvalidOperationException(
$"FakeChatClient: unerwarteter Aufruf Nr. {ReceivedRequests.Count} — " +
"keine Antwort mehr programmiert.");
return Task.FromResult(responder(snapshot));
}
// ─── Helfer ───
@@ -0,0 +1,70 @@
using System.Collections.Concurrent;
using System.Text.Json;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Konfigurierbares Tool für Engine-Tests: liefert feste Ergebnisse, kann verzögern
/// (um Nebenläufigkeit zu provozieren) oder werfen.
/// </summary>
internal sealed class FakeTool : IAgentTool
{
private readonly Func<JsonElement, AgentToolContext, CancellationToken, Task<ToolResult>> _handler;
public string Name { get; }
public string Description => $"Test-Tool {Name}";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{ "type": "object", "properties": { "action": { "type": "string" } } }
""").RootElement.Clone();
/// <summary>Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id.</summary>
public ConcurrentBag<string> Invocations { get; } = new();
private FakeTool(string name, Func<JsonElement, AgentToolContext, CancellationToken, Task<ToolResult>> handler)
{
Name = name;
_handler = handler;
}
public static FakeTool Returning(string result, string name = "TestTool")
=> new(name, (_, _, _) => Task.FromResult(ToolResult.Ok(result)));
/// <summary>Verzögert die Ausführung — vergrößert das Zeitfenster für Races.</summary>
public static FakeTool Slow(TimeSpan delay, string result = "ok", string name = "TestTool")
=> new(name, async (_, _, ct) =>
{
await Task.Delay(delay, ct);
return ToolResult.Ok(result);
});
public static FakeTool Throwing(Exception ex, string name = "TestTool")
=> new(name, (_, _, _) => throw ex);
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
Invocations.Add(context.AgentId);
return _handler(input, context, ct);
}
}
internal sealed class InMemoryStateStore : ClawdDotNet.Core.State.IStateStore
{
private readonly ConcurrentDictionary<string, string> _values = new();
public Task<string?> GetAsync(string key, CancellationToken ct)
=> Task.FromResult(_values.GetValueOrDefault(key));
public Task SetAsync(string key, string value, CancellationToken ct)
{
_values[key] = value;
return Task.CompletedTask;
}
public Task DeleteAsync(string key, CancellationToken ct)
{
_values.TryRemove(key, out _);
return Task.CompletedTask;
}
}
@@ -0,0 +1,17 @@
using System.Reflection;
using Xunit.Sdk;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Führt einen Theory-Test mehrfach aus.
///
/// Race Conditions treten sporadisch auf — ein einmaliger Durchlauf sagt wenig.
/// Der Testparameter ist der Durchlauf-Index und wird üblicherweise ignoriert.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class RepeatAttribute(int count) : DataAttribute
{
public override IEnumerable<object[]> GetData(MethodInfo testMethod)
=> Enumerable.Range(1, count).Select(i => new object[] { i });
}