Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Cases/TopRoleTests.cs
T
RichardandClaude Opus 5.5 e8964e3c74 Entscheidungsinstanz an der Spitze: Mensch, Agent oder Mensch zuerst mit Frist (#690)
- cases.top in instance.json: Human | Agent | HumanFirst, agentId, humanFirstHours
- TopRole: der entscheidende Agent steht in keiner Einheit, liest keine Fremdinhalte
  und hat nur Taskboard, Escalation, Memory, FileRW, AgentComm; geprueft beim Setzen,
  bei jeder Zustellung und vor jeder Entscheidung
- CaseDesk: was bisher an der Spitze beim Menschen landete, geht nach Einstellung an
  den Agenten; Tagesdeckel und Frist gehen weiter an den Menschen. handle verwehrt,
  forward geht an den Menschen. Mensch zuerst: Uebergabe nach Ablauf, eigene Frist.
  Der Mensch kann einen Fall des Agenten selbst entscheiden.
- Beschluesse des Agenten: ByTopAgent, zum Lesen vorgelegt; jeder Beschluss laesst
  sich mit Begruendung zuruecknehmen und bindet dann niemanden mehr
- Briefing fuer den Agenten an der Spitze, auch ohne Einheiten
- OrgAdministration.SetTop mit Audit; Werkzeugsperre und Verschiebesperre
- WebUI Vorgaenge: Reiter Spitze, Lesen/Zuruecknehmen, Zaehler; i18n en/de
- Tests: TopRoleTests (15), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:27:26 +02:00

291 lines
13 KiB
C#

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>
/// #690: The deciding instance at the top — a human, an agent, or the human first with the
/// agent behind. The agent decides and does not carry out, its decisions are presented to the
/// human, and the deadline holds for it too.
/// </summary>
public sealed class TopRoleTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "top-" + 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 };
private readonly List<CaseRecord> _called = [];
private readonly List<AgentConfig> _agents =
[
new() { AgentId = "ceo", Tools = { ["Taskboard"] = new(), ["Memory"] = new() } },
new() { AgentId = "buy-lead", UnitId = "purchasing" },
new() { AgentId = "buyer", UnitId = "purchasing" },
new() { AgentId = "dev-lead", UnitId = "devops" }
];
private readonly CaseDesk _desk;
/// <summary>purchasing (buy-lead; buyer), devops (dev-lead) — and ceo above both, in no unit.</summary>
private OrgChart Org => new(
[
new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" },
new UnitConfig { Id = "devops", Name = "DevOps", LeaderAgentId = "dev-lead" }
],
_agents);
public TopRoleTests()
{
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 void Top(TopMode mode, string? agent = "ceo", double hours = 2)
=> _settings.Top = new TopSettings { Mode = mode, AgentId = agent, HumanFirstHours = hours };
private async Task<CaseRecord> EscalateFromTheLastLead()
{
var outcome = await _desk.EscalateAsync(new EscalationRequest("buy-lead", AgentTrust.Standard, "Budget for a second warehouse",
"Do we rent a second warehouse for the season?", "Compared offers.", "A yes or no.", null, "run-1"), default);
outcome.Ok.ShouldBeTrue(outcome.Message);
return outcome.Case!;
}
private void Advance(TimeSpan by) => _clock.Advance(by);
// ═══════════════════ who holds it ═══════════════════
[Fact]
public async Task By_default_a_human_decides_at_the_top()
{
var record = await EscalateFromTheLastLead();
record.HeldByHuman.ShouldBeTrue();
_desk.TopAgentId.ShouldBeNull();
}
[Fact]
public async Task With_an_agent_at_the_top_the_case_goes_to_it_with_its_own_instructions()
{
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
record.Holder.ShouldBe("ceo");
var inbox = (await _tasks.GetAsync(record.HolderTaskId, default))!;
inbox.Assignee.ShouldBe("@ceo");
inbox.Body.ShouldContain("reached the top, and you decide it");
inbox.Body.ShouldNotContain("handle (note)", customMessage: "the top decides, it does not carry out");
}
[Fact]
public async Task Below_the_top_nothing_changes()
{
Top(TopMode.Agent);
var outcome = await _desk.EscalateAsync(new EscalationRequest("buyer", AgentTrust.Standard, "Supplier terms",
"Which payment terms do we accept?", "", "", null, null), default);
outcome.Case!.Holder.ShouldBe("buy-lead");
}
[Fact]
public async Task A_decision_at_the_top_binds_everyone_and_is_presented_to_the_human()
{
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
var decided = await _desk.DecideAsync(record.Id, "ceo", "No second warehouse this season.",
"The offers cost more than the expected sales margin.", "run-9", default);
decided.Ok.ShouldBeTrue(decided.Message);
decided.Message.ShouldContain("presented to a human");
decided.Decision!.ByTopAgent.ShouldBeTrue();
decided.Decision.ScopeUnitId.ShouldBeNull("the top decides for the whole instance");
decided.Decision.AwaitsReview.ShouldBeTrue();
}
[Fact]
public async Task The_top_agent_decides_but_does_not_handle_and_passes_on_only_to_a_human()
{
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
(await _desk.HandleAsync(record.Id, "ceo", "I rented it.", null, default)).Message.ShouldContain("you do not carry out");
var forwarded = await _desk.ForwardAsync(record.Id, "ceo", AgentTrust.Standard, "This is a question of money, not of fact.", null, default);
forwarded.Ok.ShouldBeTrue(forwarded.Message);
forwarded.Case!.HeldByHuman.ShouldBeTrue();
}
[Fact]
public async Task A_human_can_take_a_case_from_the_top_agent()
{
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
var decided = await _desk.DecideAsync(record.Id, CaseRules.Human, "Yes, rent it.", "Last season we ran out of space.", null, default);
decided.Ok.ShouldBeTrue(decided.Message);
decided.Decision!.ByTopAgent.ShouldBeFalse();
(await _tasks.GetAsync(record.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task A_conflict_of_the_top_leads_is_delivered_to_the_top_agent()
{
Top(TopMode.Agent);
var opened = await _desk.OpenConflictAsync(new ConflictRequest("buy-lead", AgentTrust.Standard, "dev-lead",
"Delivery date field", "We need a field for the delivery date.", null, null), default);
opened.Ok.ShouldBeTrue(opened.Message);
var stated = await _desk.StatePositionAsync(opened.Case!.Id, "dev-lead", AgentTrust.Standard, "The note field is enough.", null, default);
stated.Case!.Holder.ShouldBe("ceo");
}
// ═══════════════════ fitting the role ═══════════════════
[Fact]
public async Task An_agent_with_a_tool_that_acts_outside_is_passed_over()
{
_agents[0].Tools["Mail"] = new();
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
record.HeldByHuman.ShouldBeTrue();
record.Trail.Last().Text.ShouldContain("Mail");
_desk.TopAgentId.ShouldBeNull();
}
[Fact]
public void The_deciding_agent_stands_outside_the_units_and_holds_no_outside_tools()
{
TopRole.Unfit(_agents[0], "ceo").ShouldBeNull();
TopRole.Unfit(_agents[1], "buy-lead")!.ShouldContain("belongs to a unit");
TopRole.Unfit(new AgentConfig { AgentId = "x", Tools = { ["Database"] = new() } }, "x")!.ShouldContain("Database");
TopRole.Unfit(new AgentConfig { AgentId = "y", TreatAsForeignReader = true }, "y")!.ShouldContain("outside");
TopRole.Unfit(null, "ghost")!.ShouldContain("no agent");
}
[Fact]
public async Task An_agent_that_stopped_fitting_does_not_decide()
{
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
_agents[0].Tools["Mail"] = new();
(await _desk.DecideAsync(record.Id, "ceo", "No.", "Too expensive.", null, default)).Message.ShouldContain("cannot decide at the top");
}
// ═══════════════════ human first ═══════════════════
[Fact]
public async Task Human_first_hands_to_the_agent_after_the_human_had_time_and_the_deadline_still_holds()
{
Top(TopMode.HumanFirst, hours: 1);
var record = await EscalateFromTheLastLead();
record.HeldByHuman.ShouldBeTrue();
record.TopAgentAt.ShouldNotBeNull();
var humanTask = record.HolderTaskId;
Advance(TimeSpan.FromMinutes(59));
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
(await _cases.GetAsync(record.Id, default))!.HeldByHuman.ShouldBeTrue("the human still has time");
Advance(TimeSpan.FromMinutes(2));
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
var handed = (await _cases.GetAsync(record.Id, default))!;
handed.Holder.ShouldBe("ceo");
handed.TopAgentAt.ShouldBeNull();
handed.DeadlineUtc.ShouldBe(_clock.GetUtcNow().UtcDateTime.AddHours(4), "the agent gets a deadline of its own");
(await _tasks.GetAsync(humanTask, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
Advance(TimeSpan.FromHours(4));
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
var called = (await _cases.GetAsync(record.Id, default))!;
called.HeldByHuman.ShouldBeTrue("the deadline holds for the top agent too");
_called.Select(c => c.Id).ShouldContain(record.Id);
(await _tasks.GetAsync(handed.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
}
[Fact]
public async Task Human_first_ends_when_the_human_decides()
{
Top(TopMode.HumanFirst, hours: 1);
var record = await EscalateFromTheLastLead();
(await _desk.DecideAsync(record.Id, CaseRules.Human, "Yes.", "Space ran out last year.", null, default)).Ok.ShouldBeTrue();
Advance(TimeSpan.FromHours(2));
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
(await _cases.GetAsync(record.Id, default))!.Status.ShouldBe(CaseStatus.Decided);
}
// ═══════════════════ reading and withdrawing ═══════════════════
[Fact]
public async Task A_withdrawn_decision_binds_nobody_and_the_one_who_asked_is_told()
{
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
var decision = (await _desk.DecideAsync(record.Id, "ceo", "No second warehouse this season.", "Too expensive.", null, default)).Decision!;
(await _desk.FindDecisionsAsync("buyer", "Budget for a second warehouse", null, default)).ShouldNotBeEmpty();
(await _desk.RevokeDecisionAsync(decision.Id, "", default)).Ok.ShouldBeFalse("a withdrawal needs its reason");
var revoked = await _desk.RevokeDecisionAsync(decision.Id, "Sales doubled; we need the space.", default);
revoked.Ok.ShouldBeTrue(revoked.Message);
(await _desk.FindDecisionsAsync("buyer", "Budget for a second warehouse", null, default)).ShouldBeEmpty();
var stored = (await _cases.GetDecisionAsync(decision.Id, default))!;
stored.RevokedReason.ShouldBe("Sales doubled; we need the space.");
stored.AwaitsReview.ShouldBeFalse();
(await _board.ListAsync(new TaskQuery { Assignee = "@buy-lead" }, default))
.ShouldContain(t => t.Title.StartsWith($"Decision {decision.Id} withdrawn"));
}
[Fact]
public async Task Reading_a_decision_takes_it_off_the_list()
{
Top(TopMode.Agent);
var record = await EscalateFromTheLastLead();
var decision = (await _desk.DecideAsync(record.Id, "ceo", "No.", "Too expensive.", null, default)).Decision!;
(await _desk.ReviewDecisionAsync(decision.Id, default)).Ok.ShouldBeTrue();
(await _cases.GetDecisionAsync(decision.Id, default))!.AwaitsReview.ShouldBeFalse();
}
// ═══════════════════ the briefing ═══════════════════
[Fact]
public void The_top_agent_is_told_what_it_may_not_do_with_or_without_units()
{
var withUnits = OrgBriefing.For(Org, "ceo", DateTime.UtcNow, atTop: true)!;
withUnits.ShouldContain("you do not carry them out");
withUnits.ShouldContain("Purchasing (lead: buy-lead)");
var flat = new OrgChart([], [new AgentConfig { AgentId = "ceo" }, new AgentConfig { AgentId = "worker" }]);
OrgBriefing.For(flat, "ceo", DateTime.UtcNow, atTop: true)!.ShouldContain("You do not change the structure");
OrgBriefing.For(flat, "worker", DateTime.UtcNow).ShouldBeNull("without units the others keep their prompt");
}
}