feat(queue): Gesetze max_text und max_age — gekürzte Langtexte, verfallene Stau-Ansagen

Zwei Löcher im Anti-Spam-Gesetz, die ein Prompt trivial nutzt: Ein Agent
kann 64 KB Text einreichen (ElevenLabs stellt die Rechnung, die Ansage
dauert Minuten), und geparkte normal-Ansagen spielen unbegrenzt spät —
„Build fertig" zweieinhalb Minuten nach dem Build ist keine Nachricht.

- queue.max_text (Default 400 Zeichen, Env TALK_MAX_TEXT): längere Texte
  werden beim Eintritt an einer Wortgrenze gekürzt und so gesprochen; die
  /speak-Antwort meldet truncated, die CLI warnt auf stderr, das MCP-
  Ergebnis sagt es im Text. Runen, nicht Bytes.
- queue.max_age (Default 2m, Env TALK_MAX_AGE): Items, die beim
  Fälligwerden länger gewartet haben, werden verworfen (discarded
  "expired", Protokoll nennt Wartezeit und Grenze). now:true ist
  ausgenommen. Rang: now > veraltet > Ruhezeit > Rate-Limit.
- /status trägt "limits": {max_text, max_age}; `talk status` zeigt sie.
- Negative Werte sind Config-Fehler; 0 = unbegrenzt.

Tests: truncateText-Tabelle (Wortgrenze, Satzzeichen, Runen), Enqueue-
Kürzung, decide-Vorrang inkl. now-Ausnahme, geparktes Item verfällt;
Config-Schichten; Prozessgrenze: Provider bekommt die gekürzte Fassung,
dritte von drei Stau-Ansagen wird nie gesprochen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 09:54:40 +02:00
1 parent 79314dfb0f
commit 97650ad0ec
13 files changed
+430 -26

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
package test
// Prozessgrenzen-Tests für die Längen- und Alters-Gesetze: queue.max_text
// kürzt überlange Texte beim Eintritt (Antwort truncated, CLI-Hinweis, der
// Provider sieht die gekürzte Fassung); queue.max_age verwirft Ansagen, die
// zu lange geparkt haben (expired, nie gesprochen).
import (
"strings"
"testing"
"time"
)
// TestMaxTextKuerzt: Ein 60-Wörter-Text gegen max_text=40 — /speak meldet
// truncated, die CLI warnt auf stderr, der Fake-Provider bekommt höchstens
// 40 Zeichen, und /status zeigt das Limit.
func TestMaxTextKuerzt(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_MAX_TEXT": "40"})
lang := strings.Repeat("viel zu lange Antwort ", 12)
code, m := speak(t, d, `{"text":"`+lang+`"}`)
if code != 202 || m["id"] == "" || m["truncated"] != true {
t.Fatalf("speak: %d %v, want 202 mit id und truncated=true", code, m)
}
hits := fake.waitCompleted(t, 1)
if n := len([]rune(hits[0].Text)); n > 40 || !strings.HasPrefix(hits[0].Text, "viel zu lange Antwort") {
t.Errorf("Provider bekam %q (%d Zeichen), want ≤ 40 Zeichen vom Anfang", hits[0].Text, n)
}
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "say", lang)
if res.code != 0 || !strings.Contains(res.stderr, "gekürzt") {
t.Errorf("talk say (lang): Exit %d, stderr %q — will Kürzungs-Hinweis", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
res = runTalk(t, env, "say", "kurz genug")
if strings.Contains(res.stderr, "gekürzt") {
t.Errorf("kurzer Text mit Kürzungs-Hinweis: %q", res.stderr)
}
fake.waitCompleted(t, 3)
res = runTalk(t, env, "status")
if !strings.Contains(res.stdout, "limits: max. 40 Zeichen") {
t.Errorf("talk status ohne Limits-Zeile: %q", res.stdout)
}
}
// TestMaxAgeVerwirftGeparkte: Rate-Limit 1 s, max_age 1,5 s, drei normal-
// Ansagen direkt hintereinander: die erste spielt sofort, die zweite parkt
// 1 s (frisch genug), die dritte wäre erst nach 2 s fällig — veraltet,
// verworfen, nie beim Provider.
func TestMaxAgeVerwirftGeparkte(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_RATE_LIMIT": "1s",
"TALK_MAX_AGE": "1500ms",
})
for _, text := range []string{"eins", "zwei", "drei"} {
code, m := speak(t, d, `{"text":"`+text+`"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak %s: %d %v — beim Eintritt darf nichts verfallen", text, code, m)
}
}
hits := fake.waitCompleted(t, 2)
if hits[0].Text != "eins" || hits[1].Text != "zwei" {
t.Errorf("gesprochen = %q, %q; want eins, zwei", hits[0].Text, hits[1].Text)
}
fake.assertNoFurtherArrivals(t, 1500*time.Millisecond)
waitStatusIdle(t, d, 3*time.Second)
if !strings.Contains(d.stderr.String(), "max_age") {
t.Errorf("Daemon-Protokoll nennt den Verfall nicht:\n%s", d.stderr.String())
}
}