using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; namespace ClawdDotNet.Core.Tests.Engine; /// /// Was ein Abbruch mitten im Zug mit dem Gesprächskontext macht. /// /// Der Kontext ist eine geteilte, an Ort und Stelle veränderte Liste. Bricht ein Lauf /// zwischen dem Werkzeugaufruf und seiner Antwort ab, bleibt eine assistant-Nachricht /// mit tool_calls stehen, auf die keine Antwort folgt — eine Nachrichtenfolge, die die /// Schnittstelle nicht annimmt. /// public class AbbruchKontextTests { [Fact] public async Task Ein_Abbruch_waehrend_des_Werkzeugs_hinterlaesst_keinen_unbeantworteten_Aufruf() { var fixture = new EngineFixture().WithTool( FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool")); var agent = fixture.AddAgent("a1", "LangsamTool"); fixture.Client .RespondsWithToolCall("LangsamTool") .AlwaysRespondsWithText("Fertig."); var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None); // Warten, bis der Aufruf sicher steht und das Werkzeug läuft. await Task.Delay(300); fixture.Engine.AbortChat("a1"); var result = await lauf; Assert.Equal(AgentRunStatus.Cancelled, result.Status); var kontext = fixture.Engine.GetChatContext("a1"); var offeneAufrufe = kontext .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) .SelectMany(m => m.ToolCalls!) .Select(tc => tc.Id) .Where(id => !kontext.Any(m => m.Role == "tool" && m.ToolCallId == id)) .ToList(); Assert.True(offeneAufrufe.Count == 0, $"Unbeantwortete Werkzeugaufrufe im Kontext: {string.Join(", ", offeneAufrufe)}. " + "Eine solche Folge lehnt die Schnittstelle mit HTTP 400 ab — der Agent ist " + "danach dauerhaft blockiert."); // Die Ersatzantwort sagt auch, was passiert ist. Eine leere Antwort wäre formal // gültig, ließe das Modell aber rätseln, warum sein Aufruf nichts ergab. var ersatz = kontext.Last(m => m.Role == "tool"); Assert.Contains("abgebrochen", ersatz.Content, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Nach_einem_Abbruch_laesst_sich_der_Agent_wieder_ansprechen() { // Der eigentliche Punkt: Nicht die Form des Kontexts zählt, sondern ob der // Agent danach noch arbeitet. Ein zweiter Chat muss durchlaufen. var fixture = new EngineFixture().WithTool( FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool")); var agent = fixture.AddAgent("a1", "LangsamTool"); fixture.Client .RespondsWithToolCall("LangsamTool") .AlwaysRespondsWithText("Zweite Antwort."); var erster = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None); await Task.Delay(300); fixture.Engine.AbortChat("a1"); await erster; var zweiter = await fixture.Engine.ChatAsync(agent, "Und jetzt?", "inst", CancellationToken.None); Assert.Equal(AgentRunStatus.Completed, zweiter.Status); Assert.Equal("Zweite Antwort.", zweiter.FinalMessage); // Und die Folge, die dabei gesendet wurde, war vollständig beantwortet. var gesendet = fixture.Client.ReceivedRequests[^1].Messages; var offen = gesendet .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) .SelectMany(m => m.ToolCalls!) .Select(tc => tc.Id) .Where(id => !gesendet.Any(m => m.Role == "tool" && m.ToolCallId == id)) .ToList(); Assert.Empty(offen); } }