package toolcall import ( "strings" "testing" ) func TestFormatOpenAIToolCalls(t *testing.T) { formatted := FormatOpenAIToolCalls([]ParsedToolCall{{Name: "search", Input: map[string]any{"q": "x"}}}) if len(formatted) != 1 { t.Fatalf("expected 1, got %d", len(formatted)) } fn, _ := formatted[0]["function"].(map[string]any) if fn["name"] != "search" { t.Fatalf("unexpected function name: %#v", fn) } } func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) { text := `Bashpwdshow cwd` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsMultilineCDATAAndRepeatedXMLTags(t *testing.T) { text := `write_filescript.shfirstsecond` calls := ParseToolCalls(text, []string{"write_file"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "write_file" { t.Fatalf("expected tool name write_file, got %q", calls[0].Name) } if calls[0].Input["path"] != "script.sh" { t.Fatalf("expected path argument, got %#v", calls[0].Input) } content, _ := calls[0].Input["content"].(string) if !strings.Contains(content, "#!/bin/bash") || !strings.Contains(content, "echo \"hello\"") { t.Fatalf("expected multiline CDATA content to be preserved, got %#v", calls[0].Input["content"]) } items, ok := calls[0].Input["item"].([]any) if !ok || len(items) != 2 { t.Fatalf("expected repeated XML tags to become an array, got %#v", calls[0].Input["item"]) } } func TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) { text := `get_weather{"city":"beijing","unit":"c"}` calls := ParseToolCalls(text, []string{"get_weather"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "get_weather" { t.Fatalf("expected tool name get_weather, got %q", calls[0].Name) } if calls[0].Input["city"] != "beijing" || calls[0].Input["unit"] != "c" { t.Fatalf("expected parsed json parameters, got %#v", calls[0].Input) } } func TestParseToolCallsPreservesRawMalformedXMLParameters(t *testing.T) { text := `execute_commandcd /root && git status` calls := ParseToolCalls(text, []string{"execute_command"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "execute_command" { t.Fatalf("expected tool name execute_command, got %q", calls[0].Name) } raw, ok := calls[0].Input["_raw"].(string) if !ok { t.Fatalf("expected raw argument tracking, got %#v", calls[0].Input) } if raw != "cd /root && git status" { t.Fatalf("expected raw arguments to be preserved, got %q", raw) } } func TestParseToolCallsSupportsXMLParametersJSONWithAmpersandCommand(t *testing.T) { text := `execute_command{"command":"sshpass -p 'xxx' ssh -o StrictHostKeyChecking=no -p 1111 root@111.111.111.111 'cd /root && git clone https://github.com/ericc-ch/copilot-api.git'","cwd":null,"timeout":null}` calls := ParseToolCalls(text, []string{"execute_command"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "execute_command" { t.Fatalf("expected tool name execute_command, got %q", calls[0].Name) } cmd, _ := calls[0].Input["command"].(string) if !strings.Contains(cmd, "&& git clone") { t.Fatalf("expected command to keep && segment, got %#v", calls[0].Input) } } func TestParseToolCallsDoesNotTreatParameterNameTagAsToolName(t *testing.T) { text := `file.txtpwd` calls := ParseToolCalls(text, []string{"execute_command"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "execute_command" { t.Fatalf("expected tool name execute_command, got %q", calls[0].Name) } if calls[0].Input["name"] != "file.txt" { t.Fatalf("expected parameter name preserved, got %#v", calls[0].Input) } } func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) { text := `Bashpwd` res := ParseToolCallsDetailed(text, []string{"bash"}) if !res.SawToolCallSyntax { t.Fatalf("expected SawToolCallSyntax=true, got %#v", res) } if len(res.Calls) != 1 { t.Fatalf("expected one parsed call, got %#v", res) } } func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) { text := `{"tool":"Bash","params":{"command":"pwd","description":"show cwd"}}` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) { text := `Bashls -lalist` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "ls -la" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) { text := `{"command":"pwd","description":"x"}` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) { text := `pwdx` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) { text := `pwdd` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) { text := `test` calls := ParseToolCalls(text, []string{"search_web"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "search_web" { t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name) } if calls[0].Input["query"] != "test" { t.Fatalf("expected query argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsToolUseNameParametersStyle(t *testing.T) { text := `write_file{"path":"/tmp/a.txt","content":"abc"}` calls := ParseToolCalls(text, []string{"write_file"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "write_file" { t.Fatalf("expected tool name write_file, got %q", calls[0].Name) } if calls[0].Input["path"] != "/tmp/a.txt" { t.Fatalf("expected path argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsToolUseFunctionNameParametersStyle(t *testing.T) { text := `write_file{"path":"/tmp/b.txt","content":"xyz"}` calls := ParseToolCalls(text, []string{"write_file"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "write_file" { t.Fatalf("expected tool name write_file, got %q", calls[0].Name) } if calls[0].Input["content"] != "xyz" { t.Fatalf("expected content argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsToolUseToolNameBodyStyle(t *testing.T) { text := `write_file/tmp/c.txthello` calls := ParseToolCalls(text, []string{"write_file"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "write_file" { t.Fatalf("expected tool name write_file, got %q", calls[0].Name) } if calls[0].Input["path"] != "/tmp/c.txt" { t.Fatalf("expected path argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsNestedToolTagStyle(t *testing.T) { text := `pwdshow cwd` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testing.T) { text := `{"command":"pwd"}` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) { text := `{"command":"pwd"}{"file_path":"README.md"}` calls := ParseToolCalls(text, []string{"bash", "read"}) if len(calls) != 2 { t.Fatalf("expected 2 calls, got %#v", calls) } if calls[0].Name != "Bash" || calls[1].Name != "Read" { t.Fatalf("expected original names [Bash Read], got %#v", calls) } } func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) { text := `read_file{"path":"README.md"}` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { t.Fatalf("expected mismatched tags to be rejected, got %#v", calls) } } func TestParseToolCallsDoesNotTreatParametersFunctionNameAsToolName(t *testing.T) { text := `data_onlyREADME.md` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { t.Fatalf("expected no tool call when function_name appears only under parameters, got %#v", calls) } } func TestRepairInvalidJSONBackslashes(t *testing.T) { tests := []struct { input string expected string }{ {`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\name"}`}, {`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`}, {`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`}, {`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`}, {`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`}, {`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`}, } for _, tt := range tests { got := repairInvalidJSONBackslashes(tt.input) if got != tt.expected { t.Errorf("repairInvalidJSONBackslashes(%s) = %s; want %s", tt.input, got, tt.expected) } } } func TestRepairLooseJSON(t *testing.T) { tests := []struct { input string expected string }{ {`{tool_calls: [{"name": "search", "input": {"q": "go"}}]}`, `{"tool_calls": [{"name": "search", "input": {"q": "go"}}]}`}, {`{name: "search", input: {q: "go"}}`, `{"name": "search", "input": {"q": "go"}}`}, } for _, tt := range tests { got := RepairLooseJSON(tt.input) if got != tt.expected { t.Errorf("RepairLooseJSON(%s) = %s; want %s", tt.input, got, tt.expected) } } } func TestParseToolCallInputRepairsControlCharsInPath(t *testing.T) { in := `{"path":"D:\tmp\new\readme.txt","content":"line1\nline2"}` parsed := parseToolCallInput(in) path, ok := parsed["path"].(string) if !ok { t.Fatalf("expected path string in parsed input, got %#v", parsed["path"]) } if path != `D:\tmp\new\readme.txt` { t.Fatalf("expected repaired windows path, got %q", path) } content, ok := parsed["content"].(string) if !ok { t.Fatalf("expected content string in parsed input, got %#v", parsed["content"]) } if content != "line1\nline2" { t.Fatalf("expected non-path field to keep decoded escapes, got %q", content) } } func TestRepairLooseJSONWithNestedObjects(t *testing.T) { // 测试嵌套对象的修复:DeepSeek 幻觉输出,每个元素内部包含嵌套 {} // 注意:正则只支持单层嵌套,不支持更深层次的嵌套 tests := []struct { name string input string expected string }{ // 1. 单层嵌套对象(核心修复目标) { name: "单层嵌套 - 2个元素", input: `"todos": {"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}`, expected: `"todos": [{"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}]`, }, // 2. 3个单层嵌套对象 { name: "3个单层嵌套对象", input: `"items": {"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}`, expected: `"items": [{"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}]`, }, // 3. 混合嵌套:有些字段是对象,有些是原始值 { name: "混合嵌套 - 对象和原始值混合", input: `"items": {"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}`, expected: `"items": [{"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}]`, }, // 4. 4个嵌套对象(边界测试) { name: "4个嵌套对象", input: `"todos": {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}`, expected: `"todos": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]`, }, // 5. DeepSeek 典型幻觉:无空格逗号分隔 { name: "无空格逗号分隔", input: `"results": {"name": "a"}, {"name": "b"}, {"name": "c"}`, expected: `"results": [{"name": "a"}, {"name": "b"}, {"name": "c"}]`, }, // 6. 嵌套数组(数组在对象内,不是深层嵌套) { name: "对象内包含数组", input: `"data": {"items": [1,2,3]}, {"items": [4,5,6]}`, expected: `"data": [{"items": [1,2,3]}, {"items": [4,5,6]}]`, }, // 7. 真实的 DeepSeek 8皇后问题输出 { name: "DeepSeek 8皇后真实输出", input: `"todos": {"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}`, expected: `"todos": [{"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}]`, }, // 8. 简单无嵌套对象(回归测试) { name: "简单无嵌套对象", input: `"items": {"a": 1}, {"b": 2}`, expected: `"items": [{"a": 1}, {"b": 2}]`, }, // 9. 更复杂的单层嵌套 { name: "复杂单层嵌套", input: `"functions": {"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}`, expected: `"functions": [{"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}]`, }, // 10. 5个嵌套对象 { name: "5个嵌套对象", input: `"tasks": {"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}`, expected: `"tasks": [{"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}]`, }, } for _, tt := range tests { got := RepairLooseJSON(tt.input) if got != tt.expected { t.Errorf("[%s] RepairLooseJSON with nested objects:\n input: %s\n got: %s\n expected: %s", tt.name, tt.input, got, tt.expected) } } } func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) { text := `Bash{"command":"echo a > out.txt"}` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected one call, got %#v", calls) } cmd, _ := calls[0].Input["command"].(string) if cmd != "echo a > out.txt" { t.Fatalf("expected html entities to be unescaped in command, got %q", cmd) } } func TestParseToolCallsIgnoresXMLInsideFencedCodeBlock(t *testing.T) { text := "Here is an example:\n```xml\nread_file{\"path\":\"README.md\"}\n```\nDo not execute it." res := ParseToolCallsDetailed(text, []string{"read_file"}) if len(res.Calls) != 0 { t.Fatalf("expected no parsed calls for fenced example, got %#v", res.Calls) } } func TestParseToolCallsParsesOnlyNonFencedXMLToolCall(t *testing.T) { text := "```xml\nread_file{\"path\":\"README.md\"}\n```\nsearch{\"q\":\"golang\"}" res := ParseToolCallsDetailed(text, []string{"read_file", "search"}) if len(res.Calls) != 1 { t.Fatalf("expected exactly one parsed call outside fence, got %#v", res.Calls) } if res.Calls[0].Name != "search" { t.Fatalf("expected non-fenced tool call to be parsed, got %#v", res.Calls[0]) } } func TestParseToolCallsParsesAfterFourBacktickFence(t *testing.T) { text := "````markdown\n```xml\nread_file{\"path\":\"README.md\"}\n```\n````\nsearch{\"q\":\"outside\"}" res := ParseToolCallsDetailed(text, []string{"read_file", "search"}) if len(res.Calls) != 1 { t.Fatalf("expected exactly one parsed call outside four-backtick fence, got %#v", res.Calls) } if res.Calls[0].Name != "search" { t.Fatalf("expected non-fenced tool call to be parsed, got %#v", res.Calls[0]) } }