using ClawdDotNet.App.Services; using ClawdDotNet.Core.Activity; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using Shouldly; namespace ClawdDotNet.App.Tests.Services; /// /// #711: The rules that decide what the team overview shows for an agent. Every state comes /// from a recorded fact, never from a model's judgement. /// public sealed class TeamOverviewTests { private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc); private static ActiveRun RunningFor(TimeSpan duration) => new("r", "worker", "task", null, null, Now - duration); [Fact] public void Nothing_running_and_a_good_last_run_is_idle() => TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle); [Fact] public void An_agent_without_any_run_yet_is_idle() => TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle); [Fact] public void A_run_in_progress_is_working() => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now) .ShouldBe(TeamState.Working); [Fact] public void A_run_far_beyond_the_usual_duration_is_stuck() // Usually 2 minutes: three times that is 6, running for 7. => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now) .ShouldBe(TeamState.Stuck); [Fact] public void Short_runs_get_a_floor_before_they_count_as_stuck() // Usually 20 seconds: three times is one minute, but the floor is five. => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now) .ShouldBe(TeamState.Working); [Fact] public void Without_history_a_run_is_stuck_only_after_a_long_while() { TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working); TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck); } [Fact] public void An_open_approval_outranks_working() // What the human can do something about comes first. => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now) .ShouldBe(TeamState.WaitingForApproval); [Fact] public void Stuck_outranks_an_open_approval() => TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now) .ShouldBe(TeamState.Stuck); [Theory] [InlineData("Failed")] [InlineData("LoopLimitExceeded")] [InlineData("TargetBusy")] public void A_failed_last_run_is_a_problem(string status) => TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem); [Fact] public void A_refused_last_run_shows_the_exhausted_budget() => TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted); [Fact] public void A_new_run_hides_the_old_problem() // A new run is the attempt to get past it; the problem shows again if it fails. => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now) .ShouldBe(TeamState.Working); [Fact] public void A_cancelled_run_is_not_a_problem() // Cancelled means a human stopped it — that was a decision, not a fault. => TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle); // ═══════════════════ stage 1: units roll up ═══════════════════ /// marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top. private static OrgChart Shop() => new( [ new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" }, new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" }, new UnitConfig { Id = "accounting", Name = "Accounting" } ], [ new AgentConfig { AgentId = "ceo" }, new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" }, new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" }, new AgentConfig { AgentId = "ads", UnitId = "amazon" }, new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" } ]); private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0) => new(id, id, "m", state, [], approvals, null, null, null, 0, unit); [Fact] public void A_problem_deep_down_shows_on_every_container_above_it() { var units = TeamOverview.RollUp(Shop(), [ Member("ceo", null, TeamState.Working), Member("marketing-lead", "marketing", TeamState.Working), Member("amazon-lead", "amazon", TeamState.Idle), Member("ads", "amazon", TeamState.Problem), Member("bookkeeper", "accounting", TeamState.Idle) ]); var marketing = units.Single(u => u.Id == "marketing"); marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below"); marketing.Worst.ShouldBe(TeamState.Problem); marketing.States[TeamState.Working].ShouldBe(1); marketing.States[TeamState.Problem].ShouldBe(1); units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem); units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle); } [Fact] public void Open_approvals_are_added_up_along_the_tree() { var units = TeamOverview.RollUp(Shop(), [ Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1), Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2) ]); units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3); units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2); } [Fact] public void A_unit_knows_its_path_and_its_lead() { var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon"); amazon.Path.ShouldBe("Marketing / Amazon"); amazon.LeaderAgentId.ShouldBe("amazon-lead"); amazon.Agents.ShouldBe(0); amazon.Worst.ShouldBe(TeamState.Idle); } [Fact] public void Stuck_is_the_most_urgent_and_idle_the_least() { var byUrgency = Enum.GetValues().OrderByDescending(TeamOverview.Urgency).ToList(); byUrgency.First().ShouldBe(TeamState.Stuck); byUrgency.Last().ShouldBe(TeamState.Idle); TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval)); } [Fact] public void Without_units_there_is_nothing_to_roll_up() => TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)]) .ShouldBeEmpty(); // ─── #720: conflict hotspots ─── [Fact] public void A_hotspot_is_shown_relative_to_the_agents_folder() { var agents = Path.Combine(Path.GetTempPath(), "inst", "Agents"); var configured = new List { new() { AgentId = "a", SharedWorkspacePath = Path.Combine(agents, "SharedWorkspace") } }; TeamOverview.Readable(Path.Combine(agents, "SharedWorkspaces", "articles", "list.csv"), configured) .ShouldBe("SharedWorkspaces/articles/list.csv"); TeamOverview.Readable(Path.Combine(agents, "SharedWorkspace", "plan.md"), configured) .ShouldBe("SharedWorkspace/plan.md"); } [Fact] public void A_path_elsewhere_stays_as_it_is() { var configured = new List { new() { AgentId = "a", SharedWorkspacePath = Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspace") } }; var elsewhere = Path.Combine(Path.GetTempPath(), "other", "x.md"); TeamOverview.Readable(elsewhere, configured).ShouldBe(elsewhere); TeamOverview.Readable(elsewhere, []).ShouldBe(elsewhere); } }