using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tests.Infrastructure; using ClawdDotNet.Core.Tools; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; /// /// Test A13 of the test strategy — regression tests for B8 (#206). /// /// send_message is a synchronous call: A holds its gate while it /// waits for B. If B then called A, B waited for a gate that A only releases once B /// has answered. Both stood still until the timeout of the outer run resolved it. /// /// The tests check the three promises from : no /// agent twice in a chain, at most MaxDelegationDepth hand-offs, and /// the chain belongs to the call instead of to the agent. /// /// A time limit is deliberately on every test that could deadlock: without the /// check it would not turn red, but not continue at all. /// public sealed class DelegationDepthTests { private static readonly TimeSpan Frist = TimeSpan.FromSeconds(20); /// Waits for the run, but fails instead of hanging. 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 — the reported case // ═══════════════════════════════════════════════════════════ [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" // back to A — the cycle }); 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); // The way back B → A was rejected, the way there A → B was not. 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() { // The rejected agent should be able to continue working — it gets a reason, // not an abort. Otherwise the cycle check would only lead to a different failure. 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"); } // ═══════════════════════════════════════════════════════════ // The relay that does not form a cycle // ═══════════════════════════════════════════════════════════ [Fact] public async Task Eine_Staffel_endet_an_der_Tiefengrenze() { // A → B → C → D would be depth 3. With MaxDelegationDepth = 2 it stops at C. 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); } // ═══════════════════════════════════════════════════════════ // The chain belongs to the call, not to the agent // ═══════════════════════════════════════════════════════════ [Fact] public async Task Zwei_Laeufe_nacheinander_erben_die_Kette_des_anderen_nicht() { // Otherwise an agent could be permanently "used up" after a deep chain. 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 counts too // ═══════════════════════════════════════════════════════════ [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 — what the chain fundamentally cannot see // ═══════════════════════════════════════════════════════════ /// /// Two independent chains crossing over: run 1 starts at A and commissions B, run 2 /// starts at the same time at B and commissions A. Neither chain contains the /// other's agent — both are cycle-free on their own, and yet run 1 holds /// gate(A) and waits for gate(B), while run 2 holds gate(B) and waits for gate(A). /// /// The delegation chain fundamentally cannot recognize that; only a deadline /// resolves it. A delegated run therefore waits at most /// DelegationGateTimeout for a foreign gate and refuses afterwards. /// /// The pause before the hand-off is the core of the setup: the fake client /// answers synchronously; without it the first chat would run through completely before the /// second begins — the chains could not overlap at all, and the test /// would be green without showing anything. /// [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)."); // Both outer runs come to an end. At least one hand-off failed at the // deadline — which one is decided by the order and is not // fixed; that both runs come back is. (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() { // The counter-check to the deadline: it only applies to delegated runs. A second // chat of the same agent queues up and is served, even if the first one // takes longer than the delegation deadline. 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); } // ═══════════════════════════════════════════════════════════ // The chain itself // ═══════════════════════════════════════════════════════════ [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() { // Immutable: two hand-offs from the same run must not see // each other — otherwise the second would count the first as a predecessor. 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"); } }