- TeamMember: Laeufe und Kosten heute, offene und blockierte Aufgaben je Agent (eine Abfrage fuer alle, nicht eine je Agent) - TeamUnit: dieselben Werte samt Untereinheiten, offene Vorgaenge aus der Einheit, heutige Kostenmeldung (#699) - team.js: Lagezeile auf jeder Karte und in der Seitenleiste, Instanz summiert; Spruenge zu Kosten je Einheit und zu den Vorgaengen; i18n en/de - Tests: TeamOverviewTests (+2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
12 KiB
C#
249 lines
12 KiB
C#
using ClawdDotNet.App.Services;
|
||
using ClawdDotNet.Core.Activity;
|
||
using ClawdDotNet.Core.Config;
|
||
using ClawdDotNet.Core.Organisation;
|
||
using Shouldly;
|
||
|
||
namespace ClawdDotNet.App.Tests.Services;
|
||
|
||
/// <summary>
|
||
/// #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.
|
||
/// </summary>
|
||
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 ═══════════════════
|
||
|
||
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
|
||
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<TeamState>().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<AgentConfig> { 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<AgentConfig> { 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<IReadOnlyList<Core.Accounting.RaisedCostAlert>>(
|
||
[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 */ }
|
||
}
|
||
}
|
||
}
|