Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
3.8 KiB
C#
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()
|
|
{
|
|
ApprovalRules.AlwaysNeedsApproval(CaseDeskToolName, "request_budget").ShouldBeTrue();
|
|
|
|
// Not even a tool configuration that allows it, or the tool's own default, lifts it.
|
|
ApprovalRules.Classify(CaseDeskToolName, "request_budget", ToolRights.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);
|
|
}
|
|
}
|