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 }