Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Cases/CaseDeskTests.cs
T
RichardandClaude Opus 5.5 36fe1ad2aa Eskalation, Konflikt und Beschluss als eigene Vorgaenge (#689, Stufe 1+2)
- Cases/Decisions in der Instanz-DB; CaseDesk fuehrt die Regeln, der Brett-Task traegt
  den Vorgang zum jeweiligen Halter
- Eskalation nur nach oben, eine Ebene je Schritt; die Leitung entscheidet (mit Begruendung,
  bindet ihren Bereich), erledigt selbst oder reicht mit Einschaetzung weiter
- Erst nachsehen: bindende Beschluesse werden vor dem Eskalieren gezeigt; dieselbe Frage
  aus derselben Einheit schliesst sich dem offenen Vorgang an
- Tagesdeckel je Einheit; nach der Frist wird ein Mensch gerufen (Anschluss fuer #644)
- Konflikt: zuerst die Gegenseite, dann Zustellung an die niedrigste gemeinsame Leitung;
  Ebenen dazwischen vermerkt, nicht befragt
- Fremdinhalt-Stufe (#715): Eskalation ohne eigenen Text, kein Konflikt mit Position
- Jede Ebene haengt an (#697), beim Halter gerahmt als Daten
- Escalation ist ein implizites Werkzeug jedes Agenten; Briefing nennt den Weg
- Scanner behaelt einen im Lauf gesetzten Status blockiert/abgebrochen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:46:20 +02:00

312 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// #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.
/// </summary>
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<CaseRecord> _called = [];
private readonly CaseDesk _desk;
private static readonly AgentTrust ReadsWeb = new(TrustTier.ReadsForeignContent, ["WebFetch"]);
/// <summary>purchasing (buy-lead) › team-a (a-lead; buyer-a, buyer-c), team-b (b-lead; buyer-b).</summary>
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<TaskItem> Stuck(string agent, string id = "t-1")
=> _board.CreateAsync(new TaskItem { Id = id, Title = "Record the delivery date", Assignee = "@" + agent }, default);
private Task<CaseOutcome> 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("<untrusted_content source=\"case:buyer-a\">", 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: <date>'.",
"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<CaseOutcome> 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);
}
}