using ClawdDotNet.Core.Cases; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Cases; /// /// #690: The deciding instance at the top — a human, an agent, or the human first with the /// agent behind. The agent decides and does not carry out, its decisions are presented to the /// human, and the deadline holds for it too. /// public sealed class TopRoleTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "top-" + Guid.NewGuid().ToString("N")[..8]); private readonly SqliteTaskRepository _tasks; private readonly SqliteCaseRepository _cases; private readonly TaskboardService _board; private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero)); private readonly CaseSettings _settings = new() { DeadlineHours = 4 }; private readonly List _called = []; private readonly List _agents = [ new() { AgentId = "ceo", Tools = { ["Taskboard"] = new(), ["Memory"] = new() } }, new() { AgentId = "buy-lead", UnitId = "purchasing" }, new() { AgentId = "buyer", UnitId = "purchasing" }, new() { AgentId = "dev-lead", UnitId = "devops" } ]; private readonly CaseDesk _desk; /// purchasing (buy-lead; buyer), devops (dev-lead) — and ceo above both, in no unit. private OrgChart Org => new( [ new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" }, new UnitConfig { Id = "devops", Name = "DevOps", LeaderAgentId = "dev-lead" } ], _agents); public TopRoleTests() { var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _tasks = new SqliteTaskRepository(storage); _cases = new SqliteCaseRepository(storage); _board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks")); _desk = new CaseDesk(_cases, _board, () => Org, () => _settings, NullLogger.Instance, _clock, (record, _) => { _called.Add(record); return Task.CompletedTask; }); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleaning up is secondary */ } } private void Top(TopMode mode, string? agent = "ceo", double hours = 2) => _settings.Top = new TopSettings { Mode = mode, AgentId = agent, HumanFirstHours = hours }; private async Task EscalateFromTheLastLead() { var outcome = await _desk.EscalateAsync(new EscalationRequest("buy-lead", AgentTrust.Standard, "Budget for a second warehouse", "Do we rent a second warehouse for the season?", "Compared offers.", "A yes or no.", null, "run-1"), default); outcome.Ok.ShouldBeTrue(outcome.Message); return outcome.Case!; } private void Advance(TimeSpan by) => _clock.Advance(by); // ═══════════════════ who holds it ═══════════════════ [Fact] public async Task By_default_a_human_decides_at_the_top() { var record = await EscalateFromTheLastLead(); record.HeldByHuman.ShouldBeTrue(); _desk.TopAgentId.ShouldBeNull(); } [Fact] public async Task With_an_agent_at_the_top_the_case_goes_to_it_with_its_own_instructions() { Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); record.Holder.ShouldBe("ceo"); var inbox = (await _tasks.GetAsync(record.HolderTaskId, default))!; inbox.Assignee.ShouldBe("@ceo"); inbox.Body.ShouldContain("reached the top, and you decide it"); inbox.Body.ShouldNotContain("handle (note)", customMessage: "the top decides, it does not carry out"); } [Fact] public async Task Below_the_top_nothing_changes() { Top(TopMode.Agent); var outcome = await _desk.EscalateAsync(new EscalationRequest("buyer", AgentTrust.Standard, "Supplier terms", "Which payment terms do we accept?", "", "", null, null), default); outcome.Case!.Holder.ShouldBe("buy-lead"); } [Fact] public async Task A_decision_at_the_top_binds_everyone_and_is_presented_to_the_human() { Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); var decided = await _desk.DecideAsync(record.Id, "ceo", "No second warehouse this season.", "The offers cost more than the expected sales margin.", "run-9", default); decided.Ok.ShouldBeTrue(decided.Message); decided.Message.ShouldContain("presented to a human"); decided.Decision!.ByTopAgent.ShouldBeTrue(); decided.Decision.ScopeUnitId.ShouldBeNull("the top decides for the whole instance"); decided.Decision.AwaitsReview.ShouldBeTrue(); } [Fact] public async Task The_top_agent_decides_but_does_not_handle_and_passes_on_only_to_a_human() { Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); (await _desk.HandleAsync(record.Id, "ceo", "I rented it.", null, default)).Message.ShouldContain("you do not carry out"); var forwarded = await _desk.ForwardAsync(record.Id, "ceo", AgentTrust.Standard, "This is a question of money, not of fact.", null, default); forwarded.Ok.ShouldBeTrue(forwarded.Message); forwarded.Case!.HeldByHuman.ShouldBeTrue(); } [Fact] public async Task A_human_can_take_a_case_from_the_top_agent() { Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); var decided = await _desk.DecideAsync(record.Id, CaseRules.Human, "Yes, rent it.", "Last season we ran out of space.", null, default); decided.Ok.ShouldBeTrue(decided.Message); decided.Decision!.ByTopAgent.ShouldBeFalse(); (await _tasks.GetAsync(record.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Done); } [Fact] public async Task A_conflict_of_the_top_leads_is_delivered_to_the_top_agent() { Top(TopMode.Agent); var opened = await _desk.OpenConflictAsync(new ConflictRequest("buy-lead", AgentTrust.Standard, "dev-lead", "Delivery date field", "We need a field for the delivery date.", null, null), default); opened.Ok.ShouldBeTrue(opened.Message); var stated = await _desk.StatePositionAsync(opened.Case!.Id, "dev-lead", AgentTrust.Standard, "The note field is enough.", null, default); stated.Case!.Holder.ShouldBe("ceo"); } // ═══════════════════ fitting the role ═══════════════════ [Fact] public async Task An_agent_with_a_tool_that_acts_outside_is_passed_over() { _agents[0].Tools["Mail"] = new(); Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); record.HeldByHuman.ShouldBeTrue(); record.Trail.Last().Text.ShouldContain("Mail"); _desk.TopAgentId.ShouldBeNull(); } [Fact] public void The_deciding_agent_stands_outside_the_units_and_holds_no_outside_tools() { TopRole.Unfit(_agents[0], "ceo").ShouldBeNull(); TopRole.Unfit(_agents[1], "buy-lead")!.ShouldContain("belongs to a unit"); TopRole.Unfit(new AgentConfig { AgentId = "x", Tools = { ["Database"] = new() } }, "x")!.ShouldContain("Database"); TopRole.Unfit(new AgentConfig { AgentId = "y", TreatAsForeignReader = true }, "y")!.ShouldContain("outside"); TopRole.Unfit(null, "ghost")!.ShouldContain("no agent"); } [Fact] public async Task An_agent_that_stopped_fitting_does_not_decide() { Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); _agents[0].Tools["Mail"] = new(); (await _desk.DecideAsync(record.Id, "ceo", "No.", "Too expensive.", null, default)).Message.ShouldContain("cannot decide at the top"); } // ═══════════════════ human first ═══════════════════ [Fact] public async Task Human_first_hands_to_the_agent_after_the_human_had_time_and_the_deadline_still_holds() { Top(TopMode.HumanFirst, hours: 1); var record = await EscalateFromTheLastLead(); record.HeldByHuman.ShouldBeTrue(); record.TopAgentAt.ShouldNotBeNull(); var humanTask = record.HolderTaskId; Advance(TimeSpan.FromMinutes(59)); await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default); (await _cases.GetAsync(record.Id, default))!.HeldByHuman.ShouldBeTrue("the human still has time"); Advance(TimeSpan.FromMinutes(2)); await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default); var handed = (await _cases.GetAsync(record.Id, default))!; handed.Holder.ShouldBe("ceo"); handed.TopAgentAt.ShouldBeNull(); handed.DeadlineUtc.ShouldBe(_clock.GetUtcNow().UtcDateTime.AddHours(4), "the agent gets a deadline of its own"); (await _tasks.GetAsync(humanTask, default))!.Status.ShouldBe(TaskItemStatus.Canceled); Advance(TimeSpan.FromHours(4)); await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default); var called = (await _cases.GetAsync(record.Id, default))!; called.HeldByHuman.ShouldBeTrue("the deadline holds for the top agent too"); _called.Select(c => c.Id).ShouldContain(record.Id); (await _tasks.GetAsync(handed.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Canceled); } [Fact] public async Task Human_first_ends_when_the_human_decides() { Top(TopMode.HumanFirst, hours: 1); var record = await EscalateFromTheLastLead(); (await _desk.DecideAsync(record.Id, CaseRules.Human, "Yes.", "Space ran out last year.", null, default)).Ok.ShouldBeTrue(); Advance(TimeSpan.FromHours(2)); await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default); (await _cases.GetAsync(record.Id, default))!.Status.ShouldBe(CaseStatus.Decided); } // ═══════════════════ reading and withdrawing ═══════════════════ [Fact] public async Task A_withdrawn_decision_binds_nobody_and_the_one_who_asked_is_told() { Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); var decision = (await _desk.DecideAsync(record.Id, "ceo", "No second warehouse this season.", "Too expensive.", null, default)).Decision!; (await _desk.FindDecisionsAsync("buyer", "Budget for a second warehouse", null, default)).ShouldNotBeEmpty(); (await _desk.RevokeDecisionAsync(decision.Id, "", default)).Ok.ShouldBeFalse("a withdrawal needs its reason"); var revoked = await _desk.RevokeDecisionAsync(decision.Id, "Sales doubled; we need the space.", default); revoked.Ok.ShouldBeTrue(revoked.Message); (await _desk.FindDecisionsAsync("buyer", "Budget for a second warehouse", null, default)).ShouldBeEmpty(); var stored = (await _cases.GetDecisionAsync(decision.Id, default))!; stored.RevokedReason.ShouldBe("Sales doubled; we need the space."); stored.AwaitsReview.ShouldBeFalse(); (await _board.ListAsync(new TaskQuery { Assignee = "@buy-lead" }, default)) .ShouldContain(t => t.Title.StartsWith($"Decision {decision.Id} withdrawn")); } [Fact] public async Task Reading_a_decision_takes_it_off_the_list() { Top(TopMode.Agent); var record = await EscalateFromTheLastLead(); var decision = (await _desk.DecideAsync(record.Id, "ceo", "No.", "Too expensive.", null, default)).Decision!; (await _desk.ReviewDecisionAsync(decision.Id, default)).Ok.ShouldBeTrue(); (await _cases.GetDecisionAsync(decision.Id, default))!.AwaitsReview.ShouldBeFalse(); } // ═══════════════════ the briefing ═══════════════════ [Fact] public void The_top_agent_is_told_what_it_may_not_do_with_or_without_units() { var withUnits = OrgBriefing.For(Org, "ceo", DateTime.UtcNow, atTop: true)!; withUnits.ShouldContain("you do not carry them out"); withUnits.ShouldContain("Purchasing (lead: buy-lead)"); var flat = new OrgChart([], [new AgentConfig { AgentId = "ceo" }, new AgentConfig { AgentId = "worker" }]); OrgBriefing.For(flat, "ceo", DateTime.UtcNow, atTop: true)!.ShouldContain("You do not change the structure"); OrgBriefing.For(flat, "worker", DateTime.UtcNow).ShouldBeNull("without units the others keep their prompt"); } }