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

+114
View File
@@ -0,0 +1,114 @@
package queue
import (
"strings"
"testing"
"time"
)
// TestTruncateText: Kürzung auf max Runen an einer Wortgrenze (nicht in der
// ersten Hälfte), ohne hängende Satzzeichen; kurze Texte und max 0 bleiben
// unangetastet; Runen zählen, nicht Bytes.
func TestTruncateText(t *testing.T) {
cases := []struct {
name string
in string
max int
want string
truncated bool
}{
{"kurz bleibt", "Build fertig", 400, "Build fertig", false},
{"genau max bleibt", "abcde", 5, "abcde", false},
{"unbegrenzt", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"wortgrenze", "Der Build ist fertig und bereit", 14, "Der Build ist", true},
{"satzzeichen weg", "Fertig, aber dann kam noch mehr", 8, "Fertig", true},
{"keine wortgrenze in zweiter hälfte → hart", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlaute als eine rune", "Übergröße ärgert", 9, "Übergröße", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, tr := truncateText(c.in, c.max)
if got != c.want || tr != c.truncated {
t.Errorf("truncateText(%q, %d) = %q/%v, want %q/%v", c.in, c.max, got, tr, c.want, c.truncated)
}
if c.max > 0 && len([]rune(got)) > c.max {
t.Errorf("Ergebnis %q länger als %d Runen", got, c.max)
}
})
}
}
// TestEnqueueKuerztText: Das Gesetz MaxText greift beim Eintritt — die
// gekürzte Fassung wird gesprochen, Admission meldet Truncated.
func TestEnqueueKuerztText(t *testing.T) {
g := neueSpielerin()
q := New(Laws{MaxText: 20}, g.speak, nil)
defer q.Close()
adm, err := q.Enqueue(Item{Text: "Dieser Text ist deutlich länger als zwanzig Zeichen"})
if err != nil || adm.ID == "" || !adm.Truncated {
t.Fatalf("Enqueue = %+v, %v; want aufgenommen + Truncated", adm, err)
}
items := g.warteBis(t, 1)
if items[0].Text != "Dieser Text ist" {
t.Errorf("gesprochen = %q, want gekürzte Fassung", items[0].Text)
}
g.step <- struct{}{}
adm, _ = q.Enqueue(Item{Text: "kurz"})
if adm.Truncated {
t.Error("kurzer Text als gekürzt gemeldet")
}
g.warteBis(t, 2)
g.step <- struct{}{}
}
// TestDecideExpired: Ein Item, das länger als MaxAge gewartet hat, wird
// verworfen — vor Ruhezeit und Rate-Limit, aber nach now (now bricht aus).
func TestDecideExpired(t *testing.T) {
laws := Laws{MaxAge: time.Minute, RateLimit: time.Hour}
now := time.Now()
alt := Item{Text: "alt", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
frisch := Item{Text: "frisch", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
if v := laws.decide(alt, now, true); v != verdictDiscardExpired {
t.Errorf("altes Item bei freiem Slot = %v, want verdictDiscardExpired", v)
}
if v := laws.decide(frisch, now, true); v != verdictPlay {
t.Errorf("frisches Item bei freiem Slot = %v, want verdictPlay", v)
}
if v := laws.decide(frisch, now, false); v != verdictWait {
t.Errorf("frisches Item ohne Slot = %v, want verdictWait", v)
}
altNow := alt
altNow.Now = true
if v := laws.decide(altNow, now, false); v != verdictPlay {
t.Errorf("altes now-Item = %v, want verdictPlay (now bricht aus)", v)
}
if v := (Laws{}).decide(alt, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unbegrenzt) verwirft altes Item: %v", v)
}
}
// TestGeparktesItemVerfaellt: Ein normal-Item parkt auf den Rate-Limit-Slot;
// ist es beim Fälligwerden älter als MaxAge, wird es verworfen (Protokoll
// nennt max_age) und nicht mehr gesprochen.
func TestGeparktesItemVerfaellt(t *testing.T) {
logb := &syncLog{}
g := neueSpielerin()
q := New(Laws{RateLimit: 300 * time.Millisecond, MaxAge: 150 * time.Millisecond}, g.speak, logb)
defer q.Close()
q.Enqueue(Item{Text: "erste"}) // bucht das Fenster
g.warteBis(t, 1)
g.step <- struct{}{}
adm, _ := q.Enqueue(Item{Text: "parkt zu lange"}) // parkt ~300 ms > MaxAge
if adm.ID == "" || adm.Discarded != "" {
t.Fatalf("beim Eintritt darf nichts verfallen: %+v", adm)
}
waitFor(t, func() bool { return strings.Contains(logb.String(), "max_age") })
g.keineWeitere(t)
if !strings.Contains(logb.String(), adm.ID) {
t.Errorf("Protokoll nennt die verfallene Ansage nicht:\n%s", logb.String())
}
}