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); } // ═══════════════════ the department's situation (#688) ═══════════════════ [Fact] public void A_departments_day_is_added_up_along_the_tree() { var units = TeamOverview.RollUp(Shop(), [ Member("marketing-lead", "marketing", TeamState.Idle) with { RunsToday = 2, CostToday = 0.5m, OpenTasks = 1 }, Member("ads", "amazon", TeamState.Idle) with { RunsToday = 5, CostToday = 1.25m, OpenTasks = 3, BlockedTasks = 1 } ]); var marketing = units.Single(u => u.Id == "marketing"); marketing.RunsToday.ShouldBe(7); marketing.CostToday.ShouldBe(1.75m); marketing.OpenTasks.ShouldBe(4); marketing.BlockedTasks.ShouldBe(1); units.Single(u => u.Id == "amazon").CostToday.ShouldBe(1.25m); } [Fact] public async Task The_snapshot_carries_runs_cost_tasks_cases_and_the_cost_alert_of_today() { var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "team-" + Guid.NewGuid().ToString("N")[..8]); try { var storage = new Core.Storage.SqliteStorage(Path.Combine(directory, "state.db")); var usage = new Core.Accounting.SqliteUsageRepository(storage); var board = new Core.Tasks.TaskboardService(new Core.Tasks.SqliteTaskRepository(storage), Path.Combine(directory, "tasks")); var cases = new Core.Cases.SqliteCaseRepository(storage); await usage.RecordAsync(new Core.Accounting.RunUsage { AgentId = "ads", Model = "m", CostUsd = 0.4m, CostIsKnown = true, Status = "Completed", OccurredAt = DateTime.Now }, default); await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Bid for spring", Assignee = "@ads" }, default); await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Waiting", Assignee = "@ads", Status = Core.Tasks.TaskItemStatus.Blocked }, default); await cases.SaveAsync(new Core.Cases.CaseRecord { Id = "c-1", Subject = "Field", OpenedBy = "ads", OpenedUnitId = "amazon", OpenedAt = DateTime.UtcNow }, default); var today = DateOnly.FromDateTime(DateTime.Now).ToString("yyyy-MM-dd"); var overview = new TeamOverview(null, null, null, usage, () => Shop().Agents.ToList(), organisation: Shop, board: board, cases: cases, costAlerts: _ => Task.FromResult>( [new Core.Accounting.RaisedCostAlert(today, "amazon", Core.Accounting.CostAlertKind.Deviation, "x", DateTime.UtcNow)])); var snapshot = await overview.SnapshotAsync(default); var ads = snapshot.Members.Single(m => m.AgentId == "ads"); ads.RunsToday.ShouldBe(1); ads.CostToday.ShouldBe(0.4m); ads.OpenTasks.ShouldBe(1); ads.BlockedTasks.ShouldBe(1); var marketing = snapshot.Units!.Single(u => u.Id == "marketing"); marketing.OpenCases.ShouldBe(1, "a case raised in amazon lies inside marketing"); snapshot.Units!.Single(u => u.Id == "amazon").CostAlert.ShouldBe("Deviation"); marketing.CostAlert.ShouldBeNull("the alert was raised on amazon, not on marketing"); } finally { Core.Storage.SqliteStorage.ReleasePooledConnections(directory); try { Directory.Delete(directory, recursive: true); } catch { /* cleanup is secondary */ } } } }