Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
3.6 KiB
C#
93 lines
3.6 KiB
C#
using ClawdDotNet.Core.Engine;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Engine;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|