using System.Text.Json; using ClawdDotNet.Core.Budget; using ClawdDotNet.Core.Staging; 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; /// #717: Asking for a special budget — the agent proposes, a human always decides. public sealed class BudgetRequestTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); private readonly SqliteTaskRepository _tasks; private readonly SqliteSpecialBudgetRepository _pots; private readonly EscalationTool _tool = new(); public BudgetRequestTests() { var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _tasks = new SqliteTaskRepository(storage); _pots = new SqliteSpecialBudgetRepository(storage); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private AgentToolContext Context(string agent = "worker") => new( agent, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None, Tasks: _tasks) { SpecialBudgets = _pots }; private static JsonElement Request(string task, decimal amount = 4m, string reasoning = "400 listings, one pass") => JsonSerializer.SerializeToElement(new { action = "request_budget", task, amount_usd = amount, reasoning }); [Fact] public void A_special_budget_always_needs_a_human() { FreigabeRegeln.IstImmerFreigabe(CaseDeskToolName, "request_budget").ShouldBeTrue(); // Not even a tool configuration that allows it, or the tool's own default, lifts it. FreigabeRegeln.Einstufen(CaseDeskToolName, "request_budget", Werkzeugrechte.Keine, StagingDecision.Auto, new StagingPolicy()) .Entscheidung.ShouldBe(StagingDecision.Approve); } private const string CaseDeskToolName = Core.Cases.CaseDesk.ToolName; [Fact] public async Task Only_for_ones_own_task() { await _tasks.UpsertAsync(new TaskItem { Id = "t-1", Title = "big", Assignee = "@someone-else" }, default); _tool.Preflight(Request("t-1"), Context()).ShouldNotBeNull().ShouldContain("neither assigned to you nor created by you"); } [Theory] [InlineData("", 4, "why")] [InlineData("t-1", 0, "why")] [InlineData("t-1", 4, "")] public async Task An_incomplete_request_does_not_reach_a_human(string task, double amount, string reasoning) { await _tasks.UpsertAsync(new TaskItem { Id = "t-1", Title = "big", Assignee = "@worker" }, default); _tool.Preflight(Request(task, (decimal)amount, reasoning), Context()).ShouldNotBeNull(); } [Fact] public async Task Once_approved_the_pot_is_set_up_and_the_waiting_task_taken_up_again() { await _tasks.UpsertAsync(new TaskItem { Id = "t-1", Title = "big", Assignee = "@worker" }, default); await _tasks.SetStatusAsync("t-1", TaskItemStatus.Blocked, DateTime.UtcNow, default); await _tasks.MarkLargePlannedAsync("t-1", DateTime.UtcNow, default); _tool.Preflight(Request("t-1"), Context()).ShouldBeNull(); // What the staging runs after the human's approval: var result = await _tool.ExecuteAsync(Request("t-1"), Context(), default); result.Success.ShouldBeTrue(result.ErrorMessage); (await _pots.GetAsync("t-1", default))!.AmountUsd.ShouldBe(4m); (await _tasks.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo); } }