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 AbortContextTests { [Fact] public async Task An_abort_during_the_tool_leaves_no_unanswered_call() { 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 run = 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 run; Assert.Equal(AgentRunStatus.Cancelled, result.Status); var context = fixture.Engine.GetChatContext("a1"); var openCalls = context .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) .SelectMany(m => m.ToolCalls!) .Select(tc => tc.Id) .Where(id => !context.Any(m => m.Role == "tool" && m.ToolCallId == id)) .ToList(); Assert.True(openCalls.Count == 0, $"Unbeantwortete Werkzeugaufrufe im Kontext: {string.Join(", ", openCalls)}. " + "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 substitute = context.Last(m => m.Role == "tool"); Assert.Contains("abgebrochen", substitute.Content, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task After_an_abort_the_agent_can_be_addressed_again() { // 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 firstOne = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None); await Task.Delay(300); fixture.Engine.AbortChat("a1"); await firstOne; var secondOne = await fixture.Engine.ChatAsync(agent, "Und jetzt?", "inst", CancellationToken.None); Assert.Equal(AgentRunStatus.Completed, secondOne.Status); Assert.Equal("Zweite Antwort.", secondOne.FinalMessage); // And the sequence that was sent in doing so was completely answered. var sent = fixture.Client.ReceivedRequests[^1].Messages; var open = sent .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) .SelectMany(m => m.ToolCalls!) .Select(tc => tc.Id) .Where(id => !sent.Any(m => m.Role == "tool" && m.ToolCallId == id)) .ToList(); Assert.Empty(open); } }