Files
paliad/internal/services/paliadin_test.go
m ae1cba4e24 feat(paliadin/primer): t-paliad-161 Slice G — tmux crash-recovery primer
When a user's tmux session dies (mRiver reboot, OOM, manual kill,
container restart) the next turn used to wake claude with NO prior
context — the persona had to derive everything from the new turn
alone. Now: when the Go side detects a fresh pane, it pulls the last
N exchanges from paliad.paliadin_turns and prepends them as a
[primer …][/primer] block to the next user envelope.

Format SKILL.md parses (single-line, control-chars stripped):

  [PALIADIN:<turn_id>] [primer last=N] U: … \n A: … \n … [/primer] [ctx …] <Frage>

Detection paths:

- Local (LocalPaliadinService): ensurePane now returns
  (target, isFresh, err). isFresh is true when no prior
  @paliadin-scope=chat window existed and we created one. RunTurn
  passes that into buildPrimerIfFresh.

- Remote (RemotePaliadinService): can't see across the SSH boundary
  to know the pane's true freshness, so we approximate with a
  per-(session, Go-process) "primed" cache. First turn after
  process-start, ResetSession, or healthGate failure rebuilds the
  primer; subsequent turns skip it. ResetSession + healthGate failure
  both call clearPrimed(session) explicitly.

paliadinDB.buildPrimerIfFresh assembles the block:

- Reads the last MaxPrimerTurns=5 exchanges from
  ListHistoryForSession (Slice F).
- truncateForPrimer normalises each side (drops \r\n, collapses
  whitespace, caps at MaxPrimerCharsPerSide=600 with …).
- Returns "" silently when isFresh=false, no SessionID, no prior
  history, or DB error — the user's actual question still lands; we
  only lose the recap.

SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill) gets a new "Crash-recovery primer"
section above the context-envelope block. Five behaviour rules:

  1. Don't re-execute prior tool calls (audit log already has them).
  2. Use the primer for thread continuity, not as a data source.
     Re-call tools for fresh facts.
  3. Truncated lines (ending in …) are partial — paraphrase rather
     than quote.
  4. No primer at all = normal case (existing pane, history is in
     tmux memory). Behave as before.
  5. Acknowledge sparingly — usually just answer the actual question
     with the recap as silent context.

New test TestTruncateForPrimer pins the per-side truncation contract
(no \r\n leaks, repeated spaces collapsed, ellipsis on oversized
input, short input untouched). go test green.

Refs: docs/design-paliadin-inline-2026-05-08.md §6
      (deferred Anthropic API cutover prereq).
2026-05-08 21:48:08 +02:00

288 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"strings"
"testing"
)
// TestTruncateForPrimer pins the per-side truncation contract used by
// buildPrimerIfFresh — the primer block must stay single-line so the
// tmux send-keys -l command doesn't fragment it, and runaway prior
// answers must collapse to a manageable size. t-paliad-161 follow-up.
func TestTruncateForPrimer(t *testing.T) {
t.Run("collapses newlines + tabs to spaces", func(t *testing.T) {
got := truncateForPrimer("hello\nworld\ttab")
if got != "hello world\ttab" && got != "hello world tab" {
// truncateForPrimer normalises \r and \n but leaves tabs;
// either result above is acceptable as long as no \n leaks.
t.Errorf("got %q", got)
}
if strings.ContainsAny(got, "\r\n") {
t.Errorf("control chars leaked: %q", got)
}
})
t.Run("collapses repeated spaces", func(t *testing.T) {
got := truncateForPrimer("a b c")
if got != "a b c" {
t.Errorf("got %q; want %q", got, "a b c")
}
})
t.Run("truncates oversized input with ellipsis", func(t *testing.T) {
long := strings.Repeat("x", MaxPrimerCharsPerSide+50)
got := truncateForPrimer(long)
if !strings.HasSuffix(got, "…") {
t.Errorf("missing ellipsis: %q", got[len(got)-10:])
}
// The 'x' count should be exactly MaxPrimerCharsPerSide
// (ellipsis adds bytes but no x).
if strings.Count(got, "x") != MaxPrimerCharsPerSide {
t.Errorf("got %d x's; want %d", strings.Count(got, "x"), MaxPrimerCharsPerSide)
}
})
t.Run("leaves short input untouched", func(t *testing.T) {
got := truncateForPrimer("Was steht heute an?")
if got != "Was steht heute an?" {
t.Errorf("short input mangled: %q", got)
}
})
}
// TestTurnContext_EnvelopePrefix pins the bracket-block format the
// SKILL.md parser branches on. Wrong format = the inline widget's
// page-context never reaches Paliadin. t-paliad-161.
func TestTurnContext_EnvelopePrefix(t *testing.T) {
t.Run("nil context produces empty prefix", func(t *testing.T) {
var c *TurnContext
if got := c.EnvelopePrefix(); got != "" {
t.Errorf("nil ctx prefix = %q; want \"\"", got)
}
})
t.Run("empty context produces empty prefix", func(t *testing.T) {
got := (&TurnContext{}).EnvelopePrefix()
if got != "" {
t.Errorf("empty ctx prefix = %q; want \"\"", got)
}
})
t.Run("route only", func(t *testing.T) {
got := (&TurnContext{RouteName: "dashboard"}).EnvelopePrefix()
if got != "[ctx route=dashboard] " {
t.Errorf("got %q", got)
}
})
t.Run("route + entity", func(t *testing.T) {
got := (&TurnContext{
RouteName: "projects.detail",
PrimaryEntityType: "project",
PrimaryEntityID: "61e3eb9e-4a8b-7c1f-9d0e-2f5a0c8e1b3d",
}).EnvelopePrefix()
want := "[ctx route=projects.detail entity=project:61e3eb9e-4a8b-7c1f-9d0e-2f5a0c8e1b3d] "
if got != want {
t.Errorf("got %q; want %q", got, want)
}
})
t.Run("selection with spaces is quoted", func(t *testing.T) {
got := (&TurnContext{
RouteName: "agenda",
UserSelectionText: "Klageerwiderung Acme v. Müller",
}).EnvelopePrefix()
if !strings.Contains(got, `selection="Klageerwiderung Acme v. Müller"`) {
t.Errorf("missing quoted selection: %q", got)
}
})
t.Run("selection truncated at MaxSelectionChars", func(t *testing.T) {
// 'q' doesn't appear anywhere in the prefix structure (`[ctx
// route=events selection="…"]`), so q-count isolates exactly the
// selection's contribution.
long := strings.Repeat("q", MaxSelectionChars+50)
got := (&TurnContext{
RouteName: "events",
UserSelectionText: long,
}).EnvelopePrefix()
if !strings.Contains(got, "…\"") {
t.Errorf("expected truncation marker (…\"); got %q", got[:80])
}
if strings.Count(got, "q") != MaxSelectionChars {
t.Errorf("expected exactly %d 'q's; got %d", MaxSelectionChars, strings.Count(got, "q"))
}
})
t.Run("quotes inside selection are escaped", func(t *testing.T) {
got := (&TurnContext{
RouteName: "agenda",
UserSelectionText: `she said "hi" then`,
}).EnvelopePrefix()
if !strings.Contains(got, `\"hi\"`) {
t.Errorf("quotes not escaped: %q", got)
}
})
t.Run("view + filter combine cleanly", func(t *testing.T) {
got := (&TurnContext{
RouteName: "events",
ViewMode: "calendar",
FilterSummary: "status=overdue",
}).EnvelopePrefix()
want := "[ctx route=events view=calendar filter=status=overdue] "
if got != want {
t.Errorf("got %q; want %q", got, want)
}
})
}
// Tests for the PoC paliadin trailer parser. The parser is load-bearing:
// it's how the dashboard learns which tools Claude used, how many rows
// each returned, and how Claude classified the question. Wrong parsing
// = silently broken monitoring.
func TestSplitTrailer_HappyPath(t *testing.T) {
body := strings.TrimSpace(`
Diese Woche stehen 3 Fristen an:
- 16.05. Klageerwiderung [#deadline-OPEN:c47bd2-1]
- 17.05. Replik [#deadline-OPEN:e92a01-3]
- 20.05. Wiedereinsetzung [#deadline-OPEN:f31b09-7]
---
[paliadin-meta]
used_tools: search_my_deadlines, lookup_court
rows_seen: 3, 1
classifier_tag: data
[/paliadin-meta]
`)
clean, meta := splitTrailer(body)
if strings.Contains(clean, "[paliadin-meta]") {
t.Fatalf("trailer not stripped from body:\n%s", clean)
}
if !strings.HasPrefix(clean, "Diese Woche") {
t.Errorf("body lost prefix: %q", clean[:50])
}
wantTools := []string{"search_my_deadlines", "lookup_court"}
if len(meta.UsedTools) != len(wantTools) {
t.Fatalf("UsedTools len = %d; want %d", len(meta.UsedTools), len(wantTools))
}
for i, want := range wantTools {
if meta.UsedTools[i] != want {
t.Errorf("UsedTools[%d] = %q; want %q", i, meta.UsedTools[i], want)
}
}
wantRows := []int{3, 1}
if len(meta.RowsSeen) != len(wantRows) {
t.Fatalf("RowsSeen len = %d; want %d", len(meta.RowsSeen), len(wantRows))
}
for i, want := range wantRows {
if meta.RowsSeen[i] != want {
t.Errorf("RowsSeen[%d] = %d; want %d", i, meta.RowsSeen[i], want)
}
}
if meta.ClassifierTag != "data" {
t.Errorf("ClassifierTag = %q; want %q", meta.ClassifierTag, "data")
}
}
func TestSplitTrailer_NoTrailer(t *testing.T) {
body := "Just a response, no trailer."
clean, meta := splitTrailer(body)
if clean != body {
t.Errorf("body changed: %q vs %q", clean, body)
}
if len(meta.UsedTools) != 0 || len(meta.RowsSeen) != 0 || meta.ClassifierTag != "" {
t.Errorf("meta should be zero: %+v", meta)
}
}
func TestSplitTrailer_EmptyToolsList(t *testing.T) {
body := strings.TrimSpace(`
Klageerwiderung ist die Erwiderung auf die Klage.
---
[paliadin-meta]
used_tools:
rows_seen:
classifier_tag: concept
[/paliadin-meta]
`)
clean, meta := splitTrailer(body)
if strings.Contains(clean, "[paliadin-meta]") {
t.Errorf("trailer not stripped")
}
if len(meta.UsedTools) != 0 {
t.Errorf("UsedTools should be empty: %v", meta.UsedTools)
}
if meta.ClassifierTag != "concept" {
t.Errorf("ClassifierTag = %q; want concept", meta.ClassifierTag)
}
}
func TestCountChips(t *testing.T) {
cases := []struct {
body string
want int
}{
{"plain text", 0},
{"see [#deadline-OPEN:abc-123]", 1},
{"two [#deadline-OPEN:abc] and [#projekt-OPEN:slug]", 2},
{"chip nav [chip:nav:/projects/123]", 1},
{"chip filter [chip:filter:status=pending]", 1},
{"mixed [#frist-OPEN:x] and [chip:nav:/y]", 2},
// Hallucinated / malformed markers don't count.
{"[#deadline-OPEN:]", 0},
{"[#deadline-OPEN]", 0},
{"[chip:invalid]", 0},
}
for _, c := range cases {
got := countChips(c.body)
if got != c.want {
t.Errorf("countChips(%q) = %d; want %d", c.body, got, c.want)
}
}
}
func TestApproxTokenCount(t *testing.T) {
cases := []struct {
s string
want int
}{
{"", 0},
{"hello", 1}, // 1 word × 1.3 → 1
{"hello world", 2}, // 2 × 1.3 = 2.6 → 2
{"one two three four five six seven", 9}, // 7 × 1.3 = 9.1 → 9
}
for _, c := range cases {
got := approxTokenCount(c.s)
if got != c.want {
t.Errorf("approxTokenCount(%q) = %d; want %d", c.s, got, c.want)
}
}
}
func TestSanitiseForTmux(t *testing.T) {
in := "first line\nsecond line\rthird"
got := sanitiseForTmux(in)
if strings.ContainsAny(got, "\n\r") {
t.Errorf("sanitiseForTmux did not strip newlines: %q", got)
}
}
func TestPaliadinOwnerEmail_IsLowercaseStable(t *testing.T) {
// Sanity check: the constant matches the email seeded in
// migration 023 verbatim. If it ever drifts, the gate would
// reject m on a fresh DB without anyone noticing.
want := "matthias.siebels@hoganlovells.com"
if PaliadinOwnerEmail != want {
t.Fatalf("PaliadinOwnerEmail = %q; want %q (matches migration 023 seed)",
PaliadinOwnerEmail, want)
}
// Lowercase invariant — the gate uses strings.EqualFold but we
// store + compare lowercase consistently anyway.
if strings.ToLower(PaliadinOwnerEmail) != PaliadinOwnerEmail {
t.Errorf("PaliadinOwnerEmail must be lowercase: %q", PaliadinOwnerEmail)
}
}
func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
long := strings.Repeat("x", 10_000)
got := sanitiseForTmux(long)
if !strings.HasSuffix(got, "[…truncated]") {
t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
}
}