mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-20 16:07:47 +08:00
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
186 lines
5.7 KiB
Go
186 lines
5.7 KiB
Go
package promptcompat
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *testing.T) {
|
||
messages := []any{
|
||
map[string]any{"role": "user", "content": "查北京天气"},
|
||
map[string]any{
|
||
"role": "assistant",
|
||
"tool_calls": []any{
|
||
map[string]any{
|
||
"id": "call_1",
|
||
"function": map[string]any{
|
||
"name": "get_weather",
|
||
"arguments": "{\"city\":\"beijing\"}",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
map[string]any{
|
||
"role": "tool",
|
||
"tool_call_id": "call_1",
|
||
"name": "get_weather",
|
||
"content": map[string]any{"temp": 18, "condition": "sunny"},
|
||
},
|
||
}
|
||
tools := []any{
|
||
map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": "get_weather",
|
||
"description": "Get weather",
|
||
"parameters": map[string]any{
|
||
"type": "object",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
finalPrompt, toolNames := buildOpenAIFinalPrompt(messages, tools, "", false)
|
||
if len(toolNames) != 1 || toolNames[0] != "get_weather" {
|
||
t.Fatalf("unexpected tool names: %#v", toolNames)
|
||
}
|
||
if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
|
||
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
|
||
}
|
||
if !strings.Contains(finalPrompt, "<|DSML|tool_calls>") {
|
||
t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt)
|
||
}
|
||
if !strings.Contains(finalPrompt, `<|DSML|invoke name="get_weather">`) {
|
||
t.Fatalf("handler finalPrompt should include tool name history: %q", finalPrompt)
|
||
}
|
||
}
|
||
|
||
func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *testing.T) {
|
||
messages := []any{
|
||
map[string]any{"role": "system", "content": "You are helpful"},
|
||
map[string]any{"role": "user", "content": "请调用工具"},
|
||
}
|
||
tools := []any{
|
||
map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": "search",
|
||
"description": "search docs",
|
||
"parameters": map[string]any{
|
||
"type": "object",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
|
||
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>...<|/DSML|tool_calls> block at the end of your response.") {
|
||
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
|
||
}
|
||
if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") {
|
||
t.Fatalf("vercel prepare finalPrompt missing xml format instruction: %q", finalPrompt)
|
||
}
|
||
if !strings.Contains(finalPrompt, "Do NOT wrap XML in markdown fences") {
|
||
t.Fatalf("vercel prepare finalPrompt missing no-fence xml instruction: %q", finalPrompt)
|
||
}
|
||
if strings.Contains(finalPrompt, "```json") {
|
||
t.Fatalf("vercel prepare finalPrompt should not require fenced tool calls: %q", finalPrompt)
|
||
}
|
||
}
|
||
|
||
func TestBuildOpenAIFinalPromptPrependsOutputIntegrityGuard(t *testing.T) {
|
||
messages := []any{
|
||
map[string]any{"role": "system", "content": "You are helpful"},
|
||
map[string]any{"role": "user", "content": "请调用工具"},
|
||
}
|
||
tools := []any{
|
||
map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": "search",
|
||
"description": "search docs",
|
||
"parameters": map[string]any{
|
||
"type": "object",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
|
||
guardIdx := strings.Index(finalPrompt, "Output integrity guard")
|
||
toolIdx := strings.Index(finalPrompt, "TOOL CALL FORMAT")
|
||
if guardIdx < 0 {
|
||
t.Fatalf("expected output integrity guard in final prompt, got: %q", finalPrompt)
|
||
}
|
||
if toolIdx < 0 {
|
||
t.Fatalf("expected tool instructions in final prompt, got: %q", finalPrompt)
|
||
}
|
||
if guardIdx > toolIdx {
|
||
t.Fatalf("expected output integrity guard to precede tool instructions, got: %q", finalPrompt)
|
||
}
|
||
}
|
||
|
||
func TestBuildOpenAIFinalPromptReadLikeToolIncludesCacheGuard(t *testing.T) {
|
||
messages := []any{
|
||
map[string]any{"role": "user", "content": "请读取文件"},
|
||
}
|
||
tools := []any{
|
||
map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": "read_file",
|
||
"description": "Read a file",
|
||
"parameters": map[string]any{
|
||
"type": "object",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
|
||
if !strings.Contains(finalPrompt, "Read-tool cache guard") {
|
||
t.Fatalf("read-like tool prompt missing cache guard: %q", finalPrompt)
|
||
}
|
||
if !strings.Contains(finalPrompt, "provides no file body") {
|
||
t.Fatalf("read-like tool prompt missing no-body handling: %q", finalPrompt)
|
||
}
|
||
if !strings.Contains(finalPrompt, "Do not repeatedly call the same read request") {
|
||
t.Fatalf("read-like tool prompt missing loop guard: %q", finalPrompt)
|
||
}
|
||
}
|
||
|
||
func TestBuildOpenAIFinalPromptNonReadToolOmitsCacheGuard(t *testing.T) {
|
||
messages := []any{
|
||
map[string]any{"role": "user", "content": "搜索一下"},
|
||
}
|
||
tools := []any{
|
||
map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": "search",
|
||
"description": "Search docs",
|
||
"parameters": map[string]any{
|
||
"type": "object",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
|
||
if strings.Contains(finalPrompt, "Read-tool cache guard") {
|
||
t.Fatalf("non-read tool prompt should not include read cache guard: %q", finalPrompt)
|
||
}
|
||
}
|
||
|
||
func TestBuildOpenAIFinalPromptWithThinkingKeepsPromptUnchanged(t *testing.T) {
|
||
messages := []any{
|
||
map[string]any{"role": "user", "content": "继续回答上一个问题"},
|
||
}
|
||
|
||
finalPromptThinking, _ := buildOpenAIFinalPrompt(messages, nil, "", true)
|
||
finalPromptPlain, _ := buildOpenAIFinalPrompt(messages, nil, "", false)
|
||
if finalPromptThinking != finalPromptPlain {
|
||
t.Fatalf("expected thinking flag not to prepend continuation contract, thinking=%q plain=%q", finalPromptThinking, finalPromptPlain)
|
||
}
|
||
}
|