using System.Text.Json; using ClawdDotNet.Core.Cases; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.Taskboard; using ClawdDotNet.Tools.Tests.FileRW; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.Taskboard; /// #689: The agent's side of escalation — every agent has it, the engine supplies who and where. public sealed class EscalationToolTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); private readonly SqliteTaskRepository _tasks; private readonly TaskboardService _board; private readonly CaseDesk _desk; private readonly EscalationTool _tool = new(); private readonly OrgChart _org = new( [new UnitConfig { Id = "amazon", Name = "Amazon", LeaderAgentId = "amazon-lead" }], [ new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" }, new AgentConfig { AgentId = "ads", UnitId = "amazon" } ]); public EscalationToolTests() { var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _tasks = new SqliteTaskRepository(storage); _board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks")); _desk = new CaseDesk(new SqliteCaseRepository(storage), _board, () => _org, () => new CaseSettings(), NullLogger.Instance); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private Task Run(string agent, object input, string? runTaskId = null) => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext( agent, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None) { Organisation = _org, Cases = _desk, RunId = "run-" + agent, RunTaskId = runTaskId }, CancellationToken.None); [Fact] public async Task Escalating_in_a_task_run_holds_that_task_and_reaches_the_lead() { await _board.CreateAsync(new TaskItem { Id = "t-1", Title = "Price the chair", Assignee = "@ads" }, default); var result = await Run("ads", new { action = "escalate", subject = "Minimum price for the A-100", question = "Below which price may I not go?" }, runTaskId: "t-1"); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("amazon-lead"); (await _tasks.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Blocked); var cases = await Run("amazon-lead", new { action = "list_cases" }); cases.Content.ShouldContain("you hold it"); } [Fact] public async Task A_lead_decides_and_the_decision_is_found_afterwards() { var opened = await Run("ads", new { action = "escalate", subject = "Minimum price for the A-100", question = "How low?" }); var caseId = opened.Content.Split(' ')[3].TrimEnd('.'); (await Run("amazon-lead", new { action = "decide", @case = caseId, answer = "Never below 89 EUR.", reasoning = "Margin." })) .Success.ShouldBeTrue(); (await Run("ads", new { action = "find_decisions", subject = "minimum price A-100" })).Content.ShouldContain("Never below 89 EUR."); } [Fact] public void Every_agent_has_it_without_being_granted_it() { var registry = new ToolRegistry(); registry.Register(_tool); var agent = new AgentConfig { AgentId = "ads" }; registry.GetForAgent(agent).ShouldContain(t => t.Name == CaseDesk.ToolName); new PermissionGate().IsAllowed("ads", CaseDesk.ToolName, agent).ShouldBeTrue(); new PermissionGate().IsAllowed("ads", "Mail", agent).ShouldBeFalse("only escalation is implicit"); } }