package toolcall import ( "strings" "testing" ) func TestFormatOpenAIToolCalls(t *testing.T) { formatted := FormatOpenAIToolCalls([]ParsedToolCall{{Name: "search", Input: map[string]any{"q": "x"}}}, nil) 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 TestParseToolCallsSupportsToolCallsWrapper(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 TestParseToolCallsSupportsDSMLShell(t *testing.T) { text := `<|DSML|tool_calls><|DSML|invoke name="Bash"><|DSML|parameter name="command">` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 1 { t.Fatalf("expected 1 DSML call, got %#v", calls) } if calls[0].Name != "Bash" || calls[0].Input["command"] != "pwd" { t.Fatalf("unexpected DSML parse result: %#v", calls[0]) } } func TestParseToolCallsSupportsHyphenatedDSMLShellWithHereDocCDATA(t *testing.T) { text := ` ` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 1 { t.Fatalf("expected 1 hyphenated DSML call, got %#v", calls) } if calls[0].Name != "Bash" { t.Fatalf("expected Bash tool, got %#v", calls[0]) } command, _ := calls[0].Input["command"].(string) if !strings.Contains(command, `git commit -m "$(cat <<'EOF'`) || !strings.Contains(command, "Co-Authored-By: Claude Opus 4.7") { t.Fatalf("expected here-doc CDATA command to be preserved, got %q", command) } if calls[0].Input["description"] != "Create commit with architecture doc updates" { t.Fatalf("expected description parameter, got %#v", calls[0].Input) } } func TestParseToolCallsIgnoresBareHyphenatedToolCallsLookalike(t *testing.T) { text := `pwd` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 0 { t.Fatalf("expected bare hyphenated lookalike to be ignored, got %#v", calls) } } func TestParseToolCallsToleratesDSMLTrailingPipeTagTerminator(t *testing.T) { text := strings.Join([]string{ `<|DSML|tool_calls| `, ` <|DSML|invoke name="terminal">`, ` <|DSML|parameter name="command">`, ` <|DSML|parameter name="timeout">`, ` `, ``, }, "\n") calls := ParseToolCalls(text, []string{"terminal"}) if len(calls) != 1 { t.Fatalf("expected one trailing-pipe DSML call, got %#v", calls) } if calls[0].Name != "terminal" { t.Fatalf("expected terminal tool, got %#v", calls[0]) } if calls[0].Input["command"] != `find "/home" -type d` { t.Fatalf("expected command argument, got %#v", calls[0].Input) } if calls[0].Input["timeout"] != float64(10) { t.Fatalf("expected numeric timeout, got %#v", calls[0].Input) } } func TestParseToolCallsToleratesExtraLeadingLessThanBeforeDSML(t *testing.T) { text := `<<|DSML|tool_calls><<|DSML|invoke name="Bash"><<|DSML|parameter name="command">` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 1 { t.Fatalf("expected one extra-leading-less-than DSML call, got %#v", calls) } if calls[0].Name != "Bash" || calls[0].Input["command"] != "pwd" { t.Fatalf("unexpected extra-leading-less-than DSML parse result: %#v", calls[0]) } } func TestParseToolCallsToleratesRepeatedDSMLPrefixNoise(t *testing.T) { text := `<<<` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 1 { t.Fatalf("expected one repeated-prefix DSML call, got %#v", calls) } if calls[0].Name != "Bash" || calls[0].Input["command"] != "git status" { t.Fatalf("unexpected repeated-prefix DSML parse result: %#v", calls[0]) } } func TestParseToolCallsSupportsDSMLShellWithCanonicalExampleInCDATA(t *testing.T) { content := `x` text := `<|DSML|tool_calls><|DSML|invoke name="Write"><|DSML|parameter name="file_path">notes.md<|DSML|parameter name="content">` calls := ParseToolCalls(text, []string{"Write"}) if len(calls) != 1 { t.Fatalf("expected 1 DSML call with XML-looking CDATA, got %#v", calls) } if calls[0].Name != "Write" || calls[0].Input["content"] != content { t.Fatalf("unexpected DSML CDATA parse result: %#v", calls[0]) } } func TestParseToolCallsKeepsHereDocCDATAWithFencedDSMLAndLiteralCDATAEnd(t *testing.T) { command := strings.Join([]string{ "cat > docs/project-value.md << 'ENDOFFILE'", "# DS2API project value", "", "```xml", `<|DSML|tool_calls>`, ` <|DSML|invoke name="Bash">`, ` <|DSML|parameter name="command">&1]]>`, ` `, ``, "```", "", "Only the literal `]]>` needs special handling.", "", "ENDOFFILE", `echo "Done. Lines: $(wc -l < docs/project-value.md)"`, }, "\n") text := `<|DSML|tool_calls><|DSML|invoke name="Bash"><|DSML|parameter name="command"><|DSML|parameter name="description">` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 1 { t.Fatalf("expected one DSML call with extreme heredoc CDATA, got %#v", calls) } got, _ := calls[0].Input["command"].(string) if got != command { t.Fatalf("expected full heredoc command to survive, got:\n%q\nwant:\n%q", got, command) } if calls[0].Input["description"] != "Write project value doc" { t.Fatalf("expected sibling parameter after command, got %#v", calls[0].Input) } } func TestParseToolCallsKeepsCompactCDATAWithImmediateFencedDSML(t *testing.T) { content := strings.Join([]string{ "```xml", `<|DSML|tool_calls>`, ` <|DSML|invoke name="Bash">`, ` <|DSML|parameter name="command">`, ` `, ``, "```", "tail", }, "\n") text := `` calls := ParseToolCalls(text, []string{"Write"}) if len(calls) != 1 { t.Fatalf("expected one compact CDATA call, got %#v", calls) } if calls[0].Input["content"] != content { t.Fatalf("expected compact CDATA content to survive, got %#v", calls[0].Input["content"]) } } func TestParseToolCallsPreservesSimpleCDATAInlineMarkupAsText(t *testing.T) { text := `urgent]]>` calls := ParseToolCalls(text, []string{"Write"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } got, ok := calls[0].Input["description"].(string) if !ok { t.Fatalf("expected description to remain a string, got %#v", calls[0].Input["description"]) } if got != "urgent" { t.Fatalf("expected inline markup CDATA to stay raw, got %q", got) } } func TestParseToolCallsTreatsUnclosedCDATAAsText(t *testing.T) { text := `` res := ParseToolCallsDetailed(text, []string{"Write"}) if len(res.Calls) != 1 { t.Fatalf("expected unclosed CDATA to still parse via outer wrapper, got %#v", res.Calls) } got, _ := res.Calls[0].Input["content"].(string) if got != "hello world" { t.Fatalf("expected recovered CDATA payload, got %q", got) } } func TestParseToolCallsNormalizesMixedDSMLAndCanonicalToolTags(t *testing.T) { // Models commonly mix DSML wrapper tags with canonical inner tags. // These should be normalized and parsed, not rejected. text := `<|DSML|tool_calls><|DSML|parameter name="command">pwd` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 1 { t.Fatalf("expected mixed DSML/XML tool tags to be normalized and parsed, got %#v", calls) } if calls[0].Name != "Bash" || calls[0].Input["command"] != "pwd" { t.Fatalf("unexpected mixed DSML parse result: %#v", calls[0]) } } func TestParseToolCallsSupportsStandaloneToolWithMultilineCDATAAndRepeatedXMLTags(t *testing.T) { text := `script.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 TestParseToolCallsKeepsToolSyntaxInsideCDATAAsParameterText(t *testing.T) { payload := strings.Join([]string{ "# Release notes", "", "```xml", "", " ", " x", " ", "", "```", }, "\n") text := `DS2API-4.0-Release-Notes.md` calls := ParseToolCalls(text, []string{"Write"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } content, _ := calls[0].Input["content"].(string) if content != payload { t.Fatalf("expected CDATA payload with nested tool syntax to survive intact, got %q", content) } if calls[0].Input["file_path"] != "DS2API-4.0-Release-Notes.md" { t.Fatalf("expected file_path parameter, got %#v", calls[0].Input) } } func TestParseToolCallsSupportsInvokeParameters(t *testing.T) { text := `beijingc` 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 TestParseToolCallsSupportsJSONScalarParameters(t *testing.T) { text := `123true` calls := ParseToolCalls(text, []string{"configure"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } if got, ok := calls[0].Input["count"].(float64); !ok || got != 123 { t.Fatalf("expected numeric count, got %#v", calls[0].Input["count"]) } if got, ok := calls[0].Input["max_tokens"].(float64); !ok || got != 256 { t.Fatalf("expected numeric max_tokens, got %#v", calls[0].Input["max_tokens"]) } if got, ok := calls[0].Input["enabled"].(bool); !ok || !got { t.Fatalf("expected boolean enabled, got %#v", calls[0].Input["enabled"]) } } func TestParseToolCallsTreatsItemOnlyParameterBodyAsArray(t *testing.T) { text := strings.Join([]string{ `<|DSML|tool_calls>`, `<|DSML|invoke name="AskUserQuestion">`, `<|DSML|parameter name="questions">`, ``, ``, `
`, ``, ``, ``, ``, `false`, `
`, ``, ``, ``, }, "\n") calls := ParseToolCalls(text, []string{"AskUserQuestion"}) if len(calls) != 1 { t.Fatalf("expected one AskUserQuestion call, got %#v", calls) } questions, ok := calls[0].Input["questions"].([]any) if !ok || len(questions) != 1 { t.Fatalf("expected questions to parse as array, got %#v", calls[0].Input["questions"]) } first, ok := questions[0].(map[string]any) if !ok { t.Fatalf("expected first question object, got %#v", questions[0]) } if first["question"] != "What would you like to do next?" || first["header"] != "Next step" || first["multiSelect"] != false { t.Fatalf("unexpected question payload: %#v", first) } options, ok := first["options"].([]any) if !ok || len(options) != 2 { t.Fatalf("expected options to parse as array, got %#v", first["options"]) } } func TestParseToolCallsTreatsCDATAItemOnlyBodyAsArray(t *testing.T) { todos := `

Testing EnterWorktree tool
Test EnterWorktree tool
in_progress


Testing TodoWrite tool
Test TodoWrite tool
completed

` text := `<|DSML|tool_calls><|DSML|invoke name="TodoWrite"><|DSML|parameter name="todos">` calls := ParseToolCalls(text, []string{"TodoWrite"}) if len(calls) != 1 { t.Fatalf("expected one TodoWrite call, got %#v", calls) } items, ok := calls[0].Input["todos"].([]any) if !ok || len(items) != 2 { t.Fatalf("expected todos CDATA item body to parse as array, got %#v", calls[0].Input["todos"]) } first, ok := items[0].(map[string]any) if !ok { t.Fatalf("expected first todo object, got %#v", items[0]) } if first["activeForm"] != "Testing EnterWorktree tool" || first["content"] != "Test EnterWorktree tool" || first["status"] != "in_progress" { t.Fatalf("unexpected first todo: %#v", first) } } func TestParseToolCallsTreatsSingleItemCDATAAsArray(t *testing.T) { text := `one]]>` calls := ParseToolCalls(text, []string{"TodoWrite"}) if len(calls) != 1 { t.Fatalf("expected one TodoWrite call, got %#v", calls) } items, ok := calls[0].Input["todos"].([]any) if !ok || len(items) != 1 { t.Fatalf("expected single-item CDATA body to parse as array, got %#v", calls[0].Input["todos"]) } if got, ok := items[0].(string); !ok || got != "one" { t.Fatalf("expected single item value to stay intact, got %#v", items[0]) } } func TestParseToolCallsTreatsLooseJSONListAsArray(t *testing.T) { tests := []struct { name string body string }{ { name: "plain text", body: `{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}`, }, { name: "cdata", body: ``, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { text := `` + tt.body + `` calls := ParseToolCalls(text, []string{"TodoWrite"}) if len(calls) != 1 { t.Fatalf("expected one TodoWrite call, got %#v", calls) } items, ok := calls[0].Input["todos"].([]any) if !ok || len(items) != 2 { t.Fatalf("expected loose JSON list to parse as array, got %#v", calls[0].Input["todos"]) } first, ok := items[0].(map[string]any) if !ok { t.Fatalf("expected first todo object, got %#v", items[0]) } if first["content"] != "Test TodoWrite tool" || first["status"] != "completed" { t.Fatalf("unexpected first todo: %#v", first) } }) } } func TestParseToolCallsKeepsPreservedTextParametersAsText(t *testing.T) { text := `` calls := ParseToolCalls(text, []string{"Write"}) if len(calls) != 1 { t.Fatalf("expected one Write call, got %#v", calls) } got, ok := calls[0].Input["content"].(string) if !ok { t.Fatalf("expected content to stay a string, got %#v", calls[0].Input["content"]) } want := `{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}` if got != want { t.Fatalf("expected content to stay raw, got %q", got) } } func TestParseToolCallsTreatsCDATAObjectFragmentAsObject(t *testing.T) { payload := `` text := `` calls := ParseToolCalls(text, []string{"AskUserQuestion"}) if len(calls) != 1 { t.Fatalf("expected one AskUserQuestion call, got %#v", calls) } question, ok := calls[0].Input["questions"].(map[string]any) if !ok { t.Fatalf("expected CDATA XML object fragment to parse as object, got %#v", calls[0].Input["questions"]) } options, ok := question["options"].([]any) if question["question"] != "Pick one" || !ok || len(options) != 2 { t.Fatalf("unexpected parsed question: %#v", question) } } func TestParseToolCallsPreservesRawMalformedParams(t *testing.T) { text := `cd /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["command"].(string) if !ok { t.Fatalf("expected raw command tracking, got %#v", calls[0].Input) } if raw != "cd /root && git status" { t.Fatalf("expected raw arguments to be preserved, got %q", raw) } } func TestParseToolCallsSupportsParamsJSONWithAmpersandCommand(t *testing.T) { text := `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'` 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 TestParseToolCallsDoesNotTreatParamsNameTagAsToolName(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["tool_name"] != "file.txt" { t.Fatalf("expected parameter name preserved, got %#v", calls[0].Input) } } func TestParseToolCallsDetailedMarksToolCallsSyntax(t *testing.T) { text := `pwd` 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 TestParseToolCallsRejectsAllEmptyParameterPayload(t *testing.T) { text := ` ` res := ParseToolCallsDetailed(text, []string{"Bash"}) if !res.SawToolCallSyntax { t.Fatalf("expected tool syntax to be detected, got %#v", res) } if len(res.Calls) != 0 { t.Fatalf("expected all-empty payload to be rejected, got %#v", res.Calls) } } func TestParseToolCallsPreservesExplicitZeroArgToolCall(t *testing.T) { text := `` res := ParseToolCallsDetailed(text, []string{"noop"}) if len(res.Calls) != 1 { t.Fatalf("expected zero-arg tool call to remain valid, got %#v", res.Calls) } if len(res.Calls[0].Input) != 0 { t.Fatalf("expected empty input map for zero-arg tool call, got %#v", res.Calls[0].Input) } } func TestParseToolCallsSupportsInlineJSONToolObject(t *testing.T) { text := `{"input":{"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 TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) { text := `README.md` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { t.Fatalf("expected mismatched tags to be rejected, got %#v", calls) } } func TestParseToolCallsDoesNotTreatNameInsideParamsAsToolName(t *testing.T) { text := `README.md` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { t.Fatalf("expected no tool call when name appears only under params, got %#v", calls) } } func TestParseToolCallsRejectsLegacyToolsWrapper(t *testing.T) { text := `read_file{"path":"README.md"}` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { t.Fatalf("expected legacy tools wrapper to be rejected, got %#v", calls) } } func TestParseToolCallsRejectsBareInvokeWithoutToolCallsWrapper(t *testing.T) { text := `README.md` res := ParseToolCallsDetailed(text, []string{"read_file"}) if len(res.Calls) != 0 { t.Fatalf("expected bare invoke to be rejected, got %#v", res.Calls) } if res.SawToolCallSyntax { t.Fatalf("expected bare invoke to no longer count as supported syntax, got %#v", res) } } func TestParseToolCallsRepairsMissingOpeningToolCallsWrapperWhenClosingTagExists(t *testing.T) { text := `Before tool call README.md after` res := ParseToolCallsDetailed(text, []string{"read_file"}) if len(res.Calls) != 1 { t.Fatalf("expected repaired wrapper to parse exactly one call, got %#v", res) } if res.Calls[0].Name != "read_file" { t.Fatalf("expected repaired wrapper to preserve tool name, got %#v", res.Calls[0]) } if got, _ := res.Calls[0].Input["path"].(string); got != "README.md" { t.Fatalf("expected repaired wrapper to preserve args, got %#v", res.Calls[0].Input) } if !res.SawToolCallSyntax { t.Fatalf("expected repaired wrapper to mark tool syntax seen, got %#v", res) } } func TestParseToolCallsRejectsLegacyCanonicalBody(t *testing.T) { text := `read_file{"path":"README.md"}` calls := ParseToolCalls(text, []string{"read_file"}) if len(calls) != 0 { t.Fatalf("expected legacy canonical body to be rejected, 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 := `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\nREADME.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\nREADME.md\n```\ngolang" 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\nREADME.md\n```\n````\noutside" 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]) } } func TestParseToolCallsToleratesDSMLSpaceSeparatorTypo(t *testing.T) { text := strings.Join([]string{ "<|DSML tool_calls>", "<|DSML invoke name=\"Read\">", "<|DSML parameter name=\"file_path\">", "", "", }, "\n") calls := ParseToolCalls(text, []string{"Read"}) if len(calls) != 1 { t.Fatalf("expected one call from DSML space-separator typo, got %#v", calls) } if calls[0].Name != "Read" { t.Fatalf("expected Read call, got %#v", calls[0]) } if got, _ := calls[0].Input["file_path"].(string); got != "/tmp/input.txt" { t.Fatalf("expected file_path to parse, got %q", got) } } func TestParseToolCallsDoesNotAcceptDSMLSpaceLookalikeTagName(t *testing.T) { text := strings.Join([]string{ "<|DSML tool_calls_extra>", "<|DSML invoke name=\"Read\">", "<|DSML parameter name=\"file_path\">/tmp/input.txt", "", "", }, "\n") calls := ParseToolCalls(text, []string{"Read"}) if len(calls) != 0 { t.Fatalf("expected no calls from lookalike tag, got %#v", calls) } } func TestParseToolCallsToleratesDSMLCollapsedTagNames(t *testing.T) { todos := `[x] 检查 toolcalls_format.go 格式化逻辑 [x] 检查 toolcalls_parse.go 解析逻辑 [x] 检查 toolcalls_xml.go 和 toolcalls_dsml.go [x] 检查 toolcalls_markup.go 和 toolcalls_json_repair.go [x] 检查 prompt/tool_calls.go 注入逻辑 [x] 检查 toolstream 流式解析 [x] 查看测试文件确认预期行为 [x] 给出调查结论` text := strings.Join([]string{ "[]", "", "", "", "", "", }, "\n") calls := ParseToolCalls(text, []string{"update_todo_list"}) if len(calls) != 1 { t.Fatalf("expected one call from collapsed DSML tags, got %#v", calls) } if calls[0].Name != "update_todo_list" { t.Fatalf("expected update_todo_list call, got %#v", calls[0]) } if got, _ := calls[0].Input["todos"].(string); got != todos { t.Fatalf("expected todos to round-trip, got %q", got) } } func TestParseToolCallsDoesNotAcceptDSMLCollapsedLookalikeTagName(t *testing.T) { text := strings.Join([]string{ "", "", "x", "", "", }, "\n") calls := ParseToolCalls(text, []string{"update_todo_list"}) if len(calls) != 0 { t.Fatalf("expected no calls from collapsed lookalike tag, got %#v", calls) } } func TestParseToolCallsSkipsProseMentionOfSameWrapperVariant(t *testing.T) { text := strings.Join([]string{ "Summary: support canonical and DSML <|DSML|tool_calls> wrappers.", "", "<|DSML|tool_calls>", "<|DSML|invoke name=\"Bash\">", "<|DSML|parameter name=\"command\">", "", "", }, "\n") res := ParseToolCallsDetailed(text, []string{"Bash"}) if len(res.Calls) != 1 { t.Fatalf("expected one parsed call after prose mention, got %#v", res.Calls) } if res.Calls[0].Name != "Bash" { t.Fatalf("expected Bash call, got %#v", res.Calls[0]) } if got, _ := res.Calls[0].Input["command"].(string); got != "git status" { t.Fatalf("expected command to parse, got %q", got) } }