diff --git a/ClawdDotNet.csproj b/ClawdDotNet.csproj
index 6e1f679..dde355d 100644
--- a/ClawdDotNet.csproj
+++ b/ClawdDotNet.csproj
@@ -19,10 +19,15 @@
false
+
+
+
+
diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md
index 0d8d49d..f2282d2 100644
--- a/docs/Bestandsaufnahme-2026-07.md
+++ b/docs/Bestandsaufnahme-2026-07.md
@@ -178,6 +178,12 @@ sodass `AbortChat` nur den zuletzt gestarteten Lauf abbricht.
**Fix:** Pro Agent ein `SemaphoreSlim(1,1)`, das den gesamten `ChatAsync`-Durchlauf serialisiert.
Wartende Nachrichten in eine Queue statt parallel starten.
+Beim Umsetzen kamen zwei Folgeprobleme dazu, die denselben Ursprung haben:
+- `ExecuteToolCallAsync` fing `OperationCanceledException` mit ab und gab sie als
+ Tool-Fehlerergebnis zurück. Der Abbruch griff dadurch erst einen Schritt später.
+- `send_message` an den eigenen Agenten wäre mit dem neuen Gate in einen Deadlock gelaufen
+ (der laufende Chat hält es bereits) — wird jetzt abgelehnt.
+
### B3 — `maxTokens` vermischt Abrechnungs-Budget und Kontextgröße ⚠️
[`LoopGuard.cs:22`](../src/ClawdDotNet.Core/Engine/LoopGuard.cs#L22), Defaults in
[`AgentConfig.cs:105`](../src/ClawdDotNet.Core/Config/AgentConfig.cs#L105)
@@ -521,7 +527,7 @@ Siehe K3.
1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben
-3. B2 Race Condition im Chat-Kontext
+3. ~~B2 Race Condition im Chat-Kontext~~ ✅ behoben
4. S2 yt-dlp-Injection
5. S3 API-Key-Leak
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index cbf5376..4f2d993 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -21,9 +21,23 @@ public sealed class AgentEngine : IAgentMessageRouter
private readonly Dictionary> _chatHistories = new();
private readonly Dictionary> _chatContexts = new();
- private readonly Dictionary _runningChats = new();
private readonly Lock _lock = new();
+ ///
+ /// Alle aktiven Chat-Läufe je Agent — laufende wie wartende. Mehrere Quellen können
+ /// denselben Agenten gleichzeitig ansprechen (WebView, ToolJob, AgentComm), deshalb
+ /// eine Liste: AbortChat muss jeden davon erreichen.
+ ///
+ private readonly Dictionary> _runningChats = new();
+
+ ///
+ /// Serialisiert ChatAsync pro Agent. Der Konversationskontext ist eine geteilte
+ /// Liste — liefen zwei Chats desselben Agenten gleichzeitig, verschränkten sich ihre
+ /// Nachrichten zu einer ungültigen Tool-Sequenz, die die API mit HTTP 400 ablehnt.
+ /// Verschiedene Agenten bleiben unabhängig voneinander.
+ ///
+ private readonly Dictionary _agentGates = new();
+
private Func>? _agentConfigProvider;
private Func? _agentDirResolver;
private string _instanceId = "";
@@ -227,18 +241,53 @@ public sealed class AgentEngine : IAgentMessageRouter
string instanceId,
CancellationToken externalCt,
string? source = null)
+ {
+ // Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
+ // wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
+ using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
+ RegisterRun(agentConfig.AgentId, runCts);
+
+ var gate = GetAgentGate(agentConfig.AgentId);
+ try
+ {
+ await gate.WaitAsync(runCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ UnregisterRun(agentConfig.AgentId, runCts);
+ return new AgentRunResult(
+ agentConfig.AgentId, AgentRunStatus.Cancelled, "[Chat abgebrochen]",
+ 0, 0, TimeSpan.Zero);
+ }
+
+ try
+ {
+ return await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
+ }
+ finally
+ {
+ UnregisterRun(agentConfig.AgentId, runCts);
+ gate.Release();
+ }
+ }
+
+ private async Task ChatCoreAsync(
+ AgentConfig agentConfig,
+ string userMessage,
+ string instanceId,
+ CancellationToken runCt,
+ string? source)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
var sw = Stopwatch.StartNew();
+ // Die Timeout-Uhr läuft erst ab hier — Wartezeit in der Warteschlange
+ // darf den Lauf nicht aufzehren.
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
- using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token);
var ct = linkedCts.Token;
- lock (_lock)
- _runningChats[agentConfig.AgentId] = linkedCts;
-
try
{
var tools = _toolRegistry.GetForAgent(agentConfig);
@@ -376,10 +425,46 @@ public sealed class AgentEngine : IAgentMessageRouter
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
- finally
+ }
+
+ // ─── Nebenläufigkeits-Helfer ───
+
+ private SemaphoreSlim GetAgentGate(string agentId)
+ {
+ lock (_lock)
{
- lock (_lock)
- _runningChats.Remove(agentConfig.AgentId);
+ if (!_agentGates.TryGetValue(agentId, out var gate))
+ {
+ gate = new SemaphoreSlim(1, 1);
+ _agentGates[agentId] = gate;
+ }
+ return gate;
+ }
+ }
+
+ private void RegisterRun(string agentId, CancellationTokenSource cts)
+ {
+ lock (_lock)
+ {
+ if (!_runningChats.TryGetValue(agentId, out var list))
+ {
+ list = new List();
+ _runningChats[agentId] = list;
+ }
+ list.Add(cts);
+ }
+ }
+
+ private void UnregisterRun(string agentId, CancellationTokenSource cts)
+ {
+ lock (_lock)
+ {
+ if (!_runningChats.TryGetValue(agentId, out var list))
+ return;
+
+ list.Remove(cts);
+ if (list.Count == 0)
+ _runningChats.Remove(agentId);
}
}
@@ -397,12 +482,38 @@ public sealed class AgentEngine : IAgentMessageRouter
return _runningChats.ContainsKey(agentId);
}
- public void AbortChat(string agentId)
+ ///
+ /// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
+ /// die beim nächsten Schritt tatsächlich an das Modell gehen.
+ /// Nützlich für Diagnose und Kontextgrößen-Anzeige.
+ ///
+ public IReadOnlyList GetChatContext(string agentId)
{
+ lock (_lock)
+ return _chatContexts.TryGetValue(agentId, out var ctx)
+ ? ctx.ToList()
+ : [];
+ }
+
+ ///
+ /// Bricht ALLE Chat-Läufe des Agenten ab — laufende wie wartende.
+ ///
+ public void AbortChat(string agentId)
+ {
+ List toCancel;
lock (_lock)
{
- if (_runningChats.TryGetValue(agentId, out var cts))
- cts.Cancel();
+ if (!_runningChats.TryGetValue(agentId, out var list))
+ return;
+ toCancel = list.ToList();
+ }
+
+ // Außerhalb des Locks abbrechen: Cancel führt Continuations aus, die
+ // ihrerseits wieder auf _lock zugreifen können.
+ foreach (var cts in toCancel)
+ {
+ try { cts.Cancel(); }
+ catch (ObjectDisposedException) { /* Lauf war bereits fertig */ }
}
}
@@ -514,6 +625,12 @@ public sealed class AgentEngine : IAgentMessageRouter
if (configs is null)
return new AgentMessageResult(false, null, "Agent config provider not set.");
+ // Selbstadressierung würde am Agent-Gate hängen bleiben: Der laufende Chat
+ // hält es bereits und würde auf sich selbst warten.
+ if (fromAgentId == toAgentId)
+ return new AgentMessageResult(false, null,
+ "Ein Agent kann sich keine Nachricht an sich selbst schicken.");
+
var targetConfig = configs.FirstOrDefault(a => a.AgentId == toAgentId);
if (targetConfig is null)
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
@@ -678,6 +795,12 @@ public sealed class AgentEngine : IAgentMessageRouter
logger.LogWarning("Tool access denied: {Message}", ex.Message);
return JsonSerializer.Serialize(new { error = ex.Message });
}
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
+ // Schritt weiter und der Abbruch greift erst verzögert.
+ throw;
+ }
catch (Exception ex)
{
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
diff --git a/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs
new file mode 100644
index 0000000..0716be9
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Engine/AgentEngineConcurrencyTests.cs
@@ -0,0 +1,146 @@
+using ClawdDotNet.Core.Engine;
+using ClawdDotNet.Core.Tests.Infrastructure;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Engine;
+
+///
+/// Regressionstests für Bug B2: _chatContexts[agentId] ist eine geteilte
+/// List<ChatMessage>. 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.
+///
+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 condition, int timeoutMs = 2_000)
+ {
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
+ await Task.Delay(10);
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
new file mode 100644
index 0000000..3a635d8
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
@@ -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;
+
+///
+/// Baut eine einsatzbereite AgentEngine mit Fake-Client, Fake-Tools und
+/// In-Memory-State zusammen.
+///
+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 _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();
+
+ _agents.Add(config);
+ return config;
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs
index 62ee79e..a70c164 100644
--- a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs
@@ -16,6 +16,7 @@ internal sealed class FakeChatClient : IChatCompletionClient
{
private readonly Queue> _responses = new();
private Func? _fallback;
+ private readonly Lock _gate = new();
/// Alle empfangenen Requests, als tiefe Kopien.
public List 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;
+ };
}
/// Antwortet mit einer bestimmten Prompt-Token-Zahl — für Compaction-Schwellen.
@@ -76,22 +83,47 @@ internal sealed class FakeChatClient : IChatCompletionClient
return this;
}
+ public FakeChatClient AlwaysRespondsWithToolCall(string toolName = "TestTool")
+ {
+ _fallback = _ => ToolCallResponse([(toolName, "{}", null)]);
+ return this;
+ }
+
+ ///
+ /// 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.
+ ///
+ public FakeChatClient RespondsContextually(string toolName = "TestTool")
+ {
+ _fallback = req => req.Messages.LastOrDefault()?.Role == "tool"
+ ? TextResponse("Fertig")
+ : ToolCallResponse([(toolName, "{}", null)]);
+ return this;
+ }
+
// ─── IChatCompletionClient ───
public Task 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? 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 ───
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs
new file mode 100644
index 0000000..1e9c190
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs
@@ -0,0 +1,70 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+using ClawdDotNet.Core.Tools;
+
+namespace ClawdDotNet.Core.Tests.Infrastructure;
+
+///
+/// Konfigurierbares Tool für Engine-Tests: liefert feste Ergebnisse, kann verzögern
+/// (um Nebenläufigkeit zu provozieren) oder werfen.
+///
+internal sealed class FakeTool : IAgentTool
+{
+ private readonly Func> _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();
+
+ /// Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id.
+ public ConcurrentBag Invocations { get; } = new();
+
+ private FakeTool(string name, Func> handler)
+ {
+ Name = name;
+ _handler = handler;
+ }
+
+ public static FakeTool Returning(string result, string name = "TestTool")
+ => new(name, (_, _, _) => Task.FromResult(ToolResult.Ok(result)));
+
+ /// Verzögert die Ausführung — vergrößert das Zeitfenster für Races.
+ 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 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 _values = new();
+
+ public Task 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;
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/RepeatAttribute.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/RepeatAttribute.cs
new file mode 100644
index 0000000..5903b23
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/RepeatAttribute.cs
@@ -0,0 +1,17 @@
+using System.Reflection;
+using Xunit.Sdk;
+
+namespace ClawdDotNet.Core.Tests.Infrastructure;
+
+///
+/// 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.
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+public sealed class RepeatAttribute(int count) : DataAttribute
+{
+ public override IEnumerable