using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Automation; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Automation; /// /// #726: Agents propose workflows, measured and checked for free; the human decides and imports /// by hand. No flood, and a rejected proposal does not come back without a new argument. /// public sealed class AutomationProposalsTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]); private readonly SqliteAuditRepository _audit; private readonly SqliteTaskRepository _tasks; private readonly TaskboardService _board; private readonly AutomationProposals _proposals; private const string Workflow = """ {"nodes":[ {"name":"Every morning","type":"n8n-nodes-base.scheduleTrigger","parameters":{}}, {"name":"Get rates","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://api.rates.example/eur","headers":{"x":"={{ $env.TOKEN }}"}}}, {"name":"Mail","type":"n8n-nodes-base.emailSend","parameters":{"toEmail":"desk@example.org"},"credentials":{"smtp":{"id":"1","name":"Office SMTP"}}} ],"connections":{}} """; public AutomationProposalsTests() { var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _audit = new SqliteAuditRepository(storage); _tasks = new SqliteTaskRepository(storage); _board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks")); _proposals = new AutomationProposals(storage, _audit, _tasks, _board, NullLogger.Instance); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } // ─── the review ─── [Fact] public void The_review_says_what_the_workflow_does_and_where_it_reaches() { var review = WorkflowReview.Review(Workflow, null); review.Ok.ShouldBeTrue(string.Join(" ", review.Problems)); review.Nodes.Select(n => n.Effect).ShouldBe(["starts the workflow", "calls an address", "sends mail"]); review.Targets.ShouldBe(["api.rates.example", "desk@example.org"]); review.Summary.ShouldContain("Reaches outside: api.rates.example"); } [Fact] public void Code_needs_a_reason_and_a_secret_is_never_carried() { const string code = """{"nodes":[{"name":"Calc","type":"n8n-nodes-base.code","parameters":{"jsCode":"return 1"}}]}"""; WorkflowReview.Review(code, null).Problems.ShouldContain(p => p.Contains("Without a reason")); var reasoned = WorkflowReview.Review(code, "Rounds prices the same way the shop does."); reasoned.Ok.ShouldBeTrue(); reasoned.Warnings.ShouldContain(w => w.Contains("Rounds prices")); WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://x.example","apiKey":"k-123"}}]}""", null) .Problems.ShouldContain(p => p.Contains("parameters") || p.Contains("apiKey")); WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"headerValue":"Bearer abc"}}]}""", null) .Ok.ShouldBeFalse("a bearer token by its look"); WorkflowReview.Review("not json", null).Ok.ShouldBeFalse(); WorkflowReview.Review("""{"nodes":[]}""", null).Ok.ShouldBeFalse(); } // ─── proposing ─── private async Task RecurringTask(int runs, string agent = "desk", bool same = true) { var task = await _board.CreateAsync(new TaskItem { Title = "Morning exchange rates", Assignee = "@" + agent, When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 7 * * *", TimeZone = "Europe/Berlin" } }, default); for (var i = 0; i < runs; i++) { var run = $"run-{task.Id}-{i}"; await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = "WebFetch", Arguments = "{\"action\":\"fetch\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default); await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = same || i % 2 == 0 ? "Mail" : "FileRW", Arguments = "{\"action\":\"send\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default); await _audit.RecordReceiptAsync(new RunReceipt { RunId = run, AgentId = agent, TaskId = task.Id, Status = "Completed", CostUsd = 0.05m, CostIsKnown = true, PromptTokens = 1000, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default); } return task.Id; } private AutomationRequest Request(string taskId, string purpose = "Send the morning exchange rates", long? answers = null, string? argument = null) => new("desk", purpose, "every morning at 7", [taskId], ["Office SMTP"], "Sends rates to the desk only.", "Run once by hand, check the mail.", Workflow, null, answers, argument); [Fact] public async Task A_proposal_carries_its_measured_saving_and_goes_to_a_human() { var taskId = await RecurringTask(10); var outcome = await _proposals.ProposeAsync(Request(taskId), default); outcome.Ok.ShouldBeTrue(outcome.Message); var proposal = (await _proposals.ListAsync("proposed", 10, default)).Single(); proposal.Savings.Runs.ShouldBe(10); proposal.Savings.CostUsd.ShouldBe(0.50m); proposal.Summary.ShouldContain("api.rates.example"); (await _board.ListAsync(new TaskQuery { Assignee = TaskAssignee.Human }, default)).ShouldContain(t => t.Title.Contains($"#{proposal.Id}")); } [Fact] public async Task The_same_purpose_is_not_proposed_twice_and_a_rejection_must_be_answered() { var taskId = await RecurringTask(3); var first = await _proposals.ProposeAsync(Request(taskId), default); (await _proposals.ProposeAsync(Request(taskId, "Send the exchange rates every morning"), default)).Message.ShouldContain($"#{first.Id}"); (await _proposals.DecideAsync(first.Id!.Value, false, "", default)).Ok.ShouldBeFalse("a rejection needs its reason"); (await _proposals.DecideAsync(first.Id!.Value, false, "Rates change format often — the agent copes, a workflow breaks.", default)).Ok.ShouldBeTrue(); (await _proposals.ProposeAsync(Request(taskId), default)).Message.ShouldContain("must answer"); (await _proposals.ProposeAsync(Request(taskId, answers: first.Id, argument: "The rates API now has a versioned, stable format."), default)) .Ok.ShouldBeTrue(); (await _proposals.FindAsync("exchange rates", default)).Count.ShouldBe(2); } [Fact] public async Task An_agent_has_at_most_three_open_proposals() { for (var i = 0; i < 3; i++) (await _proposals.ProposeAsync(Request(await RecurringTask(1), $"Purpose number {i} quite different {Guid.NewGuid():N}"), default)).Ok.ShouldBeTrue(); (await _proposals.ProposeAsync(Request(await RecurringTask(1), "A fourth unrelated thing entirely"), default)).Message.ShouldContain("Wait for a decision"); } [Fact] public async Task A_proposal_without_the_required_parts_or_a_broken_workflow_is_refused() { var taskId = await RecurringTask(1); (await _proposals.ProposeAsync(Request(taskId) with { TaskIds = [] }, default)).Message.ShouldContain("'tasks'"); (await _proposals.ProposeAsync(Request("t-nope"), default)).Message.ShouldContain("no task"); (await _proposals.ProposeAsync(Request(taskId) with { WorkflowJson = """{"nodes":[{"name":"x","type":"n8n-nodes-base.executeCommand"}]}""" }, default)) .Message.ShouldContain("does not pass the check"); } // ─── candidates ─── [Fact] public async Task A_recurring_task_that_does_the_same_each_time_is_put_to_its_agent_once() { var same = await RecurringTask(9, "desk"); var varied = await RecurringTask(9, "other", same: false); var noted = await _proposals.FindCandidatesAsync(default); noted.ShouldBe([same], "half the runs of the other task differ"); (await _board.ListAsync(new TaskQuery { Assignee = "@desk" }, default)).ShouldContain(t => t.Title.StartsWith("Automation candidate")); (await _proposals.FindCandidatesAsync(default)).ShouldBeEmpty("once a month, not every day"); varied.ShouldNotBe(same); } [Fact] public void The_signature_folds_repeats_and_leaves_out_bookkeeping() { AuditEntry E(string tool, string op) => new() { Tool = tool, Arguments = $"{{\"action\":\"{op}\"}}", Status = AuditStatus.Ok }; AutomationProposals.Signature([E("FileRW", "read"), E("FileRW", "read"), E("Taskboard", "complete"), E("Mail", "send")]) .ShouldBe("FileRW.read > Mail.send"); } }