/ internal / prompt / builder_test.go
builder_test.go
  1  package prompt
  2  
  3  import (
  4  	"runtime"
  5  	"strings"
  6  	"testing"
  7  
  8  	"github.com/Kocoro-lab/ShanClaw/internal/skills"
  9  )
 10  
 11  // TestBuildSystemPrompt_NudgesParallelToolUse verifies the system prompt
 12  // encourages batching independent tool calls into a single response. This
 13  // cuts block churn in the agent loop — the dominant long-session CHR drag
 14  // once msgs * 1.5 exceeds Anthropic's ~20-block auto-lookback.
 15  func TestBuildSystemPrompt_NudgesParallelToolUse(t *testing.T) {
 16  	parts := BuildSystemPrompt(PromptOptions{
 17  		BasePrompt: "Base.",
 18  		ToolNames:  []string{"file_read", "bash", "grep"},
 19  	})
 20  
 21  	// Text signals — must mention parallelism AND the mechanism (tool_use block / single response).
 22  	// Case-insensitive: nudge may emphasize words in uppercase.
 23  	lower := strings.ToLower(parts.System)
 24  	for _, keyword := range []string{"parallel", "single response", "tool_use"} {
 25  		if !strings.Contains(lower, keyword) {
 26  			t.Errorf("system prompt missing %q — should nudge parallel tool use to reduce block churn", keyword)
 27  		}
 28  	}
 29  }
 30  
 31  // TestBuildSystemPrompt_ParallelNudgeOnlyWhenToolsPresent verifies the nudge
 32  // is omitted when no tools are available — adding it would waste tokens and
 33  // pollute the cached prefix for tool-less agents.
 34  func TestBuildSystemPrompt_ParallelNudgeOnlyWhenToolsPresent(t *testing.T) {
 35  	parts := BuildSystemPrompt(PromptOptions{
 36  		BasePrompt: "You answer questions without tools.",
 37  	})
 38  	if strings.Contains(parts.System, "parallel tool_use") || strings.Contains(parts.System, "SINGLE response") {
 39  		t.Errorf("parallel nudge should be absent when no tools are registered:\n%s", parts.System)
 40  	}
 41  }
 42  
 43  func TestBuildSystemPrompt_SystemIsStatic(t *testing.T) {
 44  	// Two calls with different volatile content must produce identical System fields
 45  	opts1 := PromptOptions{
 46  		BasePrompt: "You are Shannon.",
 47  		ToolNames:  []string{"bash", "file_read"},
 48  		Memory:     "User prefers Go.",
 49  		CWD:        "/home/user/project",
 50  	}
 51  	opts2 := PromptOptions{
 52  		BasePrompt: "You are Shannon.",
 53  		ToolNames:  []string{"bash", "file_read"},
 54  		Memory:     "User prefers Rust now.",
 55  		CWD:        "/tmp/other",
 56  	}
 57  
 58  	parts1 := BuildSystemPrompt(opts1)
 59  	parts2 := BuildSystemPrompt(opts2)
 60  
 61  	if parts1.System != parts2.System {
 62  		t.Errorf("System field changed between calls with different volatile content.\nFirst:\n%s\nSecond:\n%s", parts1.System, parts2.System)
 63  	}
 64  }
 65  
 66  func TestBuildSystemPrompt_VolatileContainsMemory(t *testing.T) {
 67  	parts := BuildSystemPrompt(PromptOptions{
 68  		BasePrompt: "Base.",
 69  		Memory:     "User prefers Go.",
 70  	})
 71  
 72  	if strings.Contains(parts.System, "User prefers Go.") {
 73  		t.Error("System should not contain memory content")
 74  	}
 75  	if !strings.Contains(parts.VolatileContext, "User prefers Go.") {
 76  		t.Error("VolatileContext should contain memory content")
 77  	}
 78  }
 79  
 80  func TestBuildSystemPrompt_StableContextContainsInstructions(t *testing.T) {
 81  	parts := BuildSystemPrompt(PromptOptions{
 82  		BasePrompt:   "Base.",
 83  		Instructions: "Always use gofmt.",
 84  	})
 85  
 86  	if strings.Contains(parts.System, "Always use gofmt.") {
 87  		t.Error("System should not contain instructions")
 88  	}
 89  	if strings.Contains(parts.VolatileContext, "Always use gofmt.") {
 90  		t.Error("VolatileContext should not contain instructions (must live in StableContext so it joins the cacheable prefix)")
 91  	}
 92  	if !strings.Contains(parts.StableContext, "## Instructions") {
 93  		t.Error("StableContext should contain the Instructions section header")
 94  	}
 95  	if !strings.Contains(parts.StableContext, "Always use gofmt.") {
 96  		t.Error("StableContext should contain instructions body")
 97  	}
 98  }
 99  
100  // TestBuildSystemPrompt_InstructionsOnlyStillEmitsStableContext guards the
101  // cache-break assembly path: when only instructions are present (no sticky
102  // facts), StableContext must still be non-empty so assembleUserMessage emits
103  // the <!-- cache_break --> marker. Without this, instructions would silently
104  // fall back behind the marker and lose their caching benefit.
105  func TestBuildSystemPrompt_InstructionsOnlyStillEmitsStableContext(t *testing.T) {
106  	parts := BuildSystemPrompt(PromptOptions{
107  		BasePrompt:   "Base.",
108  		Instructions: "Never push to main without review.",
109  	})
110  
111  	if parts.StableContext == "" {
112  		t.Fatal("StableContext should be non-empty when instructions are set (cache_break depends on this)")
113  	}
114  	if !strings.Contains(parts.StableContext, "Never push to main without review.") {
115  		t.Error("StableContext should contain instructions body")
116  	}
117  	if strings.Contains(parts.StableContext, "## Session Facts") {
118  		t.Error("StableContext should not emit an empty Session Facts header when sticky is empty")
119  	}
120  }
121  
122  // TestBuildSystemPrompt_InstructionsBeforeStickyFacts locks in the ordering
123  // contract: the more-stable content (file-backed instructions) must precede
124  // sticky session facts inside StableContext so a cache-prefix can extend
125  // across sessions that share an instructions.md but differ in session source.
126  func TestBuildSystemPrompt_InstructionsBeforeStickyFacts(t *testing.T) {
127  	parts := BuildSystemPrompt(PromptOptions{
128  		BasePrompt:    "Base.",
129  		Instructions:  "Always use gofmt.",
130  		StickyContext: "Customer: Alice. Order #8891.",
131  	})
132  
133  	instIdx := strings.Index(parts.StableContext, "## Instructions")
134  	factsIdx := strings.Index(parts.StableContext, "## Session Facts")
135  	if instIdx < 0 {
136  		t.Fatal("StableContext missing Instructions header")
137  	}
138  	if factsIdx < 0 {
139  		t.Fatal("StableContext missing Session Facts header")
140  	}
141  	if instIdx >= factsIdx {
142  		t.Errorf("Instructions must precede Session Facts in StableContext, got Instructions@%d Facts@%d", instIdx, factsIdx)
143  	}
144  }
145  
146  func TestBuildSystemPrompt_VolatileContainsCWD(t *testing.T) {
147  	parts := BuildSystemPrompt(PromptOptions{
148  		BasePrompt: "Base.",
149  		CWD:        "/tmp/test",
150  	})
151  
152  	if strings.Contains(parts.System, "/tmp/test") {
153  		t.Error("System should not contain CWD")
154  	}
155  	if !strings.Contains(parts.VolatileContext, "/tmp/test") {
156  		t.Error("VolatileContext should contain CWD")
157  	}
158  }
159  
160  func TestBuildSystemPrompt_VolatileContainsDateTime(t *testing.T) {
161  	parts := BuildSystemPrompt(PromptOptions{
162  		BasePrompt: "Base.",
163  	})
164  
165  	if strings.Contains(parts.System, "Current date:") {
166  		t.Error("System should not contain date/time")
167  	}
168  	if !strings.Contains(parts.VolatileContext, "Current date:") {
169  		t.Error("VolatileContext should contain date/time")
170  	}
171  }
172  
173  func TestBuildSystemPrompt_VolatileContainsMCPContext(t *testing.T) {
174  	parts := BuildSystemPrompt(PromptOptions{
175  		BasePrompt: "Base.",
176  		MCPContext: "Playwright: connected to Chrome on port 9222",
177  	})
178  
179  	if strings.Contains(parts.System, "Playwright") {
180  		t.Error("System should not contain MCP context")
181  	}
182  	if !strings.Contains(parts.VolatileContext, "Playwright") {
183  		t.Error("VolatileContext should contain MCP context")
184  	}
185  }
186  
187  func TestBuildSystemPrompt_StableContextContainsStickyFacts(t *testing.T) {
188  	parts := BuildSystemPrompt(PromptOptions{
189  		BasePrompt:    "Base.",
190  		StickyContext: "Customer: Alice. Order #8891.",
191  	})
192  
193  	if strings.Contains(parts.System, "Alice") {
194  		t.Error("System should not contain sticky context")
195  	}
196  	if strings.Contains(parts.VolatileContext, "Alice") {
197  		t.Error("VolatileContext should not contain sticky context")
198  	}
199  	if !strings.Contains(parts.StableContext, "Customer: Alice. Order #8891.") {
200  		t.Error("StableContext should contain sticky facts")
201  	}
202  }
203  
204  func TestBuildSystemPrompt_EmptyStableContext(t *testing.T) {
205  	// Neither instructions nor sticky facts → StableContext falls back to a
206  	// stable placeholder so assembleUserMessage still emits the cache_break
207  	// marker and the gateway attaches its third cache_control breakpoint.
208  	parts := BuildSystemPrompt(PromptOptions{
209  		BasePrompt: "Base.",
210  	})
211  
212  	if parts.StableContext == "" {
213  		t.Fatal("StableContext should fall back to a non-empty placeholder to preserve the third cache breakpoint")
214  	}
215  	if !strings.Contains(parts.StableContext, "Active agent context.") {
216  		t.Errorf("StableContext should contain the session placeholder, got: %q", parts.StableContext)
217  	}
218  }
219  
220  func TestBuildSystemPrompt_SystemContainsToolNames(t *testing.T) {
221  	parts := BuildSystemPrompt(PromptOptions{
222  		BasePrompt: "Base.",
223  		ToolNames:  []string{"file_read", "bash"},
224  	})
225  
226  	if !strings.Contains(parts.System, "file_read") {
227  		t.Error("System should contain tool names")
228  	}
229  }
230  
231  func TestBuildSystemPrompt_SystemContainsServerToolNames(t *testing.T) {
232  	parts := BuildSystemPrompt(PromptOptions{
233  		BasePrompt:  "Base.",
234  		ServerTools: []string{"web_search"},
235  	})
236  
237  	if !strings.Contains(parts.System, "web_search") {
238  		t.Error("System should contain server tool names")
239  	}
240  }
241  
242  func TestBuildSystemPrompt_SystemContainsSkills(t *testing.T) {
243  	parts := BuildSystemPrompt(PromptOptions{
244  		BasePrompt: "Base.",
245  		Skills: []*skills.Skill{
246  			{Name: "pdf", Description: "Extract text from PDFs"},
247  		},
248  	})
249  
250  	if strings.Contains(parts.System, "## Available Skills") {
251  		t.Error("system prompt should not contain skill listing (moved to user message)")
252  	}
253  }
254  
255  func TestBuildSystemPrompt_SystemContainsMemoryPersistenceGuidance(t *testing.T) {
256  	parts := BuildSystemPrompt(PromptOptions{
257  		BasePrompt: "Base.",
258  		MemoryDir:  "/home/user/.shannon/agents/test/",
259  	})
260  
261  	if !strings.Contains(parts.System, "## Memory Persistence") {
262  		t.Error("System should contain memory persistence guidance")
263  	}
264  }
265  
266  func TestBuildSystemPrompt_MinimalOptions(t *testing.T) {
267  	parts := BuildSystemPrompt(PromptOptions{
268  		BasePrompt: "Base only.",
269  	})
270  
271  	if !strings.HasPrefix(parts.System, "Base only.") {
272  		t.Errorf("System should start with base prompt")
273  	}
274  	if strings.Contains(parts.System, "## Memory") {
275  		t.Error("System should not have Memory section")
276  	}
277  }
278  
279  func TestBuildSystemPrompt_MemoryTruncation(t *testing.T) {
280  	bigMemory := strings.Repeat("m", maxMemoryChars+500)
281  	parts := BuildSystemPrompt(PromptOptions{
282  		BasePrompt: "Base.",
283  		Memory:     bigMemory,
284  	})
285  
286  	if !strings.Contains(parts.VolatileContext, "[truncated]") {
287  		t.Error("expected truncation marker in volatile context memory")
288  	}
289  }
290  
291  func TestBuildSystemPrompt_InstructionsTruncation(t *testing.T) {
292  	bigInstructions := strings.Repeat("i", maxInstructionsChars+1000)
293  	parts := BuildSystemPrompt(PromptOptions{
294  		BasePrompt:   "Base.",
295  		Instructions: bigInstructions,
296  	})
297  
298  	if !strings.Contains(parts.StableContext, "[truncated]") {
299  		t.Error("expected truncation marker in stable context instructions")
300  	}
301  }
302  
303  func TestBuildSystemPrompt_DeferredToolsInStaticSystem(t *testing.T) {
304  	parts := BuildSystemPrompt(PromptOptions{
305  		BasePrompt: "Base.",
306  		ToolNames:  []string{"bash", "file_read", "tool_search"},
307  		DeferredTools: []DeferredToolSummary{
308  			{Name: "playwright_click", Description: "Click an element"},
309  			{Name: "playwright_type", Description: "Type text"},
310  		},
311  	})
312  
313  	if !strings.Contains(parts.System, "## Deferred Tools") {
314  		t.Error("System should contain Deferred Tools section")
315  	}
316  	if !strings.Contains(parts.System, "playwright_click: Click an element") {
317  		t.Error("System should list deferred tool summaries")
318  	}
319  	if !strings.Contains(parts.System, "tool_search") {
320  		t.Error("System should mention tool_search in available tools")
321  	}
322  }
323  
324  func TestBuildSystemPrompt_NoDeferredSection_WhenEmpty(t *testing.T) {
325  	parts := BuildSystemPrompt(PromptOptions{
326  		BasePrompt: "Base.",
327  		ToolNames:  []string{"bash", "file_read"},
328  	})
329  
330  	if strings.Contains(parts.System, "Deferred Tools") {
331  		t.Error("System should not contain Deferred Tools section when empty")
332  	}
333  }
334  
335  func TestBuildSystemPrompt_OutputFormatDefault(t *testing.T) {
336  	// Empty OutputFormat defaults to markdown (GFM)
337  	parts := BuildSystemPrompt(PromptOptions{BasePrompt: "Base."})
338  	if !strings.Contains(parts.VolatileContext, "GitHub-flavored markdown") {
339  		t.Error("default OutputFormat should produce GFM guidance in volatile context")
340  	}
341  	if strings.Contains(parts.System, "GitHub-flavored markdown") {
342  		t.Error("formatting guidance should NOT be in static System (moved to volatile)")
343  	}
344  }
345  
346  func TestBuildSystemPrompt_OutputFormatMarkdown(t *testing.T) {
347  	parts := BuildSystemPrompt(PromptOptions{BasePrompt: "Base.", OutputFormat: "markdown"})
348  	if !strings.Contains(parts.VolatileContext, "GitHub-flavored markdown") {
349  		t.Error("markdown format should produce GFM guidance")
350  	}
351  }
352  
353  func TestBuildSystemPrompt_OutputFormatPlain(t *testing.T) {
354  	parts := BuildSystemPrompt(PromptOptions{BasePrompt: "Base.", OutputFormat: "plain"})
355  	if !strings.Contains(parts.VolatileContext, "plain text") {
356  		t.Error("plain format should produce plain text guidance")
357  	}
358  	if strings.Contains(parts.VolatileContext, "GitHub-flavored") {
359  		t.Error("plain format should NOT contain GFM guidance")
360  	}
361  }
362  
363  func TestBuildSystemPrompt_SkillsListCompact(t *testing.T) {
364  	opts := PromptOptions{
365  		BasePrompt: "You are Shannon.",
366  		Skills: []*skills.Skill{
367  			{Name: "skill-a", Description: strings.Repeat("long description words ", 20)},
368  			{Name: "skill-b", Description: "short desc"},
369  		},
370  	}
371  	p := BuildSystemPrompt(opts)
372  	// Skills must NOT appear in system prompt — they are injected as a user message instead.
373  	if strings.Contains(p.System, "## Available Skills") {
374  		t.Error("system prompt should not contain skill listing (moved to user message)")
375  	}
376  	for _, s := range opts.Skills {
377  		if strings.Contains(p.System, s.Name) {
378  			t.Fatalf("skill %s should not appear in system prompt", s.Name)
379  		}
380  	}
381  }
382  
383  func TestTruncate(t *testing.T) {
384  	tests := []struct {
385  		name     string
386  		input    string
387  		max      int
388  		expected string
389  	}{
390  		{"under limit", "hello", 10, "hello"},
391  		{"at limit", "hello", 5, "hello"},
392  		{"over limit", "hello world", 5, "hello\n[truncated]"},
393  		{"empty", "", 10, ""},
394  	}
395  
396  	for _, tt := range tests {
397  		t.Run(tt.name, func(t *testing.T) {
398  			got := truncate(tt.input, tt.max)
399  			if got != tt.expected {
400  				t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.expected)
401  			}
402  		})
403  	}
404  }
405  
406  func TestMacOSAutomationGuidance_NoStrandedHeader(t *testing.T) {
407  	if runtime.GOOS != "darwin" {
408  		t.Skip("darwin-only guidance")
409  	}
410  	// computer present but none of the bullet-emitting conditions match
411  	// → no stranded "## macOS Automation\n" header
412  	tests := []struct {
413  		name  string
414  		tools []string
415  	}{
416  		{"only-computer", []string{"computer"}},
417  		{"computer-and-wait_for", []string{"computer", "wait_for"}},
418  	}
419  	for _, tc := range tests {
420  		t.Run(tc.name, func(t *testing.T) {
421  			out := macOSAutomationGuidance(tc.tools)
422  			// "only-computer" currently produces zero bullets → must return ""
423  			// "computer-and-wait_for" produces wait_for bullet → must include it
424  			if tc.name == "only-computer" && out != "" {
425  				t.Fatalf("expected empty string for tools=%v, got %q", tc.tools, out)
426  			}
427  			if tc.name == "computer-and-wait_for" {
428  				if !strings.Contains(out, "## macOS Automation") {
429  					t.Fatalf("expected section header for tools=%v, got %q", tc.tools, out)
430  				}
431  				if !strings.Contains(out, "wait_for") {
432  					t.Fatalf("expected wait_for bullet for tools=%v, got %q", tc.tools, out)
433  				}
434  			}
435  		})
436  	}
437  }
438  
439  func TestMacOSAutomationGuidance_AccessibilityOnly(t *testing.T) {
440  	if runtime.GOOS != "darwin" {
441  		t.Skip("darwin-only guidance")
442  	}
443  	out := macOSAutomationGuidance([]string{"accessibility"})
444  	if !strings.Contains(out, "## macOS Automation") {
445  		t.Fatalf("expected header, got %q", out)
446  	}
447  	if !strings.Contains(out, "accessibility") {
448  		t.Fatalf("expected accessibility bullet, got %q", out)
449  	}
450  	// Should NOT include the AX fallback bullet (requires both accessibility+computer)
451  	if strings.Contains(out, "Fall back to `computer`") {
452  		t.Fatalf("unexpected fallback bullet when only accessibility present: %q", out)
453  	}
454  }
455  
456  func TestBuildSystemPrompt_DeferredToolsTruncated(t *testing.T) {
457  	longDesc := strings.Repeat("abcdefghij", 20) // 200 chars
458  	opts := PromptOptions{
459  		BasePrompt: "You are Shannon.",
460  		DeferredTools: []DeferredToolSummary{
461  			{Name: "long-tool", Description: longDesc},
462  		},
463  	}
464  	p := BuildSystemPrompt(opts)
465  	// Find the bullet line for long-tool and assert it's truncated
466  	found := false
467  	for _, l := range strings.Split(p.System, "\n") {
468  		if strings.HasPrefix(l, "- long-tool:") {
469  			found = true
470  			// Line = "- long-tool: " (13 chars) + up to 60 chars desc
471  			// So total <= 75 including trailing newline excluded.
472  			if len(l) > 100 {
473  				t.Fatalf("deferred tool line too long (%d chars), truncation regressed: %q", len(l), l)
474  			}
475  			if !strings.HasSuffix(l, "...") {
476  				t.Fatalf("expected truncation marker '...' on long desc, got: %q", l)
477  			}
478  		}
479  	}
480  	if !found {
481  		t.Fatalf("deferred tool bullet not found in system prompt")
482  	}
483  }