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);
}
}