From 78319093c9b3bbe12c8a4e3ee6b4d94eec8bf5ee Mon Sep 17 00:00:00 2001 From: ManManavadaria Date: Thu, 19 Feb 2026 18:29:03 +0530 Subject: [PATCH] Fix: Refactor LLM response parser to separate tool calls and strip them from text content in openai_compat providers --- pkg/providers/claude_cli_provider.go | 22 +- pkg/providers/claude_cli_provider_test.go | 155 ----- pkg/providers/codex_cli_provider.go | 6 +- pkg/providers/openai_compat/provider.go | 71 ++- pkg/providers/tool_call_extract.go | 72 --- pkg/providers/toolcall/tool_call_extract.go | 145 +++++ .../toolcall/tool_call_extract_test.go | 583 ++++++++++++++++++ 7 files changed, 787 insertions(+), 267 deletions(-) delete mode 100644 pkg/providers/tool_call_extract.go create mode 100644 pkg/providers/toolcall/tool_call_extract.go create mode 100644 pkg/providers/toolcall/tool_call_extract_test.go diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 58ba3647d..4f0dcd79e 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -7,6 +7,8 @@ import ( "fmt" "os/exec" "strings" + + "github.com/sipeed/picoclaw/pkg/providers/toolcall" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. @@ -173,28 +175,12 @@ func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, // extractToolCalls delegates to the shared extractToolCallsFromText function. func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall { - return extractToolCallsFromText(text) + return toolcall.ExtractToolCallsFromText(text) } // stripToolCallsJSON delegates to the shared stripToolCallsFromText function. func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string { - return stripToolCallsFromText(text) -} - -// findMatchingBrace finds the index after the closing brace matching the opening brace at pos. -func findMatchingBrace(text string, pos int) int { - depth := 0 - for i := pos; i < len(text); i++ { - if text[i] == '{' { - depth++ - } else if text[i] == '}' { - depth-- - if depth == 0 { - return i + 1 - } - } - } - return pos + return toolcall.StripToolCallsFromText(text) } // claudeCliJSONResponse represents the JSON output from the claude CLI. diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 063530deb..eae800299 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -824,158 +824,3 @@ func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) { t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello") } } - -// --- extractToolCalls tests --- - -func TestExtractToolCalls_NoToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls("Just a regular response.") - if len(got) != 0 { - t.Errorf("extractToolCalls() = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_WithToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `Here's the result: -{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 1 { - t.Fatalf("extractToolCalls() = %d, want 1", len(got)) - } - if got[0].ID != "call_1" { - t.Errorf("ID = %q, want %q", got[0].ID, "call_1") - } - if got[0].Name != "test" { - t.Errorf("Name = %q, want %q", got[0].Name, "test") - } - if got[0].Type != "function" { - t.Errorf("Type = %q, want %q", got[0].Type, "function") - } -} - -func TestExtractToolCalls_InvalidJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls(`{"tool_calls":invalid}`) - if len(got) != 0 { - t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_MultipleToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 2 { - t.Fatalf("extractToolCalls() = %d, want 2", len(got)) - } - if got[0].Name != "read_file" { - t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file") - } - if got[1].Name != "write_file" { - t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file") - } - // Verify arguments were parsed - if got[0].Arguments["path"] != "/tmp/test" { - t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"]) - } - if got[1].Arguments["content"] != "hello" { - t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"]) - } -} - -func TestExtractToolCalls_UnmatchedBrace(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`) - if len(got) != 0 { - t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) - } - // Verify different argument types - if got[0].Arguments["num"] != float64(42) { - t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"]) - } - if got[0].Arguments["flag"] != true { - t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"]) - } - if got[0].Arguments["name"] != "test" { - t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) - } - // Verify raw arguments string is preserved in FunctionCall - if got[0].Function.Arguments == "" { - t.Error("Function.Arguments should contain raw JSON string") - } -} - -// --- stripToolCallsJSON tests --- - -func TestStripToolCallsJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `Let me check the weather. -{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]} -Done.` - - got := p.stripToolCallsJSON(text) - if strings.Contains(got, "tool_calls") { - t.Errorf("should remove tool_calls JSON, got %q", got) - } - if !strings.Contains(got, "Let me check the weather.") { - t.Errorf("should keep text before, got %q", got) - } - if !strings.Contains(got, "Done.") { - t.Errorf("should keep text after, got %q", got) - } -} - -func TestStripToolCallsJSON_NoToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := "Just regular text." - got := p.stripToolCallsJSON(text) - if got != text { - t.Errorf("stripToolCallsJSON() = %q, want %q", got, text) - } -} - -func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}` - got := p.stripToolCallsJSON(text) - if got != "" { - t.Errorf("stripToolCallsJSON() = %q, want empty", got) - } -} - -// --- findMatchingBrace tests --- - -func TestFindMatchingBrace(t *testing.T) { - tests := []struct { - text string - pos int - want int - }{ - {`{"a":1}`, 0, 7}, - {`{"a":{"b":2}}`, 0, 13}, - {`text {"a":1} more`, 5, 12}, - {`{unclosed`, 0, 0}, // no match returns pos - {`{}`, 0, 2}, // empty object - {`{{{}}}`, 0, 6}, // deeply nested - {`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher) - } - for _, tt := range tests { - got := findMatchingBrace(tt.text, tt.pos) - if got != tt.want { - t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want) - } - } -} diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 8886406b4..931741cf3 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -8,6 +8,8 @@ import ( "fmt" "os/exec" "strings" + + "github.com/sipeed/picoclaw/pkg/providers/toolcall" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. @@ -234,12 +236,12 @@ func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) content := strings.Join(contentParts, "\n") // Extract tool calls from response text (same pattern as ClaudeCliProvider) - toolCalls := extractToolCallsFromText(content) + toolCalls := toolcall.ExtractToolCallsFromText(content) finishReason := "stop" if len(toolCalls) > 0 { finishReason = "tool_calls" - content = stripToolCallsFromText(content) + content = toolcall.StripToolCallsFromText(content) } return &LLMResponse{ diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 73fac3435..2d3042030 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -13,6 +13,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" + "github.com/sipeed/picoclaw/pkg/providers/toolcall" ) type ToolCall = protocoltypes.ToolCall @@ -152,36 +153,66 @@ func parseResponse(body []byte) (*LLMResponse, error) { } choice := apiResponse.Choices[0] - toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) - for _, tc := range choice.Message.ToolCalls { - arguments := make(map[string]interface{}) - name := "" - if tc.Function != nil { - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) - arguments["raw"] = tc.Function.Arguments - } - } - } + // Parse structured tool calls from the standard tool_calls array + toolCalls := toolcall.ParseStructuredToolCalls(choice.Message.ToolCalls) - toolCalls = append(toolCalls, ToolCall{ - ID: tc.ID, - Name: name, - Arguments: arguments, - }) + // Extract any additional tool calls from the content text (tool calls in text content) + content := choice.Message.Content + textToolCalls := toolcall.ExtractToolCallsFromText(content) + + toolCalls = mergeToolCalls(toolCalls, textToolCalls) + + if len(textToolCalls) > 0 { + content = toolcall.StripToolCallsFromText(content) } + // Determine finish reason based on tool calls + finishReason := determineFinishReason(choice.FinishReason, len(toolCalls)) + return &LLMResponse{ - Content: choice.Message.Content, + Content: strings.TrimSpace(content), ToolCalls: toolCalls, - FinishReason: choice.FinishReason, + FinishReason: finishReason, Usage: apiResponse.Usage, }, nil } +// mergeToolCalls merges embedded tool calls with structured tool calls. +func mergeToolCalls(structured []ToolCall, embedded []ToolCall) []ToolCall { + if len(embedded) == 0 { + return structured + } + + existingIDs := make(map[string]bool, len(structured)) + for _, tc := range structured { + existingIDs[tc.ID] = true + } + + // Append embedded tool calls that don't already exist + for _, etc := range embedded { + if !existingIDs[etc.ID] { + structured = append(structured, etc) + } + } + + return structured +} + +// determineFinishReason determines the appropriate finish reason based on the API response +// and whether tool calls are present. +func determineFinishReason(apiFinishReason string, toolCallCount int) string { + if toolCallCount > 0 && apiFinishReason == "stop" { + return "tool_calls" + } + + if apiFinishReason != "" { + return apiFinishReason + } + + return "stop" +} + func normalizeModel(model, apiBase string) string { idx := strings.Index(model, "/") if idx == -1 { diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go deleted file mode 100644 index 97a219283..000000000 --- a/pkg/providers/tool_call_extract.go +++ /dev/null @@ -1,72 +0,0 @@ -package providers - -import ( - "encoding/json" - "strings" -) - -// extractToolCallsFromText parses tool call JSON from response text. -// Both ClaudeCliProvider and CodexCliProvider use this to extract -// tool calls that the model outputs in its response text. -func extractToolCallsFromText(text string) []ToolCall { - start := strings.Index(text, `{"tool_calls"`) - if start == -1 { - return nil - } - - end := findMatchingBrace(text, start) - if end == start { - return nil - } - - jsonStr := text[start:end] - - var wrapper struct { - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } - - if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil { - return nil - } - - var result []ToolCall - for _, tc := range wrapper.ToolCalls { - var args map[string]interface{} - json.Unmarshal([]byte(tc.Function.Arguments), &args) - - result = append(result, ToolCall{ - ID: tc.ID, - Type: tc.Type, - Name: tc.Function.Name, - Arguments: args, - Function: &FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - }, - }) - } - - return result -} - -// stripToolCallsFromText removes tool call JSON from response text. -func stripToolCallsFromText(text string) string { - start := strings.Index(text, `{"tool_calls"`) - if start == -1 { - return text - } - - end := findMatchingBrace(text, start) - if end == start { - return text - } - - return strings.TrimSpace(text[:start] + text[end:]) -} diff --git a/pkg/providers/toolcall/tool_call_extract.go b/pkg/providers/toolcall/tool_call_extract.go new file mode 100644 index 000000000..1d9c71723 --- /dev/null +++ b/pkg/providers/toolcall/tool_call_extract.go @@ -0,0 +1,145 @@ +package toolcall + +import ( + "encoding/json" + "log" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// parseStructuredToolCalls converts API response tool calls to the internal ToolCall format. +func ParseStructuredToolCalls(apiToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +}) []protocoltypes.ToolCall { + if len(apiToolCalls) == 0 { + return nil + } + + toolCalls := make([]protocoltypes.ToolCall, 0, len(apiToolCalls)) + for _, tc := range apiToolCalls { + if tc.Function == nil { + continue + } + + arguments := ParseToolCallArguments(tc.Function.Arguments) + + toolCalls = append(toolCalls, protocoltypes.ToolCall{ + ID: tc.ID, + Type: tc.Type, + Name: tc.Function.Name, + Arguments: arguments, + Function: &protocoltypes.FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + + return toolCalls +} + +// parseToolCallArguments parses JSON arguments string into a map. +func ParseToolCallArguments(argsStr string) map[string]interface{} { + if argsStr == "" { + return make(map[string]interface{}) + } + + var arguments map[string]interface{} + if err := json.Unmarshal([]byte(argsStr), &arguments); err != nil { + log.Printf("openai_compat: failed to decode tool call arguments: %v", err) + return map[string]interface{}{"raw": argsStr} + } + + return arguments +} + +// extractToolCallsFromText parses tool call JSON from response text. +// This handles cases where models embed tool calls in the content field. +func ExtractToolCallsFromText(text string) []protocoltypes.ToolCall { + start := strings.Index(text, `{"tool_calls"`) + if start == -1 { + return nil + } + + end := FindMatchingBrace(text, start) + if end == start { + return nil + } + + jsonStr := text[start:end] + + var wrapper struct { + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } + + if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil { + return nil + } + + var result []protocoltypes.ToolCall + for _, tc := range wrapper.ToolCalls { + args := ParseToolCallArguments(tc.Function.Arguments) + + result = append(result, protocoltypes.ToolCall{ + ID: tc.ID, + Type: tc.Type, + Name: tc.Function.Name, + Arguments: args, + Function: &protocoltypes.FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + + return result +} + +// FindMatchingBrace finds the index after the closing brace matching the opening brace at pos. +func FindMatchingBrace(text string, pos int) int { + depth := 0 + for i := pos; i < len(text); i++ { + if text[i] == '{' { + depth++ + } else if text[i] == '}' { + depth-- + if depth == 0 { + return i + 1 + } + } + } + return pos +} + +// stripJSONObject removes a JSON object starting with the specified pattern from text. +func StripJSONObject(text, pattern string) string { + start := strings.Index(text, pattern) + if start == -1 { + return text + } + + end := FindMatchingBrace(text, start) + if end == start { + return text + } + + return strings.TrimSpace(text[:start] + text[end:]) +} + +// StripToolCallsFromText removes tool call JSON from response text. +func StripToolCallsFromText(text string) string { + return StripJSONObject(text, `{"tool_calls"`) +} diff --git a/pkg/providers/toolcall/tool_call_extract_test.go b/pkg/providers/toolcall/tool_call_extract_test.go new file mode 100644 index 000000000..5807d0847 --- /dev/null +++ b/pkg/providers/toolcall/tool_call_extract_test.go @@ -0,0 +1,583 @@ +package toolcall + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// --- FindMatchingBrace tests --- + +func TestFindMatchingBrace(t *testing.T) { + tests := []struct { + text string + pos int + want int + }{ + {`{"a":1}`, 0, 7}, + {`{"a":{"b":2}}`, 0, 13}, + {`text {"a":1} more`, 5, 12}, + {`{unclosed`, 0, 0}, // no match returns pos + {`{}`, 0, 2}, // empty object + {`{{{}}}`, 0, 6}, // deeply nested + {`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher) + } + for _, tt := range tests { + got := FindMatchingBrace(tt.text, tt.pos) + if got != tt.want { + t.Errorf("FindMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want) + } + } +} + +// --- StripJSONObject tests --- + +func TestStripJSONObject(t *testing.T) { + tests := []struct { + name string + text string + pattern string + want string + }{ + { + name: "removes matching object", + text: `before {"tool_calls":[]} after`, + pattern: `{"tool_calls"`, + want: `before after`, + }, + { + name: "pattern not found returns original", + text: "no match here", + pattern: `{"tool_calls"`, + want: "no match here", + }, + { + name: "unmatched brace returns original", + text: `{"tool_calls"`, + pattern: `{"tool_calls"`, + want: `{"tool_calls"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripJSONObject(tt.text, tt.pattern) + if got != tt.want { + t.Errorf("StripJSONObject() = %q, want %q", got, tt.want) + } + }) + } +} + +// --- StripToolCallsFromText tests --- + +func TestStripToolCallsFromText(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "removes tool_calls JSON", + text: `Let me check.` + "\n" + `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`, + want: "Let me check.", + }, + { + name: "no tool_calls returns original", + text: "Just regular text.", + want: "Just regular text.", + }, + { + name: "only tool_calls returns empty", + text: `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripToolCallsFromText(tt.text) + if got != tt.want { + t.Errorf("StripToolCallsFromText() = %q, want %q", got, tt.want) + } + }) + } +} + +// --- ParseToolCallArguments tests --- + +func TestParseToolCallArguments(t *testing.T) { + tests := []struct { + name string + argsStr string + want map[string]interface{} + }{ + { + name: "empty string returns empty map", + argsStr: "", + want: make(map[string]interface{}), + }, + { + name: "valid JSON object", + argsStr: `{"location":"Tokyo","unit":"celsius"}`, + want: map[string]interface{}{ + "location": "Tokyo", + "unit": "celsius", + }, + }, + { + name: "valid JSON with nested objects", + argsStr: `{"query":{"text":"hello","lang":"en"}}`, + want: map[string]interface{}{ + "query": map[string]interface{}{ + "text": "hello", + "lang": "en", + }, + }, + }, + { + name: "valid JSON with arrays", + argsStr: `{"items":["a","b","c"],"count":3}`, + want: map[string]interface{}{ + "items": []interface{}{"a", "b", "c"}, + "count": float64(3), + }, + }, + { + name: "valid JSON with numbers", + argsStr: `{"temperature":72.5,"humidity":60}`, + want: map[string]interface{}{ + "temperature": 72.5, + "humidity": float64(60), + }, + }, + { + name: "valid JSON with booleans", + argsStr: `{"enabled":true,"active":false}`, + want: map[string]interface{}{ + "enabled": true, + "active": false, + }, + }, + { + name: "invalid JSON returns raw string in map", + argsStr: `{invalid json}`, + want: map[string]interface{}{ + "raw": `{invalid json}`, + }, + }, + { + name: "malformed JSON returns raw string", + argsStr: `{"key":value}`, + want: map[string]interface{}{ + "raw": `{"key":value}`, + }, + }, + { + name: "empty JSON object", + argsStr: `{}`, + want: make(map[string]interface{}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseToolCallArguments(tt.argsStr) + if len(got) != len(tt.want) { + t.Errorf("ParseToolCallArguments() length = %d, want %d", len(got), len(tt.want)) + return + } + for key, wantVal := range tt.want { + gotVal, ok := got[key] + if !ok { + t.Errorf("ParseToolCallArguments() missing key %q", key) + continue + } + if key == "raw" { + // For raw error cases, just check it exists + if _, ok := gotVal.(string); !ok { + t.Errorf("ParseToolCallArguments() raw value is not string") + } + } else { + // For other cases, do deep comparison + if gotVal != wantVal { + // Handle nested maps + if gotMap, ok := gotVal.(map[string]interface{}); ok { + if wantMap, ok := wantVal.(map[string]interface{}); ok { + if len(gotMap) != len(wantMap) { + t.Errorf("ParseToolCallArguments()[%q] nested map length = %d, want %d", key, len(gotMap), len(wantMap)) + } + continue + } + } + t.Errorf("ParseToolCallArguments()[%q] = %v, want %v", key, gotVal, wantVal) + } + } + } + }) + } +} + +// --- ExtractToolCallsFromText tests --- + +func TestExtractToolCallsFromText(t *testing.T) { + tests := []struct { + name string + text string + want []protocoltypes.ToolCall + wantLen int + checkIDs bool + }{ + { + name: "no tool_calls returns nil", + text: "Just regular text without tool calls", + want: nil, + wantLen: 0, + }, + { + name: "text with single tool call", + text: `Let me check.` + "\n" + `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}}]}`, + wantLen: 1, + checkIDs: true, + }, + { + name: "text with multiple tool calls", + text: `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out.txt\",\"content\":\"hello\"}"}}]}`, + wantLen: 2, + checkIDs: true, + }, + { + name: "tool_calls with empty arguments", + text: `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"no_args","arguments":"{}"}}]}`, + wantLen: 1, + checkIDs: true, + }, + { + name: "tool_calls with nested JSON arguments", + text: `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"complex","arguments":"{\"query\":{\"text\":\"hello\",\"lang\":\"en\"},\"options\":{\"case\":\"lower\"}}"}}]}`, + wantLen: 1, + checkIDs: true, + }, + { + name: "malformed JSON returns nil", + text: `{"tool_calls":[invalid json]}`, + want: nil, + wantLen: 0, + }, + { + name: "unclosed brace returns nil", + text: `{"tool_calls":[{"id":"call_1"`, + want: nil, + wantLen: 0, + }, + { + name: "empty tool_calls array", + text: `{"tool_calls":[]}`, + want: nil, + wantLen: 0, + }, + { + name: "text before and after tool_calls", + text: `Before text.` + "\n" + `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}` + "\n" + `After text.`, + wantLen: 1, + checkIDs: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractToolCallsFromText(tt.text) + if tt.want == nil && got != nil { + t.Errorf("ExtractToolCallsFromText() = %v, want nil", got) + return + } + if len(got) != tt.wantLen { + t.Errorf("ExtractToolCallsFromText() length = %d, want %d", len(got), tt.wantLen) + return + } + if tt.checkIDs && len(got) > 0 { + // Verify structure of first tool call + tc := got[0] + if tc.ID == "" { + t.Error("ExtractToolCallsFromText() tool call ID is empty") + } + if tc.Name == "" { + t.Error("ExtractToolCallsFromText() tool call Name is empty") + } + if tc.Function == nil { + t.Error("ExtractToolCallsFromText() tool call Function is nil") + } else { + if tc.Function.Name == "" { + t.Error("ExtractToolCallsFromText() Function.Name is empty") + } + if tc.Function.Arguments == "" { + t.Error("ExtractToolCallsFromText() Function.Arguments is empty") + } + } + if tc.Arguments == nil { + t.Error("ExtractToolCallsFromText() Arguments map is nil") + } + } + }) + } +} + +func TestExtractToolCallsFromText_Detailed(t *testing.T) { + text := `Let me check the weather.` + "\n" + `{"tool_calls":[{"id":"call_123","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\",\"unit\":\"celsius\"}"}}]}` + + toolCalls := ExtractToolCallsFromText(text) + if len(toolCalls) != 1 { + t.Fatalf("ExtractToolCallsFromText() length = %d, want 1", len(toolCalls)) + } + + tc := toolCalls[0] + if tc.ID != "call_123" { + t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_123") + } + if tc.Type != "function" { + t.Errorf("ToolCall.Type = %q, want %q", tc.Type, "function") + } + if tc.Name != "get_weather" { + t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather") + } + if tc.Function == nil { + t.Fatal("ToolCall.Function is nil") + } + if tc.Function.Name != "get_weather" { + t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather") + } + if tc.Function.Arguments != `{"location":"Tokyo","unit":"celsius"}` { + t.Errorf("Function.Arguments = %q, want %q", tc.Function.Arguments, `{"location":"Tokyo","unit":"celsius"}`) + } + if tc.Arguments["location"] != "Tokyo" { + t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"]) + } + if tc.Arguments["unit"] != "celsius" { + t.Errorf("Arguments[unit] = %v, want celsius", tc.Arguments["unit"]) + } +} + +// --- ParseStructuredToolCalls tests --- + +func TestParseStructuredToolCalls(t *testing.T) { + tests := []struct { + name string + apiToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } + wantLen int + }{ + { + name: "empty array returns nil", + apiToolCalls: []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{}, + wantLen: 0, + }, + { + name: "single tool call", + apiToolCalls: []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{ + { + ID: "call_1", + Type: "function", + Function: &struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "get_weather", + Arguments: `{"location":"NYC"}`, + }, + }, + }, + wantLen: 1, + }, + { + name: "multiple tool calls", + apiToolCalls: []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{ + { + ID: "call_1", + Type: "function", + Function: &struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "read_file", + Arguments: `{"path":"/tmp/a.txt"}`, + }, + }, + { + ID: "call_2", + Type: "function", + Function: &struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "write_file", + Arguments: `{"path":"/tmp/b.txt","content":"hello"}`, + }, + }, + }, + wantLen: 2, + }, + { + name: "nil function is skipped", + apiToolCalls: []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{ + { + ID: "call_1", + Type: "function", + Function: nil, + }, + { + ID: "call_2", + Type: "function", + Function: &struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "valid_call", + Arguments: `{}`, + }, + }, + }, + wantLen: 1, + }, + { + name: "empty arguments string", + apiToolCalls: []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{ + { + ID: "call_1", + Type: "function", + Function: &struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "no_args", + Arguments: "", + }, + }, + }, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseStructuredToolCalls(tt.apiToolCalls) + if tt.wantLen == 0 { + if len(got) > 0 { + t.Errorf("ParseStructuredToolCalls() = %v, want nil or empty", got) + } + return + } + if len(got) != tt.wantLen { + t.Errorf("ParseStructuredToolCalls() length = %d, want %d", len(got), tt.wantLen) + return + } + // Verify structure + for i, tc := range got { + if tc.ID == "" { + t.Errorf("ParseStructuredToolCalls()[%d].ID is empty", i) + } + if tc.Name == "" { + t.Errorf("ParseStructuredToolCalls()[%d].Name is empty", i) + } + if tc.Function == nil { + t.Errorf("ParseStructuredToolCalls()[%d].Function is nil", i) + } + if tc.Arguments == nil { + t.Errorf("ParseStructuredToolCalls()[%d].Arguments is nil", i) + } + } + }) + } +} + +func TestParseStructuredToolCalls_Detailed(t *testing.T) { + apiToolCalls := []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{ + { + ID: "call_abc", + Type: "function", + Function: &struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "get_weather", + Arguments: `{"city":"SF","unit":"fahrenheit"}`, + }, + }, + } + + toolCalls := ParseStructuredToolCalls(apiToolCalls) + if len(toolCalls) != 1 { + t.Fatalf("ParseStructuredToolCalls() length = %d, want 1", len(toolCalls)) + } + + tc := toolCalls[0] + if tc.ID != "call_abc" { + t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc") + } + if tc.Type != "function" { + t.Errorf("ToolCall.Type = %q, want %q", tc.Type, "function") + } + if tc.Name != "get_weather" { + t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather") + } + if tc.Function == nil { + t.Fatal("ToolCall.Function is nil") + } + if tc.Function.Name != "get_weather" { + t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather") + } + if tc.Function.Arguments != `{"city":"SF","unit":"fahrenheit"}` { + t.Errorf("Function.Arguments = %q, want %q", tc.Function.Arguments, `{"city":"SF","unit":"fahrenheit"}`) + } + if tc.Arguments["city"] != "SF" { + t.Errorf("Arguments[city] = %v, want SF", tc.Arguments["city"]) + } + if tc.Arguments["unit"] != "fahrenheit" { + t.Errorf("Arguments[unit] = %v, want fahrenheit", tc.Arguments["unit"]) + } +}