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:
co-authored by
Claude Opus 4.8
parent
eae13771cf
commit
6bbe9f9a80
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user