Files
ds2api/internal/toolcall/tool_prompt_test.go
waiwai 1e00e482a6 fix(toolcall): eliminate strings.ToLower panics from Unicode case folding
Replace all strings.ToLower usage with ASCII case-insensitive matching
(hasASCIIPrefixFoldAt, indexASCIIFold, hasDSMLPrefix) to prevent slice
bounds errors when Unicode characters change byte length after case
folding (e.g., Turkish İ U+0130 → i + combining dot: 2 bytes → 3 bytes).

Root cause: code created a strings.ToLower(text) copy, found byte
positions in that copy, then used those positions to slice the
original text — byte offsets that were valid in the lowercased copy
became out-of-bounds in the original when case folding changed byte
lengths.

Files changed:
- toolcalls_scan.go: remove 5 lower usages, add hasDSMLPrefix
- toolcalls_parse_markup.go: remove 3 lower usages, add indexASCIIFold
- toolcalls_markup.go: SanitizeLooseCDATA lower removal
- toolcalls_parse.go: updateCDATAStateForStrip lower removal
- tool_prompt.go: align DSML pipe characters with tool call spec
- tool_prompt_test.go: fix pre-existing test character mismatch
2026-05-09 15:05:51 +08:00

141 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package toolcall
import (
"strings"
"testing"
)
func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"exec_command"})
if !strings.Contains(out, `<|DSML|invoke name="exec_command">`) {
t.Fatalf("expected exec_command in examples, got: %s", out)
}
if !strings.Contains(out, `<|DSML|parameter name="cmd"><![CDATA[pwd]]></|DSML|parameter>`) {
t.Fatalf("expected cmd parameter example for exec_command, got: %s", out)
}
}
func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"execute_command"})
if !strings.Contains(out, `<|DSML|invoke name="execute_command">`) {
t.Fatalf("expected execute_command in examples, got: %s", out)
}
if !strings.Contains(out, `<|DSML|parameter name="command"><![CDATA[pwd]]></|DSML|parameter>`) {
t.Fatalf("expected command parameter example for execute_command, got: %s", out)
}
}
func TestBuildToolCallInstructions_BashUsesCommandAndDescriptionExamples(t *testing.T) {
out := BuildToolCallInstructions([]string{"Bash"})
blocks := findInvokeBlocks(out, "Bash")
if len(blocks) == 0 {
t.Fatalf("expected Bash examples, got: %s", out)
}
sawDescription := false
for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="command">`) {
t.Fatalf("expected every Bash example to use command parameter, got: %s", block)
}
if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block)
}
if strings.Contains(block, `<|DSML|parameter name="description">`) {
sawDescription = true
}
}
if !sawDescription {
t.Fatalf("expected Bash long-script example to include description, got: %s", out)
}
if strings.Contains(out, `<|DSML|invoke name="Read">`) {
t.Fatalf("expected examples to avoid unavailable hard-coded Read tool, got: %s", out)
}
}
func TestBuildToolCallInstructions_ExecuteCommandLongScriptUsesCommand(t *testing.T) {
out := BuildToolCallInstructions([]string{"execute_command"})
blocks := findInvokeBlocks(out, "execute_command")
if len(blocks) == 0 {
t.Fatalf("expected execute_command examples, got: %s", out)
}
for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="command">`) {
t.Fatalf("expected execute_command examples to use command parameter, got: %s", block)
}
if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected execute_command examples not to use file write parameters, got: %s", block)
}
}
if !strings.Contains(out, `test_escape.sh`) {
t.Fatalf("expected execute_command long-script example, got: %s", out)
}
}
func TestBuildToolCallInstructions_ExecCommandLongScriptUsesCmd(t *testing.T) {
out := BuildToolCallInstructions([]string{"exec_command"})
blocks := findInvokeBlocks(out, "exec_command")
if len(blocks) == 0 {
t.Fatalf("expected exec_command examples, got: %s", out)
}
for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="cmd">`) {
t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block)
}
if strings.Contains(block, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected exec_command examples not to use command or file write parameters, got: %s", block)
}
}
if !strings.Contains(out, `test_escape.sh`) {
t.Fatalf("expected exec_command long-script example, got: %s", out)
}
}
func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) {
out := BuildToolCallInstructions([]string{"Write"})
blocks := findInvokeBlocks(out, "Write")
if len(blocks) == 0 {
t.Fatalf("expected Write examples, got: %s", out)
}
for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected Write examples to use file_path and content, got: %s", block)
}
if strings.Contains(block, `<|DSML|parameter name="path">`) {
t.Fatalf("expected Write examples not to use path, got: %s", block)
}
}
}
func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *testing.T) {
out := BuildToolCallInstructions([]string{"read_file"})
if !strings.Contains(out, "Never omit the opening <DSMLtool_calls> tag") {
t.Fatalf("expected explicit missing-opening-tag warning, got: %s", out)
}
if !strings.Contains(out, "Wrong 3 — missing opening wrapper") {
t.Fatalf("expected missing-opening-wrapper negative example, got: %s", out)
}
}
func findInvokeBlocks(text, name string) []string {
open := `<|DSML|invoke name="` + name + `">`
remaining := text
blocks := []string{}
for {
start := strings.Index(remaining, open)
if start < 0 {
return blocks
}
remaining = remaining[start:]
end := strings.Index(remaining, `</|DSML|invoke>`)
if end < 0 {
return blocks
}
end += len(`</|DSML|invoke>`)
blocks = append(blocks, remaining[:end])
remaining = remaining[end:]
}
}