Files
viettranx 2731f99ad5 feat(memory): context-aware recall query for auto-inject
Auto-inject previously searched episodic memory using only the latest
user message. Follow-up questions like "what's my favorite?" returned
poor matches because the embedding lost the conversational frame.

InjectParams now carries an optional RecentContext field that pgAuto
Injector prepends to the search query as "Context: ... \nQuery: ..."
before running the FTS+vector hybrid search. The "Context:"/"Query:"
framing works with both instruction-tuned embedding models (which
respect the labels) and plain models (neutral separators).

ContextStage walks the message history backward, collects up to 2
trailing user turns capped at 300 runes total, and threads the snippet
through the AutoInject callback to the injector. Empty RecentContext
preserves legacy single-message search semantics — zero-risk fallback
for callers that haven't adopted the new field.

Rune-based truncation (not byte) keeps vi/zh locales safe: a byte-wise
tail-clip would slice multi-byte runes and emit invalid UTF-8 to the
embedding model, degrading exactly the cases Phase 9 is meant to fix.
tailClipRunes helper covers Vietnamese, Chinese, Japanese, emoji.

13 regression tests: recall query builder (unicode-safe clip,
whitespace handling, position ordering), buildRecentContext (order
preservation, turn cap, truncation, non-user skip), and
tailClipRunes (CJK, short input, zero cap). All passing with -race.

Refs plans/260410-1009-openclaw-ts-feature-port/phase-09-active-
memory-recall.md — minimal-viable delivery; Tier 2 LLM re-ranking
and per-session recall cache deferred until operational data shows
context-aware search alone is insufficient.
2026-04-10 12:01:00 +07:00

121 lines
4.2 KiB
Go

package memory
import (
"strings"
"testing"
)
func TestBuildRecallQuery_NoContextReturnsMessageUnchanged(t *testing.T) {
got := buildRecallQuery("what's my favorite?", "")
if got != "what's my favorite?" {
t.Errorf("empty context should return message unchanged, got %q", got)
}
}
func TestBuildRecallQuery_EmptyMessageReturnsEmpty(t *testing.T) {
got := buildRecallQuery("", "some recent context")
if got != "" {
t.Errorf("empty message should return empty, got %q", got)
}
}
func TestBuildRecallQuery_CombinesContextAndMessage(t *testing.T) {
ctx := "We were talking about coffee shops in downtown."
msg := "what's my favorite?"
got := buildRecallQuery(msg, ctx)
if !strings.Contains(got, msg) {
t.Errorf("result must contain user message, got %q", got)
}
if !strings.Contains(got, ctx) {
t.Errorf("result must contain recent context, got %q", got)
}
// Query label must come AFTER context so position-biased embeddings give
// the message the strongest signal.
ctxIdx := strings.Index(got, "Context:")
queryIdx := strings.Index(got, "Query:")
if ctxIdx == -1 || queryIdx == -1 || ctxIdx >= queryIdx {
t.Errorf("Context must precede Query label, got %q", got)
}
}
func TestBuildRecallQuery_TruncatesOversizedContext(t *testing.T) {
// 1000-char context, maxRecallContextChars = 400
ctx := strings.Repeat("a", 1000)
msg := "test"
got := buildRecallQuery(msg, ctx)
// Stripped context section should be at most maxRecallContextRunes long.
// Full output has prefix overhead ("Context: " + "\nQuery: " + msg) ~ 20 chars.
// Total should be < 500 chars (bounded by truncation + prefix + msg).
if len(got) > maxRecallContextRunes+64 {
t.Errorf("oversized context not truncated: got %d chars, want ≤ %d",
len(got), maxRecallContextRunes+64)
}
}
func TestBuildRecallQuery_TailClipsLongContext(t *testing.T) {
// Tail-clip keeps the most recent portion (last N chars) since it's
// closer to the current turn in conversation time.
ctx := strings.Repeat("OLD ", 200) + strings.Repeat("NEW ", 20) // 820 chars, "NEW" at the end
got := buildRecallQuery("follow-up", ctx)
if !strings.Contains(got, "NEW") {
t.Errorf("tail-clip should preserve recent ('NEW') portion, got %q", got)
}
}
func TestBuildRecallQuery_TrimsWhitespace(t *testing.T) {
got := buildRecallQuery(" query ", " ctx ")
if !strings.Contains(got, "Context: ctx") {
t.Errorf("context should be trimmed, got %q", got)
}
if !strings.Contains(got, "Query: query") {
t.Errorf("message should be trimmed, got %q", got)
}
}
// TestBuildRecallQuery_UnicodeSafeTailClip verifies rune-safe truncation for
// Vietnamese / Chinese input. Byte-wise slicing would produce invalid UTF-8 at
// multi-byte rune boundaries — embedding models on that garbage return lower
// quality matches, defeating Phase 9's whole point.
func TestBuildRecallQuery_UnicodeSafeTailClip(t *testing.T) {
// 500 Vietnamese tiếng Việt runes (each char = 2-3 bytes)
viChar := "ế" // 3 bytes in UTF-8
ctx := strings.Repeat(viChar, 500)
msg := "câu hỏi"
got := buildRecallQuery(msg, ctx)
// The result must contain valid UTF-8 — no half-runes at the clip boundary.
// If the clip cut a rune, the resulting string would contain 0xEF 0xBF 0xBD
// replacement chars or fail strings.ContainsRune checks.
if !strings.Contains(got, msg) {
t.Errorf("message missing from result, got %q", got)
}
// Must still contain the Vietnamese marker char
if !strings.Contains(got, viChar) {
t.Errorf("vietnamese context missing, got %q", got)
}
// Every rune must be valid (conversion round-trip)
for _, r := range got {
if r == '\uFFFD' {
t.Errorf("result contains replacement rune (invalid UTF-8 clip boundary), got %q", got)
break
}
}
}
func TestTailClipRunes(t *testing.T) {
if got := tailClipRunes("hello", 10); got != "hello" {
t.Errorf("short input should be unchanged, got %q", got)
}
if got := tailClipRunes("abcdef", 3); got != "def" {
t.Errorf("expected 'def', got %q", got)
}
// Chinese: 10 Han chars, clip to 3
if got := tailClipRunes("一二三四五六七八九十", 3); got != "八九十" {
t.Errorf("expected '八九十', got %q", got)
}
if got := tailClipRunes("anything", 0); got != "" {
t.Errorf("zero maxRunes should return empty, got %q", got)
}
}