using System.Text.Json; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; 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; /// /// #684: One board per unit. The bookkeeper's tasks do not show up for marketing, do not /// hold up a marketing task as a "duplicate", and cannot be commented on from outside. /// /// /// marketing (lead: marketing-lead) /// └── amazon (lead: amazon-lead) · ads /// accounting (lead: accounting-lead) · bookkeeper /// ceo on the top level /// /// public sealed class UnitBoardTests : IDisposable { private readonly string _directory; private readonly SqliteStorage _storage; private readonly SqliteTaskRepository _repo; private readonly TaskboardTool _tool = new(); private readonly List _agents = [ new() { AgentId = "ceo" }, new() { AgentId = "marketing-lead", UnitId = "marketing" }, new() { AgentId = "amazon-lead", UnitId = "amazon" }, new() { AgentId = "ads", UnitId = "amazon" }, new() { AgentId = "accounting-lead", UnitId = "accounting" }, new() { AgentId = "bookkeeper", UnitId = "accounting" } ]; private readonly List _units = [ new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" }, new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" }, new() { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" } ]; public UnitBoardTests() { _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _repo = new SqliteTaskRepository(_storage); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private string TasksDir => Path.Combine(_directory, "SharedWorkspace", "tasks"); private async Task Run(string agentId, object input, bool withUnits = true) { var context = new AgentToolContext( agentId, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo) { Organisation = withUnits ? new OrgChart(_units, _agents) : new OrgChart([], _agents) }; return await _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None); } private async Task Create(string agentId, string title, string assignee = "@human") { var result = await Run(agentId, new { action = "task_create", title, assignee, force = true }); result.Success.ShouldBeTrue(result.ErrorMessage); return result.Content.Split(' ', 4)[2]; // "Task created: — …" } private async Task List(string agentId, bool withUnits = true) => (await Run(agentId, new { action = "task_list" }, withUnits)).Content; [Fact] public async Task Each_unit_sees_its_own_board() { await Create("bookkeeper", "Book the invoices of September"); await Create("ads", "Raise the bid for chairs"); var marketing = await List("ads"); var accounting = await List("bookkeeper"); marketing.ShouldContain("Raise the bid"); marketing.ShouldNotContain("invoices"); accounting.ShouldContain("invoices"); accounting.ShouldNotContain("Raise the bid"); } [Fact] public async Task A_lead_also_sees_the_boards_of_the_units_below() { await Create("ads", "Raise the bid for chairs"); await Create("bookkeeper", "Book the invoices of September"); var lead = await List("marketing-lead"); lead.ShouldContain("Raise the bid", Case.Sensitive, "amazon lies below marketing"); lead.ShouldNotContain("invoices"); } [Fact] public async Task A_task_handed_up_stays_in_view_of_whoever_handed_it_up() { // amazon-lead's lead is marketing-lead — the task lands on marketing's board … await Create("amazon-lead", "Approve the new budget", "@marketing-lead"); (await List("marketing-lead")).ShouldContain("Approve the new budget"); // … and the one who created it still sees it, although amazon's board does not hold it. (await List("amazon-lead")).ShouldContain("Approve the new budget"); (await List("ads")).ShouldNotContain("Approve the new budget"); } [Fact] public async Task A_similar_task_on_another_board_is_no_duplicate() { await Create("bookkeeper", "Check the supplier invoice"); var result = await Run("ads", new { action = "task_create", title = "Check the supplier invoice" }); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldNotContain("Observed again"); (await Run("bookkeeper", new { action = "task_find", title = "Check the supplier invoice" })).Content .ShouldContain("1 open task(s)", Case.Sensitive, "and nothing was written onto the bookkeeper's task"); } [Fact] public async Task A_task_on_another_board_cannot_be_changed_or_commented_on() { var id = await Create("bookkeeper", "Book the invoices of September"); var update = await Run("ads", new { action = "task_update", id, status = "canceled" }); var comment = await Run("ads", new { action = "task_comment", id, text = "not needed" }); var unknown = await Run("ads", new { action = "task_comment", id = "t-does-not-exist", text = "x" }); update.Success.ShouldBeFalse(); comment.Success.ShouldBeFalse(); comment.ErrorMessage!.Replace(id, "X").ShouldBe(unknown.ErrorMessage!.Replace("t-does-not-exist", "X"), "the same answer as for a task that does not exist"); (await _repo.GetAsync(id, default))!.Status.ShouldBe(TaskItemStatus.Todo); } [Fact] public async Task An_open_task_moves_with_its_agent() { await Create("amazon-lead", "Prepare the listing", "@ads"); (await List("bookkeeper")).ShouldNotContain("Prepare the listing"); _agents.Single(a => a.AgentId == "ads").UnitId = "accounting"; (await List("bookkeeper")).ShouldContain("Prepare the listing", Case.Sensitive, "the board follows the assignee"); } [Fact] public async Task Without_units_there_is_one_board_for_everyone() { await Create("bookkeeper", "Book the invoices of September"); (await List("ads", withUnits: false)).ShouldContain("invoices"); } [Fact] public async Task The_creator_is_kept_in_the_file_and_survives_a_re_import() { var id = await Create("ads", "Raise the bid for chairs"); var file = Directory.GetFiles(TasksDir).Single(); File.ReadAllText(file).ShouldContain("created_by: ads"); // A file written by hand without the field keeps what the database knows. File.WriteAllText(file, File.ReadAllText(file).Replace("created_by: ads\n", "").Replace("created_by: ads\r\n", "")); await new TaskboardService(_repo, TasksDir).ImportAllAsync(default); (await _repo.GetAsync(id, default))!.CreatedBy.ShouldBe("ads"); } }