using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; namespace ClawdDotNet.Core.Tests.Engine; /// /// What an abort in the middle of a turn does to the conversation context. /// /// The context is a shared list, changed in place. If a run aborts /// between the tool call and its answer, an assistant message /// with tool_calls remains that no answer follows — a message sequence the /// interface does not accept. /// 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); // Wait until the call is surely in place and the tool is running. 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."); // The substitute answer also says what happened. An empty answer would be formally // valid, but would leave the model puzzling why its call yielded nothing. 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() { // The actual point: not the form of the context counts, but whether the // agent still works afterwards. A second chat has to run through. 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); // And the sequence that was sent in doing so was completely answered. 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); } }