Files
ClawdDotNet/tests/ClawdDotNet.Tools.Tests/Taskboard/BudgetRequestTests.cs
T
RichardandClaude Opus 5.5 67649559f5 Grossaufgaben und Sonderbudget (#717, Stufe 2+3)
Eine Aufgabe, deren eigene Schaetzung (gemessen, Geschwister oder Autor)
ueber der Haelfte der Tageskostengrenze liegt, bekommt zuerst einen
Planungslauf: teilen (Teile unter der Elternaufgabe) oder Sonderbudget
beantragen. Danach wartet sie auf ihre Teile bzw. die Freigabe und laeuft
einmal zum Zusammenfuehren.

Sonderbudget ueber Escalation.request_budget, ausnahmslos freigabepflichtig
(auch gegen Werkzeugkonfiguration und Dauerfreigabe). Laeufe im Baum ziehen
den Topf statt der Tagesgrenzen, tragen BudgetPot in Verbrauch und Beleg und
sind in der Kostenanalyse (nach Budget) und auf der Aufgabenkarte gesondert
sichtbar. Aufgebraucht zaehlen sie wieder gegen den Tag.

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

93 lines
3.8 KiB
C#

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;
/// <summary>#717: Asking for a special budget — the agent proposes, a human always decides.</summary>
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<string, object?>(), 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);
}
}