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 Deadline = TimeSpan.FromSeconds(20); /// Waits for the run, but fails instead of hanging. private static async Task WithDeadline(Task run) { var done = await Task.WhenAny(run, Task.Delay(Deadline)); done.ShouldBe(run, "Der Lauf ist nicht zurückgekommen — das ist die Verklemmung aus B8."); return await run; } // ═══════════════════════════════════════════════════════════ // A13 — the reported case // ═══════════════════════════════════════════════════════════ [Fact] public async Task A_to_B_to_A_is_aborted_instead_of_deadlocking() { 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 WithDeadline( 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 wayBack = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" }); wayBack.Delivered.ShouldBeFalse(); wayBack.Error.ShouldNotBeNull().ShouldContain("Zyklus"); wayBack.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 The_rejection_comes_back_as_a_result_not_as_a_throw() { // 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 WithDeadline( 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 A_relay_ends_at_the_depth_limit() { // 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 WithDeadline(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 tooDeep = tool.Handoffs.Single(h => h.To == "agent-d"); tooDeep.Delivered.ShouldBeFalse(); tooDeep.Error.ShouldNotBeNull().ShouldContain("Delegationstiefe"); } [Fact] public async Task Within_the_limit_the_relay_goes_through() { 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 WithDeadline( 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 Two_consecutive_runs_do_not_inherit_each_others_chain() { // 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 WithDeadline(fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default)); await WithDeadline(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 The_chain_cannot_be_bypassed_via_spawn_either() { 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 WithDeadline(fixture.Engine.ChatAsync(a, "Bitte klären", "test-instance", default)); var wayBack = tool.Handoffs.Single(h => h is { From: "agent-b", To: "agent-a" }); wayBack.Delivered.ShouldBeFalse(); wayBack.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 Two_independent_crossing_chains_do_not_deadlock() { 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 runA = fixture.Engine.ChatAsync(a, "A fragt B", "test-instance", default); var runB = fixture.Engine.ChatAsync(b, "B fragt A", "test-instance", default); var both = Task.WhenAll(runA, runB); var done = await Task.WhenAny(both, Task.Delay(Deadline)); done.ShouldBe(both, "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 runA).Status.ShouldBe(AgentRunStatus.Completed); (await runB).Status.ShouldBe(AgentRunStatus.Completed); tool.Handoffs.ShouldContain(h => !h.Delivered && h.Error!.Contains("belegt")); } [Fact] public async Task A_chat_from_a_human_keeps_waiting_as_long_as_it_takes() { // 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 firstOne = fixture.Engine.ChatAsync(a, "Erste Frage", "test-instance", default); var secondOne = fixture.Engine.ChatAsync(a, "Zweite Frage", "test-instance", default); (await WithDeadline(firstOne)).Status.ShouldBe(AgentRunStatus.Completed); (await WithDeadline(secondOne)).Status.ShouldBe(AgentRunStatus.Completed); } // ═══════════════════════════════════════════════════════════ // The chain itself // ═══════════════════════════════════════════════════════════ [Fact] public void The_chain_counts_handoffs_not_agents() { var chain = DelegationChain.Root("a"); chain.Depth.ShouldBe(0); chain.Append("b").Depth.ShouldBe(1); chain.Append("b").Append("c").Depth.ShouldBe(2); } [Fact] public void Append_does_not_change_the_callers_chain() { // Immutable: two hand-offs from the same run must not see // each other — otherwise the second would count the first as a predecessor. var chain = DelegationChain.Root("a"); var toB = chain.Append("b"); var toC = chain.Append("c"); chain.Agents.ShouldBe(["a"]); toB.Agents.ShouldBe(["a", "b"]); toC.Agents.ShouldBe(["a", "c"]); toB.Contains("c").ShouldBeFalse(); } [Fact] public void The_chain_reads_as_a_path() { DelegationChain.Root("a").Append("b").Append("c").ToString() .ShouldBe("a -> b -> c"); } }