FileRW list sortiert und gedeckelt (#673), Taskboard task_find (#674)

Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend, die
es gab: 105 Eintraege in der Reihenfolge des Dateisystems, vierzeilig als
eingeruecktes JSON. list liefert jetzt die 50 zuletzt geaenderten Eintraege,
neueste zuerst, eine Zeile je Eintrag, mit Gesamtzahl im Kopf; pattern, sort,
order und limit steuern das.

Dabei aufgefallen (#675): Directory.GetFiles wertet Trennzeichen im Muster
aus - pattern "../*" fuehrte an der Pfadpruefung vorbei aus dem Verzeichnis
heraus, in list wie in read_many. Muster mit '/', '\' oder '..' werden jetzt
abgewiesen.

Und derselbe Befund ist keine zweite Aufgabe (#674): task_find beantwortet
die Frage vor dem Anlegen, task_create legt bei gleichbedeutendem Titel einer
offenen Aufgabe keine zweite an, sondern vermerkt das erneute Auftreten an
der bestehenden. force=true geht trotzdem, Erledigtes blockiert nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-21 21:19:19 +02:00
co-authored by Claude Opus 5
parent 24e457b11f
commit 060dc6a6ad
10 changed files with 815 additions and 19 deletions
@@ -0,0 +1,155 @@
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;
/// <summary>
/// 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.
/// </summary>
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()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_verzeichnis, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private AgentToolContext Context() => new(
"hausmeister", "inst",
new Dictionary<string, object?>(),
new NullStateStore(),
NullLogger.Instance,
CancellationToken.None,
SharedWorkspacePath: Path.Combine(_verzeichnis, "SharedWorkspace"),
Tasks: _repo);
private Task<ToolResult> Run(string json)
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
private Task<TaskItem> 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("Keine offene Aufgabe ähnelt");
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("derselbe Sachverhalt");
result.Content.ShouldContain("offen seit");
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' ist erforderlich");
}
// ─── 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("vermerkt");
(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("Erneut beobachtet");
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 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);
}
}