rony-chat-bot/internal/agent/runner_compaction_test.go

576 lines
20 KiB
Go
Raw Permalink Normal View History

package agent
import (
"context"
"errors"
"iter"
"strings"
"testing"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
llmpersona "github.com/VictorVargas/rony-llm-agent/pkg/persona"
)
// compactionStub is an llm.LLMClient that:
// - reports a configurable MaxContextWindow via Capabilities
// - records Generate calls (used for summarization)
// - returns a fixed Stream that also bumps the runner's r.usage via
// the usage chunk (so subsequent compactions see realistic usage)
type compactionStub struct {
window int
streamInput int // input tokens reported on the streamed usage chunk
generateCalls int
generateErr error
summary string
lastGenerate llm.CompletionRequest
}
func (s *compactionStub) Generate(_ context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) {
s.generateCalls++
s.lastGenerate = req
if s.generateErr != nil {
return llm.CompletionResponse{}, s.generateErr
}
return llm.CompletionResponse{
Content: s.summary,
StopReason: llm.StopReasonEndTurn,
}, nil
}
func (s *compactionStub) Stream(_ context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamChunk, error] {
return func(yield func(llm.StreamChunk, error) bool) {
yield(llm.StreamChunk{Delta: "ok", FinishReason: "stop", Usage: llm.TokenUsage{InputTokens: s.streamInput, OutputTokens: 3}}, nil)
}
}
func (s *compactionStub) Name() string { return "compaction-stub" }
func (s *compactionStub) Capabilities() llm.ProviderCapabilities {
return llm.ProviderCapabilities{MaxContextWindow: s.window}
}
// pumpStream drives a single Stream call through the runner so the usage
// chunk updates r.usage — needed because the compaction trigger reads
// from r.usage, not from the request itself.
func pumpStream(t *testing.T, r *Runner, history []Message) {
t.Helper()
for chunk, err := range r.Stream(context.Background(), history) {
if err != nil {
t.Fatal(err)
}
_ = chunk
}
}
// makeHistory builds a synthetic N-turn conversation (user + assistant
// pairs), each "long" enough to be obviously past any small threshold.
func makeHistory(turns int, contentLen int) []Message {
out := make([]Message, 0, turns*2)
payload := strings.Repeat("x", contentLen)
for i := 0; i < turns; i++ {
out = append(out,
Message{Role: RoleUser, Content: payload + " q"},
Message{Role: RoleAssistant, Content: payload + " a"},
)
}
return out
}
func personaMinimal() llmpersona.Persona {
return llmpersona.Persona{Name: "t", Tone: "concise", Language: "English"}
}
func TestCompactDisabledIsNoop(t *testing.T) {
cli := &compactionStub{window: 100, streamInput: 999, summary: "ignored"}
r := New(cli, personaMinimal(), "sys", nil, 5) // no WithCompaction
pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}})
got, err := r.Compact(context.Background(), makeHistory(8, 200))
if err != nil {
t.Fatal(err)
}
if cli.generateCalls != 0 {
t.Errorf("Generate called %d times, want 0 (compaction disabled)", cli.generateCalls)
}
if len(got) != 16 {
t.Errorf("len(history) = %d, want 16 (unchanged)", len(got))
}
stats := r.LastCompaction()
if stats.Happened {
t.Errorf("stats.Happened = true, want false")
}
}
func TestCompactBelowThresholdIsNoop(t *testing.T) {
// window = 1000, threshold = 0.5 → 500. Previous turn reported only
// 50 input tokens — well below the trigger.
cli := &compactionStub{window: 1000, streamInput: 50, summary: "ignored"}
r := New(cli, personaMinimal(), "sys", nil, 5).
WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 2})
pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}})
got, err := r.Compact(context.Background(), makeHistory(8, 40))
if err != nil {
t.Fatal(err)
}
if cli.generateCalls != 0 {
t.Errorf("Generate called %d times, want 0 (below threshold)", cli.generateCalls)
}
if len(got) != 16 {
t.Errorf("len(history) = %d, want 16 (unchanged)", len(got))
}
}
func TestCompactUsesEstimatedInputBeforeFirstStream(t *testing.T) {
cli := &compactionStub{window: 400, summary: "summary"}
r := New(cli, personaMinimal(), "system prompt", nil, 5).
WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 2})
got, err := r.Compact(context.Background(), makeHistory(8, 200))
if err != nil {
t.Fatal(err)
}
if cli.generateCalls != 1 {
t.Fatalf("Generate called %d times, want 1", cli.generateCalls)
}
if len(got) != 5 {
t.Fatalf("len(compacted) = %d, want 5", len(got))
}
}
func TestCompactShortHistoryIsNoop(t *testing.T) {
// Above threshold but only 3 turns total (3 user, 3 assistant = 6
// messages). KeepRecentTurns = 4, so splitByTurns has nothing older
// to summarize.
cli := &compactionStub{window: 100, streamInput: 999, summary: "ignored"}
r := New(cli, personaMinimal(), "sys", nil, 5).
WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 4})
pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}})
history := makeHistory(3, 200) // 6 messages, 3 user turns
got, err := r.Compact(context.Background(), history)
if err != nil {
t.Fatal(err)
}
if cli.generateCalls != 0 {
t.Errorf("Generate called %d times, want 0 (not enough turns)", cli.generateCalls)
}
if len(got) != len(history) {
t.Errorf("len(history) = %d, want %d (unchanged)", len(got), len(history))
}
}
func TestCompactUnknownWindowIsNoop(t *testing.T) {
// Provider doesn't report a window (Capabilities returns 0).
cli := &compactionStub{window: 0, streamInput: 999, summary: "ignored"}
r := New(cli, personaMinimal(), "sys", nil, 5).
WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 2})
pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}})
got, err := r.Compact(context.Background(), makeHistory(8, 200))
if err != nil {
t.Fatal(err)
}
if cli.generateCalls != 0 {
t.Errorf("Generate called %d times, want 0 (window unknown)", cli.generateCalls)
}
if len(got) != 16 {
t.Errorf("len(history) = %d, want 16 (unchanged)", len(got))
}
}
func TestCompactTriggersSummarize(t *testing.T) {
// 8 user turns × ~200 chars each = ~1600 chars ≈ 400 tokens. With
// window=400, threshold 0.5 = 200, and the stub reports 999 input
// tokens on the previous turn, compaction fires.
cli := &compactionStub{window: 400, streamInput: 999, summary: "user asked 8 things, here's the gist"}
r := New(cli, personaMinimal(), "sys", nil, 5).
WithCompaction(CompactionConfig{
Enabled: true,
ThresholdRatio: 0.5,
KeepRecentTurns: 2,
})
pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}})
history := makeHistory(8, 200)
got, err := r.Compact(context.Background(), history)
if err != nil {
t.Fatal(err)
}
if cli.generateCalls != 1 {
t.Fatalf("Generate called %d times, want 1 (compaction fired)", cli.generateCalls)
}
// The summary call's messages should be [system, user(transcript)].
if len(cli.lastGenerate.Messages) != 2 {
t.Fatalf("summarize call has %d messages, want 2", len(cli.lastGenerate.Messages))
}
if cli.lastGenerate.Messages[0].Role != RoleSystem {
t.Errorf("summarize call[0].Role = %q, want system", cli.lastGenerate.Messages[0].Role)
}
if cli.lastGenerate.Messages[1].Role != RoleUser {
t.Errorf("summarize call[1].Role = %q, want user", cli.lastGenerate.Messages[1].Role)
}
if !strings.Contains(cli.lastGenerate.Messages[1].Content, "User:") {
t.Errorf("summarize transcript missing 'User:' lines")
}
if got[0].Role != RoleSystem {
t.Errorf("compacted[0].Role = %q, want system (summary)", got[0].Role)
}
if !strings.Contains(got[0].Content, "user asked 8 things") {
t.Errorf("compacted[0] missing summary text: %q", got[0].Content)
}
// Recent tail: 2 turns = 4 messages, exactly the last 4 of the
// original history.
if len(got) != 1+4 {
t.Fatalf("len(compacted) = %d, want 5 (summary + 4)", len(got))
}
for i := 0; i < 4; i++ {
if got[1+i].Content != history[12+i].Content {
t.Errorf("kept[%d] content differs from history[%d]", i, 12+i)
}
}
stats := r.LastCompaction()
if !stats.Happened {
t.Fatal("stats.Happened = false, want true")
}
if stats.OlderTurns != 6 {
t.Errorf("OlderTurns = %d, want 6", stats.OlderTurns)
}
if stats.KeptTurns != 2 {
t.Errorf("KeptTurns = %d, want 2", stats.KeptTurns)
}
if stats.WindowTokens != 400 {
t.Errorf("WindowTokens = %d, want 400", stats.WindowTokens)
}
wantUsed := estimatePromptTokens("sys") + totalPromptTokens(history)
if stats.UsedTokens != wantUsed {
t.Errorf("UsedTokens = %d, want %d", stats.UsedTokens, wantUsed)
}
if stats.SummaryTokens == 0 {
t.Error("SummaryTokens = 0, want > 0")
}
}
func TestCompactSummarizeFailsFallsBackToTruncation(t *testing.T) {
cli := &compactionStub{
window: 400,
streamInput: 999,
generateErr: errors.New("summarize boom"),
}
r := New(cli, personaMinimal(), "sys", nil, 5).
WithCompaction(CompactionConfig{
Enabled: true,
ThresholdRatio: 0.5,
KeepRecentTurns: 2,
})
pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}})
// 7 completed turns + 1 trailing user message (the request's new
// question). Real production requests always end on a user message.
history := append(makeHistory(7, 200), Message{Role: RoleUser, Content: "new question"})
got, err := r.Compact(context.Background(), history)
if err != nil {
t.Fatalf("Compact should swallow summarize failure, got err: %v", err)
}
if cli.generateCalls != 1 {
t.Errorf("Generate called %d times, want 1 (tried once before fallback)", cli.generateCalls)
}
// Fallback should have dropped the oldest turns. The trailing user
// message (the current question) must always be preserved.
last := got[len(got)-1]
if last.Role != RoleUser {
t.Errorf("last message role = %q, want user", last.Role)
}
if last.Content != "new question" {
t.Errorf("last message content = %q, want %q", last.Content, "new question")
}
stats := r.LastCompaction()
if !stats.Happened {
t.Error("stats.Happened = false, want true (fallback counts as compaction)")
}
if stats.OlderTurns == 0 {
t.Error("OlderTurns = 0, want > 0 (fallback dropped turns)")
}
}
func TestCompactEmptyHistoryIsNoop(t *testing.T) {
cli := &compactionStub{window: 100, streamInput: 999}
r := New(cli, personaMinimal(), "sys", nil, 5).
WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.1, KeepRecentTurns: 2})
got, err := r.Compact(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Errorf("got = %v, want nil", got)
}
if cli.generateCalls != 0 {
t.Errorf("Generate called %d times, want 0", cli.generateCalls)
}
}
func TestFitHistoryDropsOldTurnsToFit(t *testing.T) {
cli := &compactionStub{window: 400}
r := New(cli, personaMinimal(), "sys", nil, 5)
history := makeHistory(4, 400)
got, err := r.fitHistory("sys", history)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("len(history) = %d, want 2", len(got))
}
if got[0].Role != RoleUser || got[1].Role != RoleAssistant {
t.Errorf("remaining turn roles = %q, %q", got[0].Role, got[1].Role)
}
}
func TestSplitByTurns(t *testing.T) {
history := []Message{
{Role: RoleUser, Content: "u1"},
{Role: RoleAssistant, Content: "a1"},
{Role: RoleUser, Content: "u2"},
{Role: RoleAssistant, Content: "a2"},
{Role: RoleUser, Content: "u3"},
{Role: RoleAssistant, Content: "a3"},
{Role: RoleUser, Content: "u4"},
{Role: RoleAssistant, Content: "a4"},
}
older, recent := splitByTurns(history, 2)
if len(older) != 4 {
t.Errorf("len(older) = %d, want 4", len(older))
}
if len(recent) != 4 {
t.Errorf("len(recent) = %d, want 4", len(recent))
}
if older[0].Content != "u1" || older[3].Content != "a2" {
t.Errorf("older = %v, want [u1,a1,u2,a2]", older)
}
if recent[0].Content != "u3" || recent[3].Content != "a4" {
t.Errorf("recent = %v, want [u3,a3,u4,a4]", recent)
}
}
func TestSplitByTurnsNothingToCompact(t *testing.T) {
history := []Message{
{Role: RoleUser, Content: "u1"},
{Role: RoleAssistant, Content: "a1"},
{Role: RoleUser, Content: "u2"},
}
older, recent := splitByTurns(history, 4)
if older != nil {
t.Errorf("older = %v, want nil", older)
}
if len(recent) != 3 {
t.Errorf("len(recent) = %d, want 3 (everything)", len(recent))
}
}
func TestRenderTranscriptSkipsToolAndEmpty(t *testing.T) {
msgs := []Message{
{Role: RoleUser, Content: "hi"},
{Role: RoleAssistant, Content: "hello!"},
{Role: RoleAssistant, Content: ""}, // streaming placeholder
{Role: RoleSystem, Content: "internal"},
}
got := renderTranscript(msgs)
want := "User: hi\nAssistant: hello!\n"
if got != want {
t.Errorf("renderTranscript =\n%q\nwant\n%q", got, want)
}
}
func TestEstimateTokens(t *testing.T) {
cases := []struct {
in string
want int
}{
{"", 0},
{"a", 1},
{"abcd", 2},
{strings.Repeat("x", 400), 101},
}
for _, c := range cases {
if got := estimateTokens(c.in); got != c.want {
t.Errorf("estimateTokens(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestEstimatePromptTokensIsConservative(t *testing.T) {
text := strings.Repeat("x", 300)
if got := estimatePromptTokens(text); got <= estimateTokens(text) {
t.Errorf("estimatePromptTokens = %d, want greater than estimateTokens = %d", got, estimateTokens(text))
}
}
func TestTruncateToBudgetAlwaysKeepsLastUserTurn(t *testing.T) {
// 3 long completed turns + a short trailing user message (the new
// question). Budget so small only the last turn can fit.
long := strings.Repeat("x", 400)
history := []Message{
{Role: RoleUser, Content: long + " q1"},
{Role: RoleAssistant, Content: long + " a1"},
{Role: RoleUser, Content: long + " q2"},
{Role: RoleAssistant, Content: long + " a2"},
{Role: RoleUser, Content: long + " q3"},
{Role: RoleAssistant, Content: long + " a3"},
{Role: RoleUser, Content: "current question"},
}
dropped, kept := truncateToBudget(history, 10)
if len(kept) == 0 {
t.Fatal("kept is empty")
}
if kept[len(kept)-1].Content != "current question" {
t.Errorf("last kept message content = %q, want %q",
kept[len(kept)-1].Content, "current question")
}
if len(dropped)+len(kept) != len(history) {
t.Errorf("dropped+kept = %d, want %d", len(dropped)+len(kept), len(history))
}
}
feat(rag): hybrid retrieval, reference documents, and vendor sampling Answers were short, sometimes in the wrong language, and occasionally about projects that do not exist. Measured on a 20-question battery against the real corpus in both Spanish and English, this takes grounded content from 3/10 to 9/10 and language matching from 7/10 to 10/10. Retrieval - Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion. Both halves are load-bearing: the corpus is English and visitors ask in Spanish, so the meaningful words score zero. "paga" appears 0 times in a document that says "Payments: Stripe" — the question "¿Con qué se paga en la tienda de ropa?" retrieved nothing at all. Embeddings put all three of that project's chunks on top. RRF ranks by agreement rather than comparing a BM25 score against a cosine, quantities with no shared scale. - internal/embed: OpenAI-compatible embeddings client, unit-normalised so a dot product is the cosine. Reorders by the response `index` field. - Store a content hash beside each vector and skip rows where it no longer matches the chunk. Chunk ids survive body edits, so without this an edited document keeps serving embeddings that describe text that is gone — reproduced live by changing a payment provider and watching the old one keep coming back. - Degrade to keyword-only when the embedder is down instead of failing. Reference documents that are not projects - Index `.mdx` alongside `.md`, and split sources into projects (announced in the catalogue) and reference material (retrievable, never listed). A CV is what someone deciding whether to hire actually reads, and it was unreachable while it lived only in the Astro site — but filing it under projects made the bot list "cv" as one of Victor's works. - Skip each directory's README. `data/projects/README.md` was being indexed, so the catalogue injected into every prompt announced "README" and "README.es" as projects of Victor's. - Exclude frontmatter from retrieval. It is dense metadata in a very short chunk, which makes it a magnet for short queries: a CV's `location:` field answered "¿Dónde ha trabajado Victor?" with a city instead of a work history. - Split oversized sections at `###` before falling back to byte offsets. A CV's Experience section is a list of jobs, and size-splitting cut one mid-word, stranding the employer's name in the previous chunk. Prompt and sampling - Inject the full project catalogue every turn. Top-K search returns the best matching sections, so "list every project" cannot be answered from retrieval alone, and a small model asked to enumerate from partial hits invents the rest. ~10 tokens per project; this is what stopped the invented names. - Wire the sampling parameters the model authors publish (top_k, top_p, min_p, repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them at llama.cpp's defaults produced 16-token stub answers. - Localised system prompt selected by detected language. The English prompt plus "reply in the user's language" answered 1/5 Spanish questions in Spanish; few-shot examples fixed the language but got copied verbatim into real answers. - Fold compaction's system notes into the leading system message. Gemma's chat template rejects a system message that is not first, and the whole request failed with HTTP 400 the moment compaction fired. Configuration and docs - context_size 4096, down from 8192. The largest prompt this bot ever built over 20 real requests was 1255 tokens, compaction starts at ~3070, and the cut saved 212 MB resident with zero truncations and identical throughput. - Correct the RAM figures throughout. They were measured with a GPU absorbing llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs still started gemma-3-1b while the config defaulted to qwen, and neither started the embedder at all. Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder + 0.02 GB bot, 21.0 tok/s steady state. Known and unfixed, so they are not re-filed as new bugs: the model reads dates out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha trabajado Victor?" still answers with projects rather than employers, though "¿En qué empresas ha trabajado?" works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
// A compacted history carries the summary as a system message. Several chat
// templates (Gemma 3, Anthropic) reject a system turn that isn't the first
// message, so BuildMessages must fold it into the single leading system
// prompt rather than pass it through as its own turn. Regression test: this
// used to send two system messages and llama-server answered HTTP 400.
func TestBuildMessagesFoldsSystemNotesIntoOneSystemMessage(t *testing.T) {
r := New(&compactionStub{window: 100000}, personaMinimal(), "persona", nil, 5)
msgs, _, err := r.BuildMessages(context.Background(), []Message{
{Role: RoleSystem, Content: "Earlier conversation summary:\nuser asked about the dashboard"},
{Role: RoleUser, Content: "y el stack?"},
})
if err != nil {
t.Fatal(err)
}
for i, m := range msgs {
if i > 0 && m.Role == RoleSystem {
t.Fatalf("msgs[%d] is a second system message; want exactly one, at index 0", i)
}
}
if msgs[0].Role != RoleSystem {
t.Fatalf("msgs[0].Role = %q, want system", msgs[0].Role)
}
if !strings.Contains(msgs[0].Content, "user asked about the dashboard") {
t.Errorf("summary was dropped instead of folded into the system prompt: %q", msgs[0].Content)
}
if !strings.Contains(msgs[0].Content, "persona") {
t.Errorf("folding lost the persona prompt: %q", msgs[0].Content)
}
// The turn list must be the conversation only, and must still alternate.
if len(msgs) != 2 || msgs[1].Role != RoleUser || msgs[1].Content != "y el stack?" {
t.Fatalf("turns = %+v, want [system, user]", msgs)
}
}
// foldSystemNotes must not write through to the caller's backing array —
// handlers reuse the history slice to persist the conversation.
func TestFoldSystemNotesDoesNotMutateCaller(t *testing.T) {
history := []Message{
{Role: RoleUser, Content: "one"},
{Role: RoleSystem, Content: "note"},
{Role: RoleAssistant, Content: "two"},
}
before := append([]Message(nil), history...)
notes, rest := foldSystemNotes(history)
if len(notes) != 1 || notes[0] != "note" {
t.Fatalf("notes = %v, want [note]", notes)
}
if len(rest) != 2 {
t.Fatalf("rest has %d messages, want 2", len(rest))
}
for i := range before {
if history[i].Role != before[i].Role || history[i].Content != before[i].Content {
t.Errorf("caller's history[%d] mutated: %+v -> %+v", i, before[i], history[i])
}
}
}
// A 1B model won't infer the reply language from an all-English system
// prompt, so the runner pins it explicitly from the last user message.
func TestLanguageDirectiveFollowsTheUserAndThePersonaOverride(t *testing.T) {
auto := New(&compactionStub{window: 100000},
llmpersona.Persona{Language: "the user's language"}, "persona", nil, 5)
es := auto.languageDirective(detectLanguage([]Message{{Role: RoleUser, Content: "¿Qué proyectos tiene Victor?"}}))
if !strings.Contains(es, "español") {
t.Errorf("Spanish question got directive %q, want a Spanish one", es)
}
en := auto.languageDirective(detectLanguage([]Message{{Role: RoleUser, Content: "What projects does he have?"}}))
if !strings.Contains(en, "English") {
t.Errorf("English question got directive %q, want an English one", en)
}
// Detection must follow the *latest* user turn, not the first.
switched := auto.languageDirective(detectLanguage([]Message{
{Role: RoleUser, Content: "What projects does he have?"},
{Role: RoleAssistant, Content: "..."},
{Role: RoleUser, Content: "¿Y cuál usa Stripe?"},
}))
if !strings.Contains(switched, "español") {
t.Errorf("after switching to Spanish got %q, want a Spanish directive", switched)
}
if auto.languageDirective(detectLanguage(nil)) != "" {
t.Errorf("empty history should produce no directive")
}
pinned := New(&compactionStub{window: 100000},
llmpersona.Persona{Language: "Spanish"}, "persona", nil, 5)
if got := pinned.languageDirective(detectLanguage([]Message{{Role: RoleUser, Content: "hello there"}})); got != "Write your entire reply in Spanish." {
t.Errorf("persona override ignored: %q", got)
}
}
// The directive must be the final line of the system prompt — that position
// is why it survives.
func TestLanguageDirectiveIsLastInSystemPrompt(t *testing.T) {
r := New(&compactionStub{window: 100000},
llmpersona.Persona{Language: "the user's language"}, "persona", nil, 5)
msgs, _, err := r.BuildMessages(context.Background(), []Message{
{Role: RoleSystem, Content: "Earlier conversation summary:\nalgo"},
{Role: RoleUser, Content: "¿Qué stack usa el dashboard?"},
})
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(strings.TrimSpace(msgs[0].Content),
"El visitante escribió en español. Responde ÍNTEGRAMENTE en español, incluido el saludo.") {
t.Errorf("language directive is not the last line of the system prompt:\n%s", msgs[0].Content)
}
}
// A Spanish question must build on the Spanish rendition of the prompt, not
// the default one with a directive bolted on.
func TestLocalizedPromptSelectedByDetectedLanguage(t *testing.T) {
r := New(&compactionStub{window: 100000},
llmpersona.Persona{Language: "the user's language"}, "ENGLISH PROMPT", nil, 5).
WithLocalizedPrompt("es", "PROMPT EN ESPAÑOL")
es, _, err := r.BuildMessages(context.Background(),
[]Message{{Role: RoleUser, Content: "¿Qué proyectos tiene Victor?"}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(es[0].Content, "PROMPT EN ESPAÑOL") {
t.Errorf("Spanish question did not select the Spanish prompt:\n%s", es[0].Content)
}
en, _, err := r.BuildMessages(context.Background(),
[]Message{{Role: RoleUser, Content: "What projects does he have?"}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(en[0].Content, "ENGLISH PROMPT") {
t.Errorf("English question did not select the default prompt:\n%s", en[0].Content)
}
// No translation registered → fall back, never blank.
bare := New(&compactionStub{window: 100000},
llmpersona.Persona{Language: "the user's language"}, "ENGLISH PROMPT", nil, 5)
msgs, _, err := bare.BuildMessages(context.Background(),
[]Message{{Role: RoleUser, Content: "¿Y esto?"}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msgs[0].Content, "ENGLISH PROMPT") {
t.Errorf("missing translation should fall back to the default prompt:\n%s", msgs[0].Content)
}
}