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:
@@ -18,7 +18,7 @@
|
|||||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||||
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
||||||
-->
|
-->
|
||||||
<Version>0.1.14</Version>
|
<Version>0.1.15</Version>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
||||||
|
|||||||
@@ -240,10 +240,23 @@ Memory`):
|
|||||||
| Aktion | Zweck |
|
| Aktion | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `task_create` | Aufgabe anlegen — schreibt Datei **und** DB-Zeile (über den Importer). Vergibt die `id`. |
|
| `task_create` | Aufgabe anlegen — schreibt Datei **und** DB-Zeile (über den Importer). Vergibt die `id`. |
|
||||||
|
| `task_find` | Offene Aufgaben mit ähnlichem Titel — die Frage vor dem Anlegen (#674). |
|
||||||
| `task_list` | Aufgaben filtern (Status, Assignee, Betreff). Gekappte, kontextschonende Ausgabe wie bei Memory. |
|
| `task_list` | Aufgaben filtern (Status, Assignee, Betreff). Gekappte, kontextschonende Ausgabe wie bei Memory. |
|
||||||
| `task_update` | Status/Felder ändern; `blocked` melden; Ergebnis eintragen. |
|
| `task_update` | Status/Felder ändern; `blocked` melden; Ergebnis eintragen. |
|
||||||
| `task_comment` | Kommentar/Kritik anhängen; mit Reopen den Zuständigen erneut wecken. |
|
| `task_comment` | Kommentar/Kritik anhängen; mit Reopen den Zuständigen erneut wecken. |
|
||||||
|
|
||||||
|
**Derselbe Befund ist keine zweite Aufgabe (#674).** Titel werden normalisiert (Kleinschrift,
|
||||||
|
Umlaute gefaltet, Satzzeichen weg) und als Wortmengen verglichen; „Eur Kurse nicht aktuell"
|
||||||
|
und „Kurse EUR nicht aktuell" sind dieselbe Aufgabe. Findet `task_create` eine *offene*
|
||||||
|
Aufgabe mit gleichbedeutendem Titel, legt es keine zweite an, sondern vermerkt das erneute
|
||||||
|
Auftreten als Kommentar an der bestehenden und verweist darauf. `force: true` legt trotzdem
|
||||||
|
an — für den Fall, dass es wirklich ein anderer Sachverhalt ist. Erledigte und verworfene
|
||||||
|
Aufgaben blockieren nichts: derselbe Befund darf wiederkommen.
|
||||||
|
|
||||||
|
Anlass war der Testbetrieb (#572): Eine Betriebskontrolle alle drei Stunden legte über
|
||||||
|
dreißig Aufgaben an `@human` an, sechs davon mit demselben Kursbefund. Aus einer solchen
|
||||||
|
Liste ist nicht mehr abzulesen, ob ein Problem neu ist oder seit Tagen unbeantwortet liegt.
|
||||||
|
|
||||||
Agent-zu-Agent-Delegation läuft künftig hierüber: Statt rekursivem `send_message` legt
|
Agent-zu-Agent-Delegation läuft künftig hierüber: Statt rekursivem `send_message` legt
|
||||||
ein Agent eine Aufgabe mit `assignee: @<other>` an. Das ist strukturell zyklenfrei (B8) —
|
ein Agent eine Aufgabe mit `assignee: @<other>` an. Das ist strukturell zyklenfrei (B8) —
|
||||||
eine Aufgabe ist ein Datensatz, kein synchroner Aufruf-Stack.
|
eine Aufgabe ist ein Datensatz, kein synchroner Aufruf-Stack.
|
||||||
|
|||||||
@@ -268,6 +268,17 @@ Schlägt ein Zugriff fehl, weil der Pfad im *anderen* Workspace liegt, sagt die
|
|||||||
Fehlermeldung das (#662) – aber nur, wenn der Agent den anderen Workspace auch benutzen
|
Fehlermeldung das (#662) – aber nur, wenn der Agent den anderen Workspace auch benutzen
|
||||||
darf.
|
darf.
|
||||||
|
|
||||||
|
`list` zeigt die 50 zuletzt geänderten Einträge, neueste zuerst, eine Zeile je Eintrag;
|
||||||
|
`pattern`, `sort` (`modified`/`name`), `order` (`desc`/`asc`) und `limit` (max 500) steuern
|
||||||
|
das. Der Kopf nennt immer die Gesamtzahl – eine gedeckelte Liste, die ihre Deckelung
|
||||||
|
verschweigt, ist die schlimmere Falle. Der Grund steht in #673: Ein Verzeichnis mit 105
|
||||||
|
Dateien, unsortiert und als eingerücktes JSON ausgegeben, ließ die Betriebskontrolle zwei
|
||||||
|
vorhandene Dateien als fehlend melden.
|
||||||
|
|
||||||
|
`pattern` ist ein **Dateiname mit Platzhaltern, kein Pfad**. Trennzeichen und `..` werden
|
||||||
|
abgewiesen, weil `Directory.GetFiles` sie auswertet und ein Muster sonst an der
|
||||||
|
Pfadprüfung vorbei aus dem Verzeichnis herausführt (#675).
|
||||||
|
|
||||||
**Config-Felder:**
|
**Config-Felder:**
|
||||||
- `rootPath` – Basisverzeichnis (Agent ist darin eingesperrt)
|
- `rootPath` – Basisverzeichnis (Agent ist darin eingesperrt)
|
||||||
- `allowWrite` – `true`/`false`
|
- `allowWrite` – `true`/`false`
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tasks;
|
||||||
|
|
||||||
|
/// <summary>How closely two task titles describe the same thing.</summary>
|
||||||
|
public enum TaskTitleMatch
|
||||||
|
{
|
||||||
|
/// <summary>Different subjects.</summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>Related enough to be worth showing, not close enough to refuse a new task.</summary>
|
||||||
|
Similar,
|
||||||
|
|
||||||
|
/// <summary>The same finding said twice.</summary>
|
||||||
|
Duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Title comparison for the taskboard (#674).
|
||||||
|
///
|
||||||
|
/// <para>Observed in the test operation: an agent that runs every three hours re-reported the
|
||||||
|
/// same gap on every run. Six tasks said "Eur Kurse nicht aktuell", five said the news were
|
||||||
|
/// missing. Nobody can tell from that whether a problem is new or three days old.</para>
|
||||||
|
///
|
||||||
|
/// <para>The comparison is deliberately shallow - normalise, then compare word sets. Two titles
|
||||||
|
/// that differ only in word order, case, umlaut spelling or punctuation are the same title;
|
||||||
|
/// a title fully contained in a longer one is the same finding with more detail. Anything
|
||||||
|
/// cleverer would be guesswork a cheap model cannot audit.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class TaskSimilarity
|
||||||
|
{
|
||||||
|
/// <summary>Jaccard overlap from which two titles count as the same finding.</summary>
|
||||||
|
public const double DuplicateThreshold = 0.8;
|
||||||
|
|
||||||
|
/// <summary>Overlap from which two titles are worth showing side by side.</summary>
|
||||||
|
public const double SimilarThreshold = 0.5;
|
||||||
|
|
||||||
|
/// <summary>Statuses that still ask for attention - only these can be duplicated.</summary>
|
||||||
|
public static bool IsOpen(TaskItemStatus status) => status is
|
||||||
|
TaskItemStatus.Backlog or TaskItemStatus.Todo or TaskItemStatus.InProgress
|
||||||
|
or TaskItemStatus.InReview or TaskItemStatus.Blocked;
|
||||||
|
|
||||||
|
/// <summary>Lower case, umlauts folded, punctuation dropped, whitespace collapsed.</summary>
|
||||||
|
public static string Normalize(string? title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title)) return "";
|
||||||
|
|
||||||
|
var folded = new StringBuilder(title.Length);
|
||||||
|
foreach (var c in title.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case 'ä': folded.Append("ae"); break;
|
||||||
|
case 'ö': folded.Append("oe"); break;
|
||||||
|
case 'ü': folded.Append("ue"); break;
|
||||||
|
case 'ß': folded.Append("ss"); break;
|
||||||
|
default: folded.Append(c); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining diacritics (é, ç, …) lose their marks rather than their letter.
|
||||||
|
var decomposed = folded.ToString().Normalize(NormalizationForm.FormD);
|
||||||
|
var plain = new StringBuilder(decomposed.Length);
|
||||||
|
foreach (var c in decomposed)
|
||||||
|
{
|
||||||
|
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
|
||||||
|
plain.Append(char.IsLetterOrDigit(c) ? c : ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(' ', plain.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Word set of a normalised title.</summary>
|
||||||
|
private static HashSet<string> Words(string normalized)
|
||||||
|
=> new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>How closely two titles match.</summary>
|
||||||
|
public static TaskTitleMatch Compare(string? left, string? right)
|
||||||
|
{
|
||||||
|
var a = Normalize(left);
|
||||||
|
var b = Normalize(right);
|
||||||
|
if (a.Length == 0 || b.Length == 0) return TaskTitleMatch.None;
|
||||||
|
if (a == b) return TaskTitleMatch.Duplicate;
|
||||||
|
|
||||||
|
var wordsA = Words(a);
|
||||||
|
var wordsB = Words(b);
|
||||||
|
var shared = wordsA.Intersect(wordsB, StringComparer.Ordinal).Count();
|
||||||
|
if (shared == 0) return TaskTitleMatch.None;
|
||||||
|
|
||||||
|
// "Betriebsluecke: Eur Kurse" inside "Betriebsluecke: Eur Kurse nicht aktuell" is the
|
||||||
|
// same finding, told at different lengths. A single shared word is not - "Wetter"
|
||||||
|
// would swallow every weather task there is.
|
||||||
|
var (smaller, larger) = wordsA.Count <= wordsB.Count ? (wordsA, wordsB) : (wordsB, wordsA);
|
||||||
|
if (smaller.Count >= 2 && shared == smaller.Count) return TaskTitleMatch.Duplicate;
|
||||||
|
|
||||||
|
var overlap = (double)shared / (wordsA.Count + wordsB.Count - shared);
|
||||||
|
if (overlap >= DuplicateThreshold) return TaskTitleMatch.Duplicate;
|
||||||
|
if (overlap >= SimilarThreshold) return TaskTitleMatch.Similar;
|
||||||
|
return TaskTitleMatch.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open tasks whose title describes the same thing as <paramref name="title"/>,
|
||||||
|
/// closest match first. <paramref name="exceptId"/> skips the task being edited.
|
||||||
|
/// </summary>
|
||||||
|
public static List<(TaskItem Task, TaskTitleMatch Match)> FindOpen(
|
||||||
|
IEnumerable<TaskItem> tasks, string? title, string? exceptId = null)
|
||||||
|
{
|
||||||
|
var hits = new List<(TaskItem Task, TaskTitleMatch Match)>();
|
||||||
|
foreach (var task in tasks)
|
||||||
|
{
|
||||||
|
if (!IsOpen(task.Status)) continue;
|
||||||
|
if (exceptId is not null && string.Equals(task.Id, exceptId, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var match = Compare(task.Title, title);
|
||||||
|
if (match != TaskTitleMatch.None) hits.Add((task, match));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits
|
||||||
|
.OrderByDescending(h => h.Match)
|
||||||
|
.ThenByDescending(h => h.Task.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
"'read_many' liest mehrere Dateien in EINEM Aufruf - entweder 'paths' (Liste) oder 'path' " +
|
"'read_many' liest mehrere Dateien in EINEM Aufruf - entweder 'paths' (Liste) oder 'path' " +
|
||||||
"(Verzeichnis) mit 'pattern' (z.B. \"berlin_2026-09-20_*.txt\"). Nimm das, statt viele " +
|
"(Verzeichnis) mit 'pattern' (z.B. \"berlin_2026-09-20_*.txt\"). Nimm das, statt viele " +
|
||||||
"Dateien einzeln zu lesen: jeder Einzelaufruf kostet einen Schritt.\n" +
|
"Dateien einzeln zu lesen: jeder Einzelaufruf kostet einen Schritt.\n" +
|
||||||
|
"'list' zeigt standardmäßig die 50 zuletzt geänderten Einträge, neueste zuerst; " +
|
||||||
|
"'pattern', 'sort', 'order' und 'limit' steuern das. Frage nach einer bestimmten Datei " +
|
||||||
|
"lieber mit 'pattern', statt eine lange Liste zu durchsuchen.\n" +
|
||||||
"Bei copy kann zwischen Workspaces kopiert werden.\n" +
|
"Bei copy kann zwischen Workspaces kopiert werden.\n" +
|
||||||
"'stock_add' fügt einen neuen Datenpunkt zur Aktien-Wissensdatenbank hinzu (shared:stocks/{ticker}/). " +
|
"'stock_add' fügt einen neuen Datenpunkt zur Aktien-Wissensdatenbank hinzu (shared:stocks/{ticker}/). " +
|
||||||
"Geschützte Pfade (protectedPaths) erlauben nur das Erstellen neuer Dateien — kein Überschreiben oder Löschen.";
|
"Geschützte Pfade (protectedPaths) erlauben nur das Erstellen neuer Dateien — kein Überschreiben oder Löschen.";
|
||||||
@@ -64,7 +67,21 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
},
|
},
|
||||||
"pattern": {
|
"pattern": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Nur für read_many: Dateimuster im Verzeichnis 'path', z.B. 'berlin_2026-09-20_*.txt'. Ohne Muster werden alle Dateien des Verzeichnisses gelesen."
|
"description": "Für read_many und list: Dateimuster im Verzeichnis 'path', z.B. 'berlin_2026-09-20_*.txt'. Ohne Muster gilt das ganze Verzeichnis."
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["modified", "name"],
|
||||||
|
"description": "Nur für list: Sortierung. Standard 'modified' (neueste zuerst)."
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desc", "asc"],
|
||||||
|
"description": "Nur für list: Richtung. Standard absteigend bei 'modified', aufsteigend bei 'name'."
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Nur für list: Höchstzahl der Einträge. Standard 50, Maximum 500. Der Kopf der Antwort nennt immer die Gesamtzahl."
|
||||||
},
|
},
|
||||||
"destinationWorkspace": {
|
"destinationWorkspace": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -520,6 +537,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
var muster = input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String
|
var muster = input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String
|
||||||
? m.GetString()
|
? m.GetString()
|
||||||
: null;
|
: null;
|
||||||
|
if (MusterFehler(muster) is { } musterFehler)
|
||||||
|
return ([], musterFehler);
|
||||||
|
|
||||||
var gefunden = Directory.GetFiles(verzeichnis, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
|
var gefunden = Directory.GetFiles(verzeichnis, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
|
||||||
.OrderBy(f => f, StringComparer.Ordinal)
|
.OrderBy(f => f, StringComparer.Ordinal)
|
||||||
@@ -616,6 +635,25 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wie viele Eintraege ein <c>list</c> ohne ausdrueckliches <c>limit</c> zeigt.</summary>
|
||||||
|
public const int StandardEintraege = 50;
|
||||||
|
|
||||||
|
/// <summary>Obergrenze fuer <c>limit</c> - darueber wird die Antwort zur Heuhaufensuche.</summary>
|
||||||
|
public const int MaxEintraege = 500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #673: Verzeichnis auflisten - sortiert, gedeckelt, kompakt.
|
||||||
|
///
|
||||||
|
/// <para>Anlass: Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend,
|
||||||
|
/// die es gab. Das Verzeichnis lieferte 105 Eintraege in der Reihenfolge des Dateisystems,
|
||||||
|
/// vierzeilig als eingeruecktes JSON. Darin zwei bestimmte Namen zu finden ist Sucharbeit,
|
||||||
|
/// und ein guenstiges Modell erledigt sie falsch.</para>
|
||||||
|
///
|
||||||
|
/// <para>Also: neueste zuerst (umstellbar ueber <c>sort</c> und <c>order</c>), <c>pattern</c>
|
||||||
|
/// zum Eingrenzen, eine Zeile je Eintrag - und der Kopf sagt, wie viele Eintraege es
|
||||||
|
/// insgesamt gibt. Eine gedeckelte Liste, die ihre Deckelung verschweigt, waere die
|
||||||
|
/// schlimmere Falle.</para>
|
||||||
|
/// </summary>
|
||||||
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||||
@@ -626,21 +664,91 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
|
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
var relativeDirPath = Path.GetRelativePath(rootPath, path);
|
var muster = GetOptionalString(input, "pattern");
|
||||||
var entries = Directory.GetFileSystemEntries(path)
|
if (MusterFehler(muster) is { } musterFehler)
|
||||||
.Select(e => new
|
return ToolResult.Fail(musterFehler);
|
||||||
|
|
||||||
|
List<string> pfade;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Name = Path.GetFileName(e),
|
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster).ToList();
|
||||||
Type = Directory.Exists(e) ? "directory" : "file",
|
}
|
||||||
Size = Directory.Exists(e) ? 0 : new FileInfo(e).Length,
|
catch (ArgumentException ex)
|
||||||
LastModified = File.GetLastWriteTime(e)
|
{
|
||||||
})
|
return ToolResult.Fail($"Ungueltiges Muster '{muster}': {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var eintraege = pfade
|
||||||
|
.Select(e => new Verzeichniseintrag(
|
||||||
|
Path.GetFileName(e),
|
||||||
|
Directory.Exists(e),
|
||||||
|
Directory.Exists(e) ? 0 : new FileInfo(e).Length,
|
||||||
|
File.GetLastWriteTime(e)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var header = $"[{workspace} Workspace] Verzeichnis: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}\n";
|
var nachName = string.Equals(GetOptionalString(input, "sort"), "name", StringComparison.OrdinalIgnoreCase);
|
||||||
return ToolResult.Ok(header + JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true }));
|
var absteigend = GetOptionalString(input, "order") is { } o
|
||||||
|
? string.Equals(o, "desc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
: !nachName; // neueste zuerst, Namen aufsteigend
|
||||||
|
|
||||||
|
IEnumerable<Verzeichniseintrag> sortiert = nachName
|
||||||
|
? (absteigend
|
||||||
|
? eintraege.OrderByDescending(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
: eintraege.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
: (absteigend
|
||||||
|
? eintraege.OrderByDescending(e => e.Geaendert)
|
||||||
|
: eintraege.OrderBy(e => e.Geaendert));
|
||||||
|
|
||||||
|
var grenze = Math.Clamp(GetInt(input, "limit") ?? StandardEintraege, 1, MaxEintraege);
|
||||||
|
var gezeigt = sortiert.Take(grenze).ToList();
|
||||||
|
|
||||||
|
var relativeDirPath = Path.GetRelativePath(rootPath, path);
|
||||||
|
var ordnung = nachName
|
||||||
|
? (absteigend ? "Name absteigend" : "Name aufsteigend")
|
||||||
|
: (absteigend ? "neueste zuerst" : "aelteste zuerst");
|
||||||
|
|
||||||
|
var text = new StringBuilder();
|
||||||
|
text.Append($"[{workspace} Workspace] Verzeichnis: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
|
||||||
|
text.Append($" - {gezeigt.Count} von {eintraege.Count} Eintraegen, {ordnung}.");
|
||||||
|
if (!string.IsNullOrWhiteSpace(muster))
|
||||||
|
text.Append($" Muster: {muster}.");
|
||||||
|
if (eintraege.Count > gezeigt.Count)
|
||||||
|
text.Append($"\n{eintraege.Count - gezeigt.Count} weitere nicht gezeigt - grenze mit 'pattern' ein oder setze 'limit' (max {MaxEintraege}).");
|
||||||
|
text.Append("\nGeaendert Groesse Name\n");
|
||||||
|
|
||||||
|
foreach (var e in gezeigt)
|
||||||
|
{
|
||||||
|
var groesse = e.IstVerzeichnis ? "<DIR>" : e.Groesse.ToString();
|
||||||
|
text.Append($"{e.Geaendert:yyyy-MM-dd HH:mm} {groesse,9} {e.Name}{(e.IstVerzeichnis ? "/" : "")}\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await Task.FromResult(ToolResult.Ok(text.ToString().TrimEnd()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Verzeichniseintrag(string Name, bool IstVerzeichnis, long Groesse, DateTime Geaendert);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ein Muster ist ein Dateiname mit Platzhaltern, kein Pfad. <c>Directory.GetFiles</c>
|
||||||
|
/// wertet Trennzeichen im Muster aus — <c>pattern: "../*"</c> listet damit das
|
||||||
|
/// Elternverzeichnis, an der Pfadpruefung vorbei. Deshalb hier und nicht erst dort.
|
||||||
|
/// </summary>
|
||||||
|
private static string? MusterFehler(string? muster)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(muster)) return null;
|
||||||
|
|
||||||
|
if (muster.Contains('/') || muster.Contains('\\') || muster.Contains(".."))
|
||||||
|
return $"Ungueltiges Muster '{muster}': ein Muster ist ein Dateiname mit Platzhaltern "
|
||||||
|
+ "(z.B. 'berlin_2026-09-21_*.txt'), kein Pfad. Das Verzeichnis gehoert in 'path'.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetOptionalString(JsonElement input, string name)
|
||||||
|
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
private static int? GetInt(JsonElement input, string name)
|
||||||
|
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
|
||||||
|
|
||||||
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
Termine. Eine Aufgabe an einen anderen Agenten legst du mit assignee='@<agentId>'
|
Termine. Eine Aufgabe an einen anderen Agenten legst du mit assignee='@<agentId>'
|
||||||
an — sie läuft, wenn sie fällig ist, ohne dass du wartest.
|
an — sie läuft, wenn sie fällig ist, ohne dass du wartest.
|
||||||
|
|
||||||
Aktionen: task_create, task_list, task_update, task_comment
|
Aktionen: task_create, task_find, task_list, task_update, task_comment
|
||||||
|
|
||||||
|
Bevor du etwas meldest: task_find mit dem geplanten Titel. Gibt es die Aufgabe
|
||||||
|
schon, hänge deine Beobachtung mit task_comment an, statt sie erneut anzulegen —
|
||||||
|
sonst weiß niemand, ob ein Befund neu ist oder seit Tagen unbeantwortet liegt.
|
||||||
|
|
||||||
Assignee:
|
Assignee:
|
||||||
- '@new' → frischer Lauf ohne Vorgeschichte
|
- '@new' → frischer Lauf ohne Vorgeschichte
|
||||||
@@ -48,8 +52,8 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
"properties": {
|
"properties": {
|
||||||
"action": {
|
"action": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["task_create", "task_list", "task_update", "task_comment"],
|
"enum": ["task_create", "task_find", "task_list", "task_update", "task_comment"],
|
||||||
"description": "Die auszuführende Aktion."
|
"description": "Die auszuführende Aktion. 'task_find' sucht offene Aufgaben mit ähnlichem Titel — immer vor task_create."
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -57,7 +61,11 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Titel der Aufgabe. Pflicht bei task_create."
|
"description": "Titel der Aufgabe. Pflicht bei task_create und task_find."
|
||||||
|
},
|
||||||
|
"force": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "task_create: trotz gleichlautender offener Aufgabe wirklich eine zweite anlegen. Nur, wenn es ein anderer Sachverhalt ist."
|
||||||
},
|
},
|
||||||
"body": {
|
"body": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -147,7 +155,8 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
{
|
{
|
||||||
return action switch
|
return action switch
|
||||||
{
|
{
|
||||||
"task_create" => await CreateAsync(input, board, ct),
|
"task_create" => await CreateAsync(input, board, context.AgentId, ct),
|
||||||
|
"task_find" => await FindAsync(input, board, ct),
|
||||||
"task_list" => await ListAsync(input, board, ct),
|
"task_list" => await ListAsync(input, board, ct),
|
||||||
"task_update" => await UpdateAsync(input, board, ct),
|
"task_update" => await UpdateAsync(input, board, ct),
|
||||||
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
|
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
|
||||||
@@ -163,8 +172,50 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
|
|
||||||
// ─── Aktionen ───
|
// ─── Aktionen ───
|
||||||
|
|
||||||
private static async Task<ToolResult> CreateAsync(
|
/// <summary>
|
||||||
|
/// #674: Offene Aufgaben mit ähnlichem Titel — die Frage vor dem Anlegen.
|
||||||
|
///
|
||||||
|
/// <para>Die Freitextsuche in task_list beantwortet sie nicht: sie findet nur, was
|
||||||
|
/// wörtlich übereinstimmt, und liefert eine Liste, die der Agent selbst deuten muss.
|
||||||
|
/// Hier steht die Antwort im ersten Satz.</para>
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<ToolResult> FindAsync(
|
||||||
JsonElement input, TaskboardService board, CancellationToken ct)
|
JsonElement input, TaskboardService board, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var title = GetString(input, "title") ?? GetString(input, "search");
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
return ToolResult.Fail("'title' ist erforderlich — wonach soll gesucht werden?");
|
||||||
|
|
||||||
|
var treffer = await SucheAehnlicheAsync(board, title, null, ct);
|
||||||
|
if (treffer.Count == 0)
|
||||||
|
return ToolResult.Ok($"Keine offene Aufgabe ähnelt \"{title.Trim()}\". Du kannst sie mit task_create anlegen.");
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"{treffer.Count} offene Aufgabe(n) ähneln \"{title.Trim()}\":");
|
||||||
|
sb.AppendLine();
|
||||||
|
foreach (var (task, match) in treffer.Take(10))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
|
||||||
|
sb.AppendLine($" {(match == TaskTitleMatch.Duplicate ? "derselbe Sachverhalt" : "verwandt")}" +
|
||||||
|
$" | Assignee: {task.Assignee} | Priorität: {task.Priority}" +
|
||||||
|
$" | offen seit {task.CreatedAt:dd.MM. HH:mm}");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append("Hänge deine Beobachtung mit task_comment an die bestehende Aufgabe an, statt eine zweite anzulegen.");
|
||||||
|
return ToolResult.Ok(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Offene Aufgaben mit verwandtem Titel, engste zuerst.</summary>
|
||||||
|
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
|
||||||
|
TaskboardService board, string title, string? exceptId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var alle = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
|
||||||
|
return TaskSimilarity.FindOpen(alle, title, exceptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ToolResult> CreateAsync(
|
||||||
|
JsonElement input, TaskboardService board, string author, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var title = GetString(input, "title");
|
var title = GetString(input, "title");
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
@@ -173,6 +224,27 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
if (!TryReadWhen(input, out var when, out var whenError))
|
if (!TryReadWhen(input, out var when, out var whenError))
|
||||||
return ToolResult.Fail(whenError!);
|
return ToolResult.Fail(whenError!);
|
||||||
|
|
||||||
|
// #674: Derselbe Befund zum zweiten Mal ist keine zweite Aufgabe. Er wird an der
|
||||||
|
// bestehenden vermerkt — damit man später sieht, wie oft und wie lange schon.
|
||||||
|
if (GetBool(input, "force") != true)
|
||||||
|
{
|
||||||
|
var dublette = (await SucheAehnlicheAsync(board, title, null, ct))
|
||||||
|
.FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task;
|
||||||
|
|
||||||
|
if (dublette is not null)
|
||||||
|
{
|
||||||
|
await board.AddCommentAsync(
|
||||||
|
dublette.Id, author,
|
||||||
|
$"Erneut beobachtet — {author} wollte dieselbe Aufgabe anlegen: \"{title.Trim()}\"",
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return ToolResult.Fail(
|
||||||
|
$"Nicht angelegt: {dublette.Id} \"{dublette.Title}\" ist seit {dublette.CreatedAt:dd.MM. HH:mm} " +
|
||||||
|
$"offen und meint dasselbe. Dein erneutes Auftreten wurde dort vermerkt.\n" +
|
||||||
|
$"Ergänze sie mit task_comment, oder lege bei wirklich anderem Sachverhalt mit force=true an.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var definition = new TaskItem
|
var definition = new TaskItem
|
||||||
{
|
{
|
||||||
Title = title.Trim(),
|
Title = title.Trim(),
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using ClawdDotNet.Core.Tasks;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Title comparison (#674). The cases are the real titles the test operation produced:
|
||||||
|
/// over thirty open tasks for @human, six of them the same exchange-rate complaint.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TaskSimilarityTests
|
||||||
|
{
|
||||||
|
// ─── the duplicates that actually piled up ───
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Betriebsluecke: Eur Kurse nicht aktuell", "Betriebsluecke: Kurse EUR nicht aktuell")]
|
||||||
|
[InlineData("Betriebsluecke: Eur Kurse", "Betriebsluecke: Eur Kurse nicht aktuell")]
|
||||||
|
[InlineData("Betriebsluecke: Nachrichten", "Betriebsluecke: Nachrichten heute fehlend")]
|
||||||
|
[InlineData("Betriebsluecke: Wetter Stunden", "Betriebsluecke: Wetter Stunden (17:00, 18:00 fehlen)")]
|
||||||
|
[InlineData("Betriebslücke: Währungskurse eur.csv", "Betriebsluecke: Waehrungskurse eur.csv")]
|
||||||
|
[InlineData("Morgenbericht", "morgenbericht")]
|
||||||
|
public void The_same_finding_told_twice_is_a_duplicate(string left, string right)
|
||||||
|
{
|
||||||
|
TaskSimilarity.Compare(left, right).ShouldBe(TaskTitleMatch.Duplicate);
|
||||||
|
TaskSimilarity.Compare(right, left).ShouldBe(TaskTitleMatch.Duplicate, "the comparison is symmetric");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── and the ones that must stay apart ───
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Betriebsluecke: Wetter Stunden", "Betriebsluecke: Wetter Warnungen stand.md veraltet")]
|
||||||
|
[InlineData("Betriebsluecke: Nachrichten fehlen", "Betriebsluecke: Eur Kurse nicht aktuell")]
|
||||||
|
[InlineData("Wetter", "Wetter Warnungen stand.md veraltet")]
|
||||||
|
[InlineData("Morgenbericht", "Tagesabschluss Wetter")]
|
||||||
|
[InlineData("DB-Pruefung: Wetterwerte uebernehmen", "Web-Pruefung: Grenzen der Whitelist")]
|
||||||
|
public void Different_subjects_are_not_duplicates(string left, string right)
|
||||||
|
=> TaskSimilarity.Compare(left, right).ShouldNotBe(TaskTitleMatch.Duplicate);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_single_shared_word_does_not_swallow_a_whole_topic()
|
||||||
|
{
|
||||||
|
// "Wetter" is one word of many weather tasks - containment must not apply here.
|
||||||
|
TaskSimilarity.Compare("Wetter", "Wetter Stunden fehlen seit gestern").ShouldBe(TaskTitleMatch.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Related_but_distinguishable_titles_come_back_as_similar()
|
||||||
|
=> TaskSimilarity.Compare(
|
||||||
|
"Betriebsluecke: Wetter Stunden fehlen",
|
||||||
|
"Betriebsluecke: Wetter Stunden veraltet").ShouldBe(TaskTitleMatch.Similar);
|
||||||
|
|
||||||
|
// ─── normalisation ───
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Betriebslücke: Wetter", "betriebsluecke wetter")]
|
||||||
|
[InlineData(" Doppelte Leerzeichen ", "doppelte leerzeichen")]
|
||||||
|
[InlineData("Größe/Maß (17:00)", "groesse mass 17 00")]
|
||||||
|
[InlineData("Café", "cafe")]
|
||||||
|
[InlineData(null, "")]
|
||||||
|
public void Normalisation_folds_case_umlauts_and_punctuation(string? input, string expected)
|
||||||
|
=> TaskSimilarity.Normalize(input).ShouldBe(expected);
|
||||||
|
|
||||||
|
// ─── only open tasks can be duplicated ───
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_closed_task_does_not_block_a_new_one()
|
||||||
|
{
|
||||||
|
var tasks = new[]
|
||||||
|
{
|
||||||
|
new TaskItem { Id = "t-alt", Title = "Betriebsluecke: Eur Kurse", Status = TaskItemStatus.Done },
|
||||||
|
new TaskItem { Id = "t-weg", Title = "Betriebsluecke: Eur Kurse", Status = TaskItemStatus.Canceled },
|
||||||
|
new TaskItem { Id = "t-offen", Title = "Betriebsluecke: Eur Kurse nicht aktuell", Status = TaskItemStatus.Todo }
|
||||||
|
};
|
||||||
|
|
||||||
|
var hits = TaskSimilarity.FindOpen(tasks, "Betriebsluecke: Kurse EUR nicht aktuell");
|
||||||
|
|
||||||
|
hits.Count.ShouldBe(1, "erledigte Aufgaben sind erledigt - derselbe Befund darf wiederkommen");
|
||||||
|
hits[0].Task.Id.ShouldBe("t-offen");
|
||||||
|
hits[0].Match.ShouldBe(TaskTitleMatch.Duplicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_closest_match_comes_first_and_the_task_itself_is_skipped()
|
||||||
|
{
|
||||||
|
var tasks = new[]
|
||||||
|
{
|
||||||
|
new TaskItem { Id = "t-verwandt", Title = "Betriebsluecke: Wetter Stunden veraltet", Status = TaskItemStatus.Todo },
|
||||||
|
new TaskItem { Id = "t-gleich", Title = "Betriebsluecke: Wetter Stunden fehlen", Status = TaskItemStatus.Todo },
|
||||||
|
new TaskItem { Id = "t-selbst", Title = "Betriebsluecke: Wetter Stunden fehlen", Status = TaskItemStatus.Todo }
|
||||||
|
};
|
||||||
|
|
||||||
|
var hits = TaskSimilarity.FindOpen(tasks, "Betriebsluecke: Wetter Stunden fehlen", exceptId: "t-selbst");
|
||||||
|
|
||||||
|
hits.Select(h => h.Task.Id).ShouldBe(["t-gleich", "t-verwandt"]);
|
||||||
|
hits[0].Match.ShouldBe(TaskTitleMatch.Duplicate);
|
||||||
|
hits[1].Match.ShouldBe(TaskTitleMatch.Similar);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
using ClawdDotNet.Tools.FileRW;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sortiertes, gedeckeltes Auflisten (#673).
|
||||||
|
///
|
||||||
|
/// Anlass aus dem Testbetrieb: Die Betriebskontrolle meldete zwei Stundendateien als
|
||||||
|
/// fehlend, die es gab. Das Verzeichnis lieferte 105 Eintraege in der Reihenfolge des
|
||||||
|
/// Dateisystems — darin zwei Namen zu finden ist Sucharbeit, und ein guenstiges Modell
|
||||||
|
/// erledigt sie falsch.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VerzeichnisListeTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _basis;
|
||||||
|
private readonly string _personal;
|
||||||
|
private readonly string _shared;
|
||||||
|
private readonly FileRWTool _tool = new();
|
||||||
|
|
||||||
|
public VerzeichnisListeTests()
|
||||||
|
{
|
||||||
|
_basis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
_personal = Path.Combine(_basis, "Workspace");
|
||||||
|
_shared = Path.Combine(_basis, "SharedWorkspace");
|
||||||
|
Directory.CreateDirectory(_personal);
|
||||||
|
Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_basis, recursive: true); }
|
||||||
|
catch { /* Aufräumen ist Nebensache */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolContext Context() => new(
|
||||||
|
"hausmeister", "inst",
|
||||||
|
new Dictionary<string, object?> { ["sharedAccessLevel"] = "ReadWrite" },
|
||||||
|
new NullStateStore(),
|
||||||
|
NullLogger.Instance,
|
||||||
|
CancellationToken.None,
|
||||||
|
WorkspacePath: _personal,
|
||||||
|
SharedWorkspacePath: _shared);
|
||||||
|
|
||||||
|
private Task<ToolResult> Run(string json)
|
||||||
|
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
||||||
|
|
||||||
|
/// <summary>Stundendateien, absichtlich in gemischter Reihenfolge geschrieben.</summary>
|
||||||
|
private void Stundendateien(int anzahl, string tag = "2026-09-21")
|
||||||
|
{
|
||||||
|
var verzeichnis = Path.Combine(_shared, "wetter", "stunden");
|
||||||
|
foreach (var stunde in Enumerable.Range(0, anzahl).OrderBy(_ => Guid.NewGuid()))
|
||||||
|
{
|
||||||
|
var pfad = Path.Combine(verzeichnis, $"berlin_{tag}_{stunde:00}-00.txt");
|
||||||
|
File.WriteAllText(pfad, $"{tag} {stunde:00}:00 Temperatur {10 + stunde} C");
|
||||||
|
File.SetLastWriteTime(pfad, DateTime.Parse($"{tag}T{stunde:00}:05:00"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> Namen(string inhalt) => inhalt
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(z => z.Trim())
|
||||||
|
.Where(z => z.StartsWith("20", StringComparison.Ordinal))
|
||||||
|
.Select(z => z.Split(" ", StringSplitOptions.RemoveEmptyEntries).Last())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ═══════════════════ Reihenfolge ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Die_juengste_Datei_steht_oben()
|
||||||
|
{
|
||||||
|
Stundendateien(6);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||||
|
result.Content.ShouldContain("neueste zuerst");
|
||||||
|
Namen(result.Content).First().ShouldBe("berlin_2026-09-21_05-00.txt");
|
||||||
|
Namen(result.Content).Last().ShouldBe("berlin_2026-09-21_00-00.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Nach_Namen_sortiert_laeuft_aufsteigend()
|
||||||
|
{
|
||||||
|
Stundendateien(4);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"name"}""");
|
||||||
|
|
||||||
|
Namen(result.Content).ShouldBe([
|
||||||
|
"berlin_2026-09-21_00-00.txt", "berlin_2026-09-21_01-00.txt",
|
||||||
|
"berlin_2026-09-21_02-00.txt", "berlin_2026-09-21_03-00.txt"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Die_Richtung_laesst_sich_umkehren()
|
||||||
|
{
|
||||||
|
Stundendateien(4);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","sort":"modified","order":"asc"}""");
|
||||||
|
|
||||||
|
result.Content.ShouldContain("aelteste zuerst");
|
||||||
|
Namen(result.Content).First().ShouldBe("berlin_2026-09-21_00-00.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ Deckel und Gesamtzahl ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Der_Kopf_nennt_immer_die_Gesamtzahl()
|
||||||
|
{
|
||||||
|
Stundendateien(24);
|
||||||
|
Stundendateien(24, "2026-09-20");
|
||||||
|
Stundendateien(24, "2026-09-19");
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Content.ShouldContain($"{FileRWTool.StandardEintraege} von 72 Eintraegen");
|
||||||
|
result.Content.ShouldContain("22 weitere nicht gezeigt");
|
||||||
|
result.Content.ShouldContain("'pattern'", Case.Sensitive, "der Agent soll wissen, wie er gezielt fragt");
|
||||||
|
Namen(result.Content).Count.ShouldBe(FileRWTool.StandardEintraege);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ein_eigenes_Limit_wird_beachtet_und_gedeckelt()
|
||||||
|
{
|
||||||
|
Stundendateien(10);
|
||||||
|
|
||||||
|
var wenig = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":3}""");
|
||||||
|
Namen(wenig.Content).Count.ShouldBe(3);
|
||||||
|
wenig.Content.ShouldContain("3 von 10 Eintraegen");
|
||||||
|
|
||||||
|
var zuviel = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","limit":99999}""");
|
||||||
|
Namen(zuviel.Content).Count.ShouldBe(10, "mehr als da ist gibt es nicht");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ Gezielt fragen ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ein_Muster_beantwortet_die_Frage_nach_einer_bestimmten_Datei()
|
||||||
|
{
|
||||||
|
Stundendateien(24);
|
||||||
|
Stundendateien(24, "2026-09-20");
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden","pattern":"berlin_2026-09-21_1?-00.txt"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||||
|
result.Content.ShouldContain("10 von 10 Eintraegen");
|
||||||
|
result.Content.ShouldContain("Muster:");
|
||||||
|
Namen(result.Content).ShouldAllBe(n => n.StartsWith("berlin_2026-09-21_1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ein_leeres_Verzeichnis_ist_kein_Fehler_sondern_eine_Null()
|
||||||
|
{
|
||||||
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||||
|
result.Content.ShouldContain("0 von 0 Eintraegen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ Form ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Jeder_Eintrag_steht_auf_genau_einer_Zeile()
|
||||||
|
{
|
||||||
|
Stundendateien(3);
|
||||||
|
Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden", "alt"));
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"list","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Content.ShouldNotContain("\"Name\":", Case.Sensitive, "kein eingeruecktes JSON mehr");
|
||||||
|
result.Content.ShouldContain("alt/", Case.Sensitive);
|
||||||
|
result.Content.ShouldContain("<DIR>", Case.Sensitive);
|
||||||
|
Namen(result.Content).Count.ShouldBe(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ Das Muster ist kein zweiter Pfad ═══════════════════
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("list")]
|
||||||
|
[InlineData("read_many")]
|
||||||
|
public async Task Ein_Muster_darf_nicht_aus_dem_Verzeichnis_herausfuehren(string action)
|
||||||
|
{
|
||||||
|
// Directory.GetFiles wertet Trennzeichen im Muster aus: "../*" listet das
|
||||||
|
// Elternverzeichnis, ohne dass die Pfadpruefung je gefragt wurde.
|
||||||
|
Stundendateien(2);
|
||||||
|
File.WriteAllText(Path.Combine(_shared, "wetter", "geheim.txt"), "nicht fuer den Agenten");
|
||||||
|
|
||||||
|
var result = await Run($$"""{"action":"{{action}}","workspace":"shared","path":"wetter/stunden","pattern":"../*"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse("ein Muster ist ein Dateiname, kein Pfad");
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Muster");
|
||||||
|
(result.Content ?? "").ShouldNotContain("geheim");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("unter/*.txt")]
|
||||||
|
[InlineData("..\\*")]
|
||||||
|
[InlineData("*/../*")]
|
||||||
|
public async Task Auch_die_anderen_Pfadschreibweisen_werden_abgewiesen(string muster)
|
||||||
|
{
|
||||||
|
Stundendateien(2);
|
||||||
|
|
||||||
|
var input = JsonSerializer.SerializeToElement(
|
||||||
|
new { action = "list", workspace = "shared", path = "wetter/stunden", pattern = muster });
|
||||||
|
var result = await _tool.ExecuteAsync(input, Context(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("kein Pfad");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user