using System.Text.Json; using ClawdDotNet.Core.State; 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; /// /// task_find und die Dublettenbremse (#674). /// /// Anlass aus dem Testbetrieb: Die Betriebskontrolle laeuft alle drei Stunden und legte /// bei jedem Lauf erneut an, was beim vorigen schon offen war — ueber dreissig Aufgaben /// an @human, darunter sechsmal derselbe Kursbefund. /// public sealed class DublettenTests : IDisposable { private readonly string _verzeichnis; private readonly SqliteStorage _storage; private readonly SqliteTaskRepository _repo; private readonly TaskboardService _board; private readonly TaskboardTool _tool = new(); public DublettenTests() { _verzeichnis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _storage = new SqliteStorage(Path.Combine(_verzeichnis, "state.db")); _repo = new SqliteTaskRepository(_storage); _board = new TaskboardService(_repo, Path.Combine(_verzeichnis, "SharedWorkspace", "tasks")); } public void Dispose() { ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis); try { Directory.Delete(_verzeichnis, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } private AgentToolContext Context() => new( "hausmeister", "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None, SharedWorkspacePath: Path.Combine(_verzeichnis, "SharedWorkspace"), Tasks: _repo); private Task Run(string json) => _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None); private Task Anlegen(string titel, TaskItemStatus status = TaskItemStatus.Todo) => _board.CreateAsync(new TaskItem { Title = titel, Status = status, Assignee = "@human", Body = "Beobachtet." }, default); // ─── task_find ─── [Fact] public async Task Ohne_Treffer_sagt_die_Antwort_dass_angelegt_werden_darf() { await Anlegen("Betriebsluecke: Wetter Warnungen veraltet"); var result = await Run("""{"action":"task_find","title":"Betriebsluecke: Eur Kurse nicht aktuell"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("No open task resembles"); result.Content.ShouldContain("task_create"); } [Fact] public async Task Der_gleiche_Befund_wird_mit_Id_Alter_und_Einstufung_gefunden() { var vorhanden = await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell"); var result = await Run("""{"action":"task_find","title":"Betriebsluecke: Kurse EUR nicht aktuell"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain(vorhanden.Id); result.Content.ShouldContain("same issue"); result.Content.ShouldContain("open since"); result.Content.ShouldContain("task_comment", Case.Sensitive, "der Agent soll ergaenzen, nicht neu anlegen"); } [Fact] public async Task Ohne_Titel_fragt_das_Werkzeug_nach() { var result = await Run("""{"action":"task_find"}"""); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain("'title' is required"); } // ─── die Bremse beim Anlegen ─── [Fact] public async Task Eine_zweite_gleichlautende_Aufgabe_wird_nicht_angelegt() { var erste = await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell"); var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Kurse EUR nicht aktuell","assignee":"@human"}"""); result.Success.ShouldBeFalse(); result.ErrorMessage.ShouldNotBeNull().ShouldContain(erste.Id); result.ErrorMessage.ShouldContain("recorded"); (await _board.ListAsync(new TaskQuery { Limit = 50 }, default)).Count .ShouldBe(1, "es bleibt bei der einen Aufgabe"); } [Fact] public async Task Das_erneute_Auftreten_steht_danach_an_der_bestehenden_Aufgabe() { var erste = await Anlegen("Betriebsluecke: Nachrichten fehlen"); await Run("""{"action":"task_create","title":"Betriebsluecke: Nachrichten fehlen","assignee":"@human"}"""); var datei = await File.ReadAllTextAsync(Path.Combine(_board.TasksDirectory, (await _board.GetAsync(erste.Id, default))!.FileName)); datei.ShouldContain("Observed again"); datei.ShouldContain("hausmeister", Case.Insensitive); } [Fact] public async Task Ein_anderer_Sachverhalt_kommt_ganz_normal_durch() { await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell"); var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Wetter Warnungen stand.md veraltet","assignee":"@human"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); (await _board.ListAsync(new TaskQuery { Limit = 50 }, default)).Count.ShouldBe(2); } [Fact] public async Task Mit_force_darf_der_Agent_trotzdem_anlegen() { await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell"); var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Eur Kurse nicht aktuell","assignee":"@human","force":true}"""); result.Success.ShouldBeTrue(result.ErrorMessage); (await _board.ListAsync(new TaskQuery { Limit = 50 }, default)).Count.ShouldBe(2); } [Fact] public async Task Die_Dublette_wird_auch_hinter_500_anderen_Aufgaben_gefunden() { // #716: Die Suche las ListAsync mit Limit 500, sortiert nach Prioritaet. Eine alte // Aufgabe mit niedriger Prioritaet fiel damit aus dem Blick, sobald das Brett wuchs. var alt = await _board.CreateAsync(new TaskItem { Title = "Betriebsluecke: Eur Kurse nicht aktuell", Assignee = "@human", Priority = 1 }, default); for (var i = 0; i < 510; i++) await _repo.UpsertAsync(new TaskItem { Id = $"t-fill-{i}", Title = $"Anderes Thema {i}", Priority = 5 }, default); var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Kurse EUR nicht aktuell","assignee":"@human"}"""); result.Success.ShouldBeFalse("die alte Aufgabe meint dasselbe"); result.ErrorMessage.ShouldNotBeNull().ShouldContain(alt.Id); } [Fact] public async Task Eine_erledigte_Aufgabe_blockiert_den_naechsten_Befund_nicht() { await Anlegen("Betriebsluecke: Eur Kurse nicht aktuell", TaskItemStatus.Done); var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Eur Kurse nicht aktuell","assignee":"@human"}"""); result.Success.ShouldBeTrue(result.ErrorMessage); } }