/ internal / agent / assemble_test.go
assemble_test.go
  1  package agent
  2  
  3  import (
  4  	"strings"
  5  	"testing"
  6  
  7  	"github.com/Kocoro-lab/ShanClaw/internal/prompt"
  8  )
  9  
 10  // TestAssembleUserMessage_InstructionsOnlyEmitsCacheBreak is the end-to-end
 11  // guard for the instructions-in-StableContext move: when only shared
 12  // instructions are present (no sticky facts), the assembled user message must
 13  // still emit the <!-- cache_break --> marker with instructions sitting in the
 14  // cacheable prefix. This is what locks in the "caching win" contract; a unit
 15  // test on buildStableContext alone would pass even if assembleUserMessage
 16  // regressed to skip the marker.
 17  func TestAssembleUserMessage_InstructionsOnlyEmitsCacheBreak(t *testing.T) {
 18  	parts := prompt.BuildSystemPrompt(prompt.PromptOptions{
 19  		BasePrompt:   "You are Shannon.",
 20  		Instructions: "Never push to main without review.",
 21  	})
 22  
 23  	result := assembleUserMessage(parts, "ship the release")
 24  
 25  	idx := strings.Index(result, "<!-- cache_break -->")
 26  	if idx < 0 {
 27  		t.Fatalf("expected cache_break marker, got:\n%s", result)
 28  	}
 29  
 30  	prefix := result[:idx]
 31  	suffix := result[idx:]
 32  
 33  	if !strings.Contains(prefix, "## Instructions") {
 34  		t.Error("Instructions header should be in the cached prefix (before cache_break)")
 35  	}
 36  	if !strings.Contains(prefix, "Never push to main without review.") {
 37  		t.Error("instructions body should be in the cached prefix")
 38  	}
 39  	if strings.Contains(suffix, "Never push to main without review.") {
 40  		t.Error("instructions body must not appear after cache_break")
 41  	}
 42  	if !strings.HasSuffix(result, "ship the release") {
 43  		t.Error("raw user message should be at the end")
 44  	}
 45  }
 46  
 47  func TestAssembleUserMessage_CacheBreakRegression(t *testing.T) {
 48  	t.Run("empty stable omits marker", func(t *testing.T) {
 49  		result := assembleUserMessage(prompt.PromptParts{
 50  			StableContext:  "",
 51  			VolatileContext: "current date: 2026-04-03",
 52  		}, "hello")
 53  		if strings.Contains(result, "cache_break") {
 54  			t.Error("cache_break should not appear when StableContext is empty")
 55  		}
 56  	})
 57  
 58  	t.Run("non-empty stable includes marker", func(t *testing.T) {
 59  		result := assembleUserMessage(prompt.PromptParts{
 60  			StableContext:  "system instructions",
 61  			VolatileContext: "current date: 2026-04-03",
 62  		}, "hello")
 63  		if !strings.Contains(result, "cache_break") {
 64  			t.Error("cache_break should appear when StableContext is non-empty")
 65  		}
 66  	})
 67  
 68  	t.Run("marker separates stable from volatile", func(t *testing.T) {
 69  		result := assembleUserMessage(prompt.PromptParts{
 70  			StableContext:  "stable-prefix",
 71  			VolatileContext: "volatile-suffix",
 72  		}, "user-query")
 73  
 74  		idx := strings.Index(result, "<!-- cache_break -->")
 75  		if idx < 0 {
 76  			t.Fatal("marker not found")
 77  		}
 78  		if !strings.Contains(result[:idx], "stable-prefix") {
 79  			t.Error("stable content should be before marker")
 80  		}
 81  		if !strings.Contains(result[idx:], "volatile-suffix") {
 82  			t.Error("volatile content should be after marker")
 83  		}
 84  		if !strings.HasSuffix(result, "user-query") {
 85  			t.Error("user message should be at the end")
 86  		}
 87  	})
 88  }
 89  
 90  func TestAssembleUserMessage_SessionPlaceholderEmitsCacheBreak(t *testing.T) {
 91  	parts := prompt.PromptParts{
 92  		System:          "static",
 93  		StableContext:   "## Session\nActive agent context.",
 94  		VolatileContext: "## Context\nDate: 2026-04-14",
 95  	}
 96  	msg := assembleUserMessage(parts, "hello")
 97  	if !strings.Contains(msg, "<!-- cache_break -->") {
 98  		t.Fatalf("cache_break marker missing when only session placeholder present")
 99  	}
100  	if !strings.Contains(msg, "Active agent context.") {
101  		t.Fatalf("session placeholder not preserved: %q", msg)
102  	}
103  }