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; /// /// #689: Escalation, conflict and decision as cases of their own — only up, delivered where /// the tree says, looked up before asking again, and never left unsolved. /// public sealed class CaseDeskTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "cases-" + 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, EscalationsPerUnitPerDay = 10 }; private readonly List _called = []; private readonly CaseDesk _desk; private static readonly AgentTrust ReadsWeb = new(TrustTier.ReadsForeignContent, ["WebFetch"]); /// purchasing (buy-lead) › team-a (a-lead; buyer-a, buyer-c), team-b (b-lead; buyer-b). private readonly OrgChart _org = new( [ new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" }, new UnitConfig { Id = "team-a", Name = "Team A", ParentId = "purchasing", LeaderAgentId = "a-lead" }, new UnitConfig { Id = "team-b", Name = "Team B", ParentId = "purchasing", LeaderAgentId = "b-lead" } ], [ new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" }, new AgentConfig { AgentId = "a-lead", UnitId = "team-a" }, new AgentConfig { AgentId = "buyer-a", UnitId = "team-a" }, new AgentConfig { AgentId = "buyer-c", UnitId = "team-a" }, new AgentConfig { AgentId = "b-lead", UnitId = "team-b" }, new AgentConfig { AgentId = "buyer-b", UnitId = "team-b" } ]); public CaseDeskTests() { 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 Task Stuck(string agent, string id = "t-1") => _board.CreateAsync(new TaskItem { Id = id, Title = "Record the delivery date", Assignee = "@" + agent }, default); private Task Escalate(string agent, string subject = "Delivery date field in orders", string? task = null, bool force = false, AgentTrust? trust = null) => _desk.EscalateAsync(new EscalationRequest(agent, trust ?? AgentTrust.Standard, subject, "The order confirmation has a delivery date, but the order has no field for it.", "Looked at the columns of the orders table: no date besides created_at.", "A field, or a place to put it.", task, "run-1", force), default); // ═══════════════════ escalation ═══════════════════ [Fact] public async Task An_escalation_goes_to_the_own_lead_and_the_stuck_task_waits() { await Stuck("buyer-a"); var outcome = await Escalate("buyer-a", task: "t-1"); outcome.Ok.ShouldBeTrue(outcome.Message); var record = outcome.Case!; record.Holder.ShouldBe("a-lead"); record.DeadlineUtc.ShouldBe(_clock.GetUtcNow().UtcDateTime.AddHours(4)); (await _tasks.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Blocked); var inbox = (await _tasks.GetAsync(record.HolderTaskId, default))!; inbox.Assignee.ShouldBe("@a-lead"); inbox.OriginTaskId.ShouldBe("t-1", "#697: the origin travels with it"); inbox.Body.ShouldContain("", Case.Sensitive, "what a level says is data for the next"); inbox.Body.ShouldContain("forward (assessment)"); } [Fact] public async Task A_decision_closes_the_case_binds_the_area_and_sends_the_stuck_task_back_to_work() { await Stuck("buyer-a"); var record = (await Escalate("buyer-a", task: "t-1")).Case!; var decided = await _desk.DecideAsync(record.Id, "a-lead", "Put it into the note field as 'delivery: '.", "A new column is DevOps' call and takes weeks; the note field is read by the warehouse.", "run-2", default); decided.Ok.ShouldBeTrue(decided.Message); decided.Decision!.ScopeUnitId.ShouldBe("team-a"); var stuck = (await _tasks.GetAsync("t-1", default))!; stuck.Status.ShouldBe(TaskItemStatus.Todo); stuck.Body.ShouldContain(decided.Decision.Id); (await _cases.GetAsync(record.Id, default))!.Status.ShouldBe(CaseStatus.Decided); } [Fact] public async Task Only_the_holder_decides_and_never_without_reasons() { var record = (await Escalate("buyer-a")).Case!; (await _desk.DecideAsync(record.Id, "buyer-c", "Yes", "Because.", null, default)).Ok.ShouldBeFalse(); (await _desk.DecideAsync(record.Id, "a-lead", "Yes", "", null, default)).Message.ShouldContain("reasoning"); } [Fact] public async Task The_next_one_with_the_same_question_is_shown_the_decision_first() { var first = (await Escalate("buyer-a")).Case!; await _desk.DecideAsync(first.Id, "a-lead", "Use the note field.", "No new column for now.", null, default); var again = await Escalate("buyer-c", subject: "Delivery date field orders"); again.Ok.ShouldBeFalse(); again.Message.ShouldContain("Use the note field."); (await Escalate("buyer-c", subject: "Delivery date field orders", force: true)).Ok .ShouldBeTrue("with force, when it truly does not fit"); } [Fact] public async Task A_decision_binds_nobody_outside_the_deciding_area() { var first = (await Escalate("buyer-a")).Case!; await _desk.DecideAsync(first.Id, "a-lead", "Use the note field.", "No new column for now.", null, default); (await _desk.FindDecisionsAsync("buyer-b", "Delivery date field in orders", null, default)).ShouldBeEmpty(); } [Fact] public async Task Passing_on_goes_one_level_up_and_adds_rather_than_replaces() { var record = (await Escalate("buyer-a")).Case!; var forwarded = await _desk.ForwardAsync(record.Id, "a-lead", AgentTrust.Standard, "Worth a column: every team needs it.", "run-2", default); forwarded.Case!.Holder.ShouldBe("buy-lead"); forwarded.Case.Level.ShouldBe(1); forwarded.Case.Trail.Select(e => e.Kind).ShouldBe(["opened", "forwarded"]); var inbox = (await _tasks.GetAsync(forwarded.Case.HolderTaskId, default))!.Body; inbox.ShouldContain("The order confirmation has a delivery date", Case.Sensitive, "the original stays"); inbox.ShouldContain("Worth a column"); (await _desk.ForwardAsync(record.Id, "a-lead", AgentTrust.Standard, "", null, default)).Ok .ShouldBeFalse("it is no longer with a-lead"); } [Fact] public async Task The_same_question_from_the_same_unit_joins_the_open_case() { var first = (await Escalate("buyer-a")).Case!; await Stuck("buyer-c", "t-2"); var second = await Escalate("buyer-c", task: "t-2"); second.Case!.Id.ShouldBe(first.Id); second.Case.Trail.Last().Kind.ShouldBe("joined"); (await _tasks.GetAsync("t-2", default))!.Status.ShouldBe(TaskItemStatus.Blocked); (await _cases.ListOpenAsync(default)).Count.ShouldBe(1); } [Fact] public async Task Beyond_the_daily_cap_of_a_unit_it_goes_straight_to_a_human() { _settings.EscalationsPerUnitPerDay = 2; (await Escalate("buyer-a", subject: "First matter")).Case!.Holder.ShouldBe("a-lead"); (await Escalate("buyer-a", subject: "Second matter")).Case!.Holder.ShouldBe("a-lead"); var third = (await Escalate("buyer-a", subject: "Third matter")).Case!; third.HeldByHuman.ShouldBeTrue(); third.Trail.ShouldContain(e => e.Kind == "note" && e.Text.Contains("cap")); } [Fact] public async Task After_the_deadline_a_human_is_called_once() { var record = (await Escalate("buyer-a")).Case!; _clock.Advance(TimeSpan.FromHours(4) + TimeSpan.FromMinutes(1)); await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default); _clock.Advance(TimeSpan.FromMinutes(5)); await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default); _called.ShouldHaveSingleItem().Id.ShouldBe(record.Id); var now = (await _cases.GetAsync(record.Id, default))!; now.HeldByHuman.ShouldBeTrue(); (await _tasks.GetAsync(now.HolderTaskId, default))!.Assignee.ShouldBe(TaskAssignee.Human); } [Fact] public async Task An_agent_reading_foreign_content_escalates_without_text_of_its_own() { (await Escalate("buyer-a", trust: ReadsWeb)).Ok.ShouldBeFalse("without a task there is nothing to point to"); await Stuck("buyer-a"); var record = (await Escalate("buyer-a", task: "t-1", trust: ReadsWeb)).Case!; record.Subject.ShouldBe("t-1 is stuck"); record.Trail.Single().Text.ShouldNotContain("order confirmation"); } [Fact] public async Task Handling_it_closes_the_case_and_the_opener_hears_of_it() { var record = (await Escalate("buyer-a")).Case!; (await _desk.HandleAsync(record.Id, "a-lead", "Asked DevOps myself; column comes Friday.", null, default)).Ok.ShouldBeTrue(); (await _board.ListAllAsync(default)).ShouldContain(t => t.Assignee == "@buyer-a" && t.Title.StartsWith("Outcome of case")); } // ═══════════════════ conflict ═══════════════════ private Task Conflict(string from = "a-lead", string with = "b-lead") => _desk.OpenConflictAsync(new ConflictRequest(from, AgentTrust.Standard, with, "Delivery date column", "We need the column: three teams type dates into notes.", null, "run-1"), default); [Fact] public async Task A_conflict_asks_the_other_side_first_then_reaches_the_lowest_common_lead() { var opened = await Conflict(); opened.Ok.ShouldBeTrue(opened.Message); var record = opened.Case!; record.Holder.ShouldBe("buy-lead"); record.HolderTaskId.ShouldBeEmpty("nobody decides before both sides are heard"); var ask = (await _tasks.GetAsync(record.PendingStatements["b-lead"], default))!; ask.Assignee.ShouldBe("@b-lead"); (await _desk.DecideAsync(record.Id, "buy-lead", "x", "y", null, default)).Ok.ShouldBeFalse("b-lead has not spoken yet"); var stated = await _desk.StatePositionAsync(record.Id, "b-lead", AgentTrust.Standard, "Notes are enough; a column costs a migration.", "run-2", default); stated.Case!.HolderTaskId.ShouldNotBeEmpty(); var inbox = (await _tasks.GetAsync(stated.Case.HolderTaskId, default))!; inbox.Assignee.ShouldBe("@buy-lead"); inbox.Body.ShouldContain("three teams type dates"); inbox.Body.ShouldContain("a column costs a migration"); } [Fact] public async Task Both_sides_hear_the_decision() { var record = (await Conflict()).Case!; await _desk.StatePositionAsync(record.Id, "b-lead", AgentTrust.Standard, "Notes are enough.", null, default); (await _desk.DecideAsync(record.Id, "buy-lead", "Add the column.", "Three teams need it daily.", null, default)).Ok.ShouldBeTrue(); var outcomes = (await _board.ListAllAsync(default)).Where(t => t.Title.StartsWith("Outcome of case")).Select(t => t.Assignee); outcomes.ShouldBe(["@a-lead", "@b-lead"], ignoreOrder: true); } [Fact] public async Task A_conflict_cannot_be_passed_on_or_handled_it_is_decided() { var record = (await Conflict()).Case!; await _desk.StatePositionAsync(record.Id, "b-lead", AgentTrust.Standard, "No.", null, default); (await _desk.ForwardAsync(record.Id, "buy-lead", AgentTrust.Standard, "", null, default)).Ok.ShouldBeFalse(); (await _desk.HandleAsync(record.Id, "buy-lead", "done", null, default)).Ok.ShouldBeFalse(); } [Fact] public async Task A_side_that_does_not_answer_does_not_hold_the_case_up_for_ever() { var record = (await Conflict()).Case!; _clock.Advance(TimeSpan.FromHours(5)); await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default); var now = (await _cases.GetAsync(record.Id, default))!; now.HeldByHuman.ShouldBeTrue(); now.PendingStatements.ShouldBeEmpty(); (await _tasks.GetAsync(record.PendingStatements["b-lead"], default))!.Status.ShouldBe(TaskItemStatus.Canceled); } // ═══════════════════ the human ═══════════════════ [Fact] public async Task A_human_asks_one_side_for_a_statement_and_decides_after_it() { var record = (await Escalate("buy-lead", subject: "Budget for a new supplier")).Case!; record.HeldByHuman.ShouldBeTrue("buy-lead has no lead above it"); var asked = await _desk.RequestStatementAsync(record.Id, "a-lead", "How often does team A order from them?", default); asked.Ok.ShouldBeTrue(asked.Message); await _desk.StatePositionAsync(record.Id, "a-lead", AgentTrust.Standard, "Twice a week.", null, default); var humanTasks = (await _board.ListAllAsync(default)).Count(t => t.Assignee == TaskAssignee.Human); humanTasks.ShouldBe(1, "the case stays in the one inbox it is in"); (await _desk.DecideAsync(record.Id, CaseRules.Human, "Approved.", "Twice a week is worth it.", null, default)) .Decision!.ScopeUnitId.ShouldBeNull("a human decides for the whole instance"); (await _tasks.GetAsync((await _cases.GetAsync(record.Id, default))!.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Done); } }