using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; using ClawdDotNet.Core.Tools; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; /// /// Test A13 der Teststrategie — Regressionstests für B8 (#206). /// /// send_message ist ein synchroner Aufruf: A hält sein Gate, während es /// auf B wartet. Rief B nun A, wartete B auf ein Gate, das A erst freigibt, wenn B /// geantwortet hat. Beide standen, bis der Timeout des äußeren Laufs sie auflöste. /// /// Die Tests prüfen die drei Zusagen aus : kein /// Agent zweimal in einer Kette, höchstens MaxDelegationDepth Weitergaben, und /// die Kette gehört zum Aufruf statt zum Agenten. /// /// Ein Zeitlimit steht bewusst an jedem Test, der verklemmen könnte: Ohne die /// Prüfung liefe er nicht rot, sondern gar nicht mehr weiter. /// public sealed class DelegationDepthTests { private static readonly TimeSpan Frist = TimeSpan.FromSeconds(20); /// Wartet auf den Lauf, scheitert aber statt zu hängen. private static async Task MitFrist(Task lauf) { var fertig = await Task.WhenAny(lauf, Task.Delay(Frist)); fertig.ShouldBe(lauf, "Der Lauf ist nicht zurückgekommen — das ist die Verklemmung aus B8."); return await lauf; } // ═══════════════════════════════════════════════════════════ // A13 — der gemeldete Fall // ═══════════════════════════════════════════════════════════ [Fact] public async Task A_nach_B_nach_A_wird_abgebrochen_statt_zu_verklemmen() { var tool = new DelegatingTool(new Dictionary { ["agent-a"] = "agent-b", ["agent-b"] = "agent-a" // zurück zu A — der Zyklus }); var fixture = new EngineFixture().WithTool(tool); var a = fixture.AddAgent("agent-a", "TestTool"); fixture.AddAgent("agent-b", "TestTool"); fixture.Client.RespondsContextually(); var result = await MitFrist( fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default)); result.Status.ShouldBe(AgentRunStatus.Completed); // Der Rückweg B → A ist abgelehnt worden, der Hinweg A → B nicht. var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" }); rueckweg.Delivered.ShouldBeFalse(); rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus"); rueckweg.Error.ShouldNotBeNull().ShouldContain("agent-a -> agent-b -> agent-a"); tool.Handoffs.Single(h => h is { From: "agent-a", To: "agent-b" }).Delivered.ShouldBeTrue(); } [Fact] public async Task Die_Ablehnung_kommt_als_Ergebnis_zurueck_nicht_als_Wurf() { // Der abgewiesene Agent soll weiterarbeiten können — er bekommt einen Grund, // keinen Abbruch. Sonst führte die Zyklusprüfung nur zu einem anderen Ausfall. var tool = new DelegatingTool(new Dictionary { ["agent-a"] = "agent-b", ["agent-b"] = "agent-a" }); var fixture = new EngineFixture().WithTool(tool); var a = fixture.AddAgent("agent-a", "TestTool"); fixture.AddAgent("agent-b", "TestTool"); fixture.Client.RespondsContextually(); var result = await MitFrist( fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default)); result.Status.ShouldBe(AgentRunStatus.Completed); result.FinalMessage.ShouldBe("Fertig"); } // ═══════════════════════════════════════════════════════════ // Die Staffel, die keinen Zyklus bildet // ═══════════════════════════════════════════════════════════ [Fact] public async Task Eine_Staffel_endet_an_der_Tiefengrenze() { // A → B → C → D wäre die Tiefe 3. Mit MaxDelegationDepth = 2 ist bei C Schluss. var tool = new DelegatingTool(new Dictionary { ["agent-a"] = "agent-b", ["agent-b"] = "agent-c", ["agent-c"] = "agent-d" }); var fixture = new EngineFixture().WithTool(tool); fixture.Engine.MaxDelegationDepth = 2; var a = fixture.AddAgent("agent-a", "TestTool"); fixture.AddAgent("agent-b", "TestTool"); fixture.AddAgent("agent-c", "TestTool"); fixture.AddAgent("agent-d", "TestTool"); fixture.Client.RespondsContextually(); await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default)); tool.Handoffs.Single(h => h.To == "agent-b").Delivered.ShouldBeTrue(); tool.Handoffs.Single(h => h.To == "agent-c").Delivered.ShouldBeTrue(); var zuTief = tool.Handoffs.Single(h => h.To == "agent-d"); zuTief.Delivered.ShouldBeFalse(); zuTief.Error.ShouldNotBeNull().ShouldContain("Delegationstiefe"); } [Fact] public async Task Innerhalb_der_Grenze_geht_die_Staffel_durch() { var tool = new DelegatingTool(new Dictionary { ["agent-a"] = "agent-b", ["agent-b"] = "agent-c" }); var fixture = new EngineFixture().WithTool(tool); fixture.Engine.MaxDelegationDepth = 2; var a = fixture.AddAgent("agent-a", "TestTool"); fixture.AddAgent("agent-b", "TestTool"); fixture.AddAgent("agent-c", "TestTool"); fixture.Client.RespondsContextually(); var result = await MitFrist( fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default)); result.Status.ShouldBe(AgentRunStatus.Completed); tool.Handoffs.ShouldAllBe(h => h.Delivered); } // ═══════════════════════════════════════════════════════════ // Die Kette gehört zum Aufruf, nicht zum Agenten // ═══════════════════════════════════════════════════════════ [Fact] public async Task Zwei_Laeufe_nacheinander_erben_die_Kette_des_anderen_nicht() { // Sonst könnte ein Agent nach einer tiefen Kette dauerhaft „verbraucht" sein. var tool = new DelegatingTool(new Dictionary { ["agent-a"] = "agent-b" }); var fixture = new EngineFixture().WithTool(tool); var a = fixture.AddAgent("agent-a", "TestTool"); fixture.AddAgent("agent-b", "TestTool"); fixture.Client.RespondsContextually(); await MitFrist(fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default)); await MitFrist(fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default)); tool.Handoffs.Count(h => h.To == "agent-b").ShouldBe(2); tool.Handoffs.ShouldAllBe(h => h.Delivered); } // ═══════════════════════════════════════════════════════════ // spawn zählt mit // ═══════════════════════════════════════════════════════════ [Fact] public async Task Auch_ueber_spawn_laesst_sich_die_Kette_nicht_umgehen() { var tool = new DelegatingTool(new Dictionary { ["agent-a"] = "agent-b", ["agent-b"] = "agent-a" }, spawn: true); var fixture = new EngineFixture().WithTool(tool); var a = fixture.AddAgent("agent-a", "TestTool"); fixture.AddAgent("agent-b", "TestTool"); fixture.Client.RespondsContextually(); await MitFrist(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default)); var rueckweg = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" }); rueckweg.Delivered.ShouldBeFalse(); rueckweg.Error.ShouldNotBeNull().ShouldContain("Zyklus"); } // ═══════════════════════════════════════════════════════════ // #461 — was die Kette grundsätzlich nicht sehen kann // ═══════════════════════════════════════════════════════════ /// /// Zwei unabhängige Ketten über Kreuz: Lauf 1 startet bei A und beauftragt B, Lauf 2 /// startet zeitgleich bei B und beauftragt A. Keine der beiden Ketten enthält den /// Agenten der anderen — beide sind für sich zyklusfrei, und trotzdem hält Lauf 1 /// gate(A) und wartet auf gate(B), während Lauf 2 gate(B) hält und auf gate(A) wartet. /// /// Das kann die Delegationskette prinzipiell nicht erkennen; nur eine Frist /// löst es auf. Ein delegierter Lauf wartet deshalb höchstens /// DelegationGateTimeout auf ein fremdes Gate und sagt danach ab. /// /// Die Pause vor der Weitergabe ist der Kern des Aufbaus: Der Fake-Client /// antwortet synchron, ohne sie liefe der erste Chat vollständig durch, bevor der /// zweite beginnt — die Ketten könnten sich gar nicht überschneiden, und der Test /// wäre grün, ohne etwas zu zeigen. /// [Fact] public async Task Zwei_unabhaengige_Ketten_ueber_Kreuz_verklemmen_sich_nicht() { var tool = new DelegatingTool(new Dictionary { ["agent-a"] = "agent-b", ["agent-b"] = "agent-a" }, delayBeforeHandoff: TimeSpan.FromMilliseconds(300)); var fixture = new EngineFixture().WithTool(tool); fixture.Engine.DelegationGateTimeout = TimeSpan.FromSeconds(1); var a = fixture.AddAgent("agent-a", "TestTool"); var b = fixture.AddAgent("agent-b", "TestTool"); fixture.Client.RespondsContextually(); var laufA = fixture.Engine.ChatAsync(a, "A fragt B", "test-instance", default); var laufB = fixture.Engine.ChatAsync(b, "B fragt A", "test-instance", default); var beide = Task.WhenAll(laufA, laufB); var fertig = await Task.WhenAny(beide, Task.Delay(Frist)); fertig.ShouldBe(beide, "Beide Laeufe warten auf das Gate des jeweils anderen (#461)."); // Beide äußeren Läufe kommen zu Ende. Mindestens eine Weitergabe ist an der // Frist gescheitert — welche, entscheidet die Reihenfolge und ist nicht // festgelegt; dass beide Läufe zurückkommen, schon. (await laufA).Status.ShouldBe(AgentRunStatus.Completed); (await laufB).Status.ShouldBe(AgentRunStatus.Completed); tool.Handoffs.ShouldContain(h => !h.Delivered && h.Error!.Contains("belegt")); } [Fact] public async Task Ein_Chat_vom_Menschen_wartet_weiter_so_lange_es_dauert() { // Die Gegenprobe zur Frist: Sie gilt nur für delegierte Läufe. Ein zweiter // Chat desselben Agenten reiht sich ein und wird bedient, auch wenn der erste // länger braucht als die Delegationsfrist. var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(600))); fixture.Engine.DelegationGateTimeout = TimeSpan.FromMilliseconds(50); var a = fixture.AddAgent("agent-a", "TestTool"); fixture.Client.RespondsContextually(); var erster = fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default); var zweiter = fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default); (await MitFrist(erster)).Status.ShouldBe(AgentRunStatus.Completed); (await MitFrist(zweiter)).Status.ShouldBe(AgentRunStatus.Completed); } // ═══════════════════════════════════════════════════════════ // Die Kette selbst // ═══════════════════════════════════════════════════════════ [Fact] public void Die_Kette_zaehlt_Weitergaben_nicht_Agenten() { var kette = DelegationChain.Root("a"); kette.Depth.ShouldBe(0); kette.Append("b").Depth.ShouldBe(1); kette.Append("b").Append("c").Depth.ShouldBe(2); } [Fact] public void Append_veraendert_die_Kette_des_Aufrufers_nicht() { // Unveränderlich: Zwei Weitergaben aus demselben Lauf dürfen sich nicht // gegenseitig sehen — sonst zählte die zweite die erste als Vorgänger. var kette = DelegationChain.Root("a"); var nachB = kette.Append("b"); var nachC = kette.Append("c"); kette.Agents.ShouldBe(["a"]); nachB.Agents.ShouldBe(["a", "b"]); nachC.Agents.ShouldBe(["a", "c"]); nachB.Contains("c").ShouldBeFalse(); } [Fact] public void Die_Kette_liest_sich_als_Weg() { DelegationChain.Root("a").Append("b").Append("c").ToString() .ShouldBe("a -> b -> c"); } }