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).
288 lines
9.0 KiB
Go
288 lines
9.0 KiB
Go
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:])
|
||
}
|
||
}
|