using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Tests.Infrastructure; using ClawdDotNet.Core.Tools; using Shouldly; namespace ClawdDotNet.Core.Tests.Organisation; /// /// #683: The address book is enforced by the engine — not merely described in a prompt that /// a model may or may not follow. And every agent is told where it stands. /// public sealed class AddressBookEnforcementTests { /// marketing (lead: marketing-lead) › amazon (lead: amazon-lead, ads); accounting: bookkeeper. private static (EngineFixture Fixture, List Units) Shop() { var fixture = new EngineFixture(); var units = new List { new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" }, new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead", Goal = "Sell on Amazon." }, new() { Id = "accounting", Name = "Accounting" } }; foreach (var (id, unit) in new[] { ("marketing-lead", "marketing"), ("amazon-lead", "amazon"), ("ads", "amazon"), ("bookkeeper", "accounting") }) fixture.AddAgent(id).UnitId = unit; fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents); return (fixture, units); } // ═══════════════════ messages and spawns ═══════════════════ [Fact] public async Task A_message_outside_the_address_book_is_refused_with_the_way_that_is_open() { var (shop, _) = Shop(); var result = await shop.Engine.SendMessageAsync("ads", "bookkeeper", "Kannst du zahlen?", "inst", null, default); result.Delivered.ShouldBeFalse(); result.Error.ShouldNotBeNull().ShouldContain("not in your address book"); result.Error.ShouldContain("amazon-lead", Case.Sensitive, "the way that is open: the own lead"); shop.Client.CallCount.ShouldBe(0, "refused before any model call"); } [Fact] public async Task A_message_to_the_own_lead_goes_through() { var (shop, _) = Shop(); shop.Client.RespondsWithText("Verstanden."); var result = await shop.Engine.SendMessageAsync("ads", "amazon-lead", "Frage", "inst", null, default); result.Delivered.ShouldBeTrue(result.Error); } [Fact] public async Task A_spawn_is_held_to_the_same_address_book() { var (shop, _) = Shop(); var result = await shop.Engine.SpawnAgentAsync("ads", "bookkeeper", "Buche das", "inst", null, default); result.Started.ShouldBeFalse(); result.Error.ShouldNotBeNull().ShouldContain("not in your address book"); } [Fact] public void The_agent_list_shows_only_the_address_book() { var (shop, _) = Shop(); shop.Engine.ListAgents("ads").Select(a => a.AgentId).ShouldBe(["amazon-lead"]); } [Fact] public void Without_units_the_agent_list_is_unchanged() { var fixture = new EngineFixture(); fixture.AddAgent("a"); fixture.AddAgent("b"); fixture.AddAgent("c"); fixture.Engine.ListAgents("a").Select(x => x.AgentId).ShouldBe(["b", "c"], ignoreOrder: true); } // ═══════════════════ delegation depth ═══════════════════ [Fact] public async Task The_delegation_limit_follows_the_depth_of_the_tree() { // Five levels deep: with the old fixed limit of 3, the bottom could not be reached. var fixture = new EngineFixture(); var units = new List(); string? parent = null; for (var level = 1; level <= 5; level++) { units.Add(new UnitConfig { Id = $"u{level}", Name = $"U{level}", ParentId = parent, LeaderAgentId = $"lead{level}" }); fixture.AddAgent($"lead{level}").UnitId = $"u{level}"; parent = $"u{level}"; } fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents); fixture.Client.RespondsWithText("ok"); // lead1 → lead2 → lead3 → lead4 → lead5: the fourth hand-over. var chain = DelegationChain.Root("lead1").Append("lead2").Append("lead3").Append("lead4"); var result = await fixture.Engine.SendMessageAsync("lead4", "lead5", "weiter", "inst", chain, default); result.Delivered.ShouldBeTrue(result.Error); } // ═══════════════════ the organisation section ═══════════════════ [Fact] public async Task Every_run_tells_the_agent_its_unit_and_its_lead() { var (shop, _) = Shop(); shop.Client.RespondsWithText("ok"); await shop.Engine.RunAsync(shop.Agents.First(a => a.AgentId == "ads"), "Los", "inst", default); var system = shop.Client.ReceivedRequests[0].Messages[0].Content!; system.ShouldContain(OrgBriefing.Heading); system.ShouldContain("\"Amazon\" (Marketing / Amazon)"); system.ShouldContain("Your lead: amazon-lead"); system.ShouldContain("Goal of your unit: Sell on Amazon."); system.ShouldNotContain("bookkeeper", Case.Sensitive, "not in the address book — not in the prompt either"); } [Fact] public async Task A_move_reaches_a_running_chat_context() { var (shop, units) = Shop(); var ads = shop.Agents.First(a => a.AgentId == "ads"); shop.Client.RespondsWithText("ok").RespondsWithText("ok"); await shop.Engine.ChatAsync(ads, "Hallo", "inst", default); shop.Client.ReceivedRequests[0].Messages[0].Content!.ShouldContain("Your lead: amazon-lead"); // Moved to accounting, which has no lead: the next run knows it. ads.UnitId = "accounting"; ads.UnitSinceUtc = DateTime.UtcNow; await shop.Engine.ChatAsync(ads, "Und jetzt?", "inst", default); var system = shop.Client.ReceivedRequests[1].Messages[0].Content!; system.ShouldContain("\"Accounting\""); system.ShouldContain("this is recent"); system.ShouldContain("There is no lead agent above you"); system.ShouldStartWith("Du bist ads.", Case.Sensitive, "the rest of the prompt is untouched"); } [Fact] public async Task Without_units_the_prompt_has_no_organisation_section() { var fixture = new EngineFixture(); var agent = fixture.AddAgent("solo"); fixture.Client.RespondsWithText("ok"); await fixture.Engine.RunAsync(agent, "Los", "inst", default); fixture.Client.ReceivedRequests[0].Messages[0].Content!.ShouldNotContain(OrgBriefing.Heading); } [Fact] public void The_section_is_replaced_not_stacked() { var once = OrgBriefing.WithSection("Base.", "# Organisation\nA"); var twice = OrgBriefing.WithSection(once, "# Organisation\nB"); twice.ShouldBe("Base.\n\n# Organisation\nB"); OrgBriefing.WithSection(twice, null).ShouldBe("Base."); } }