using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Tasks; using Shouldly; namespace ClawdDotNet.Core.Tests.Accounting; /// The cost forecast (#595): basis per task, projection, other runs. public sealed class CostForecastTests { private static readonly DateTime Now = new(2026, 9, 15, 10, 0, 0, DateTimeKind.Utc); private static readonly AgentConfig Messages = new() { AgentId = "nachrichten", Model = "anthropic/claude-haiku-4.5", SystemPrompt = new string('x', 4000) }; private static TaskItem Hourly(string id, string assignee = "@new:nachrichten", TaskItemStatus status = TaskItemStatus.Todo) => new() { Id = id, Title = id, Assignee = assignee, Status = status, When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" } }; private static RunReceipt Receipt(string taskId, decimal usd, int hoursBack = 1) => new() { TaskId = taskId, AgentId = "nachrichten", CostUsd = usd, CostIsKnown = true, OccurredAt = Now.AddHours(-hoursBack) }; private static Dictionary> Receipts(params (string Id, RunReceipt[] Liste)[] entries) => entries.ToDictionary(e => e.Id, e => (IReadOnlyList)e.Liste); private static InstanceForecast Compute( IReadOnlyList tasks, Dictionary>? receipts = null, IReadOnlyList? usage = null, Func? estimate = null) => CostForecast.Calculate(tasks, receipts ?? [], usage ?? [], [Messages], estimate, Now); [Fact] public void Average_of_the_last_runs_times_runs_per_day() { var p = Compute([Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.01m), Receipt("t-1", 0.03m, 2)]))); var line = p.Aufgaben.Single(); line.Grundlage.ShouldBe(ForecastBasis.Laeufe); line.Stichprobe.ShouldBe(2); line.KostenJeLauf.ShouldBe(0.02m); line.LaeufeProTag.ShouldBe(24); line.KostenProTag.ShouldBe(0.48m); p.SummeProMonat.ShouldBe(14.4m); } [Fact] public void Without_own_runs_the_agents_average_applies() { var p = Compute([Hourly("t-1"), Hourly("t-2")], Receipts(("t-1", [Receipt("t-1", 0.02m)]))); var newOne = p.Aufgaben.Single(z => z.TaskId == "t-2"); newOne.Grundlage.ShouldBe(ForecastBasis.Agent); newOne.KostenJeLauf.ShouldBe(0.02m); } [Fact] public void Before_the_first_run_it_estimates_from_the_model_price() { var task = Hourly("t-1"); CostEstimate Price(string model, int prompt, int completion) => new(prompt / 1_000_000m + completion * 5 / 1_000_000m, true, model); var p = Compute([task], estimate: Price); var line = p.Aufgaben.Single(); line.Grundlage.ShouldBe(ForecastBasis.Schaetzung); var expected = CostForecast.EstimatedPromptTokens(task, Messages) / 1_000_000m + CostForecast.AssumedSteps * CostForecast.AssumedAnswerTokensPerStep * 5 / 1_000_000m; line.KostenJeLauf.ShouldBe(Math.Round(expected, 4)); } [Fact] public void Without_price_and_runs_it_stays_honestly_unknown() { var p = Compute([Hourly("t-1")], estimate: (m, _, _) => new CostEstimate(0m, false, m)); var line = p.Aufgaben.Single(); line.Grundlage.ShouldBe(ForecastBasis.Unbekannt); line.KostenJeLauf.ShouldBeNull(); line.KostenProTag.ShouldBe(0m); p.OhneKostenbasis.ShouldBe(1); } [Fact] public void Paused_and_human_tasks_cost_nothing() => Compute([Hourly("t-p", status: TaskItemStatus.Backlog), Hourly("t-h", "@human")]) .Aufgaben.ShouldBeEmpty(); [Fact] public void Other_runs_are_the_daily_average_outside_the_tasks() { var p = Compute( [Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.14m)])), [new RunUsage { AgentId = "nachrichten", CostUsd = 0.70m, CostIsKnown = true, OccurredAt = Now.AddDays(-1) }]); p.SonstigeProTag.ShouldBe(0.08m); // (0,70 - 0,14) / 7 p.AufgabenProTag.ShouldBe(3.36m); // 0,14 * 24 p.SummeProTag.ShouldBe(3.44m); } [Fact] public void Budget_rejections_do_not_lower_the_average() { // #608: A rejection stands as a receipt of 0 USD in the history - it is not a run. var rejected = Receipt("t-1", 0m) with { Status = "BudgetExceeded" }; var line = Compute([Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.02m, 2), rejected]))) .Aufgaben.Single(); line.KostenJeLauf.ShouldBe(0.02m); line.Stichprobe.ShouldBe(1); } [Fact] public void Warns_when_the_tasks_exceed_the_agents_daily_budget() { var tight = new AgentConfig { AgentId = "nachrichten", Model = Messages.Model, SystemPrompt = "x" }; tight.Budget.DailyCostUsd = 0.40m; var p = CostForecast.Calculate( [Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.02m)])), [], [tight], null, Now); var warning = p.Budgetwarnungen.ShouldHaveSingleItem(); warning.AgentId.ShouldBe("nachrichten"); warning.KostenProTag.ShouldBe(0.48m); warning.BudgetProTag.ShouldBe(0.40m); } [Theory] [InlineData(0)] // no limit set [InlineData(1.0)] // budget suffices public void Without_limit_or_with_headroom_no_budget_warning(double budget) { var agent = new AgentConfig { AgentId = "nachrichten", Model = Messages.Model, SystemPrompt = "x" }; agent.Budget.DailyCostUsd = (decimal)budget; CostForecast.Calculate([Hourly("t-1")], Receipts(("t-1", [Receipt("t-1", 0.02m)])), [], [agent], null, Now) .Budgetwarnungen.ShouldBeEmpty(); } [Fact] public void Tool_job_counts_only_actual_wakeups() { var job = new TaskItem { Id = "tj", Title = "Telegram", Assignee = "@nachrichten", Type = TaskItemType.ToolJob, Status = TaskItemStatus.Todo, When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "* * * * *", TimeZone = "UTC" } }; var p = Compute([job], Receipts(("tj", [Receipt("tj", 0.07m), Receipt("tj", 0.07m, 30)]))); var line = p.Aufgaben.Single(); line.KostenProTag.ShouldBe(0.02m, "zwei Weckungen in sieben Tagen, nicht 1.440 Ticks je Tag"); line.LaeufeProTag.ShouldBe(0.29); } }