From bfca84c2c7dd4a7d79788a4e58d7a2f0cd0a8f17 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 1 Apr 2026 01:24:55 +0800 Subject: [PATCH 1/2] Align tool-call parsing across Go/JS and pass quality gates --- .../adapter/claude/handler_helpers_misc.go | 97 +++++++++++++ internal/adapter/claude/handler_util_test.go | 41 ++++++ internal/adapter/claude/handler_utils.go | 130 ++++-------------- internal/adapter/claude/tool_call_state.go | 25 ++++ internal/adapter/gemini/convert_messages.go | 21 ++- .../adapter/gemini/convert_messages_test.go | 45 ++++++ internal/adapter/openai/tool_sieve_core.go | 4 +- .../js/helpers/stream-tool-sieve/parse.js | 5 +- .../stream-tool-sieve/parse_payload.js | 24 ++++ .../stream-tool-sieve/tool-keywords.js | 2 + internal/util/toolcalls_candidates.go | 2 +- internal/util/toolcalls_parse.go | 67 +-------- internal/util/toolcalls_parse_item.go | 88 ++++++++++++ internal/util/toolcalls_test.go | 28 ++++ tests/node/stream-tool-sieve.test.js | 18 +++ 15 files changed, 423 insertions(+), 174 deletions(-) create mode 100644 internal/adapter/claude/handler_helpers_misc.go create mode 100644 internal/adapter/claude/tool_call_state.go create mode 100644 internal/util/toolcalls_parse_item.go diff --git a/internal/adapter/claude/handler_helpers_misc.go b/internal/adapter/claude/handler_helpers_misc.go new file mode 100644 index 0000000..7b89734 --- /dev/null +++ b/internal/adapter/claude/handler_helpers_misc.go @@ -0,0 +1,97 @@ +package claude + +import ( + "fmt" + "strings" +) + +func hasSystemMessage(messages []any) bool { + for _, m := range messages { + msg, ok := m.(map[string]any) + if ok && msg["role"] == "system" { + return true + } + } + return false +} + +func extractClaudeToolNames(tools []any) []string { + out := make([]string, 0, len(tools)) + for _, t := range tools { + m, ok := t.(map[string]any) + if !ok { + continue + } + name, _, _ := extractClaudeToolMeta(m) + if name != "" { + out = append(out, name) + } + } + return out +} + +func extractClaudeToolMeta(m map[string]any) (string, string, any) { + name, _ := m["name"].(string) + desc, _ := m["description"].(string) + schemaObj := m["input_schema"] + if schemaObj == nil { + schemaObj = m["parameters"] + } + + if fn, ok := m["function"].(map[string]any); ok { + if strings.TrimSpace(name) == "" { + name, _ = fn["name"].(string) + } + if strings.TrimSpace(desc) == "" { + desc, _ = fn["description"].(string) + } + if schemaObj == nil { + if v, ok := fn["input_schema"]; ok { + schemaObj = v + } + } + if schemaObj == nil { + if v, ok := fn["parameters"]; ok { + schemaObj = v + } + } + } + return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj +} + +func toMessageMaps(v any) []map[string]any { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +func extractMessageContent(v any) string { + switch x := v.(type) { + case string: + return x + case []any: + parts := make([]string, 0, len(x)) + for _, it := range x { + parts = append(parts, fmt.Sprintf("%v", it)) + } + return strings.Join(parts, "\n") + default: + return fmt.Sprintf("%v", x) + } +} + +func cloneMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index 0b6085b..82302f0 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -225,6 +225,47 @@ func TestNormalizeClaudeMessagesToolResultNonTextPayloadStringified(t *testing.T } } +func TestNormalizeClaudeMessagesBackfillsToolResultCallIDByName(t *testing.T) { + msgs := []any{ + map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{ + "type": "tool_use", + "name": "search_web", + "input": map[string]any{"query": "latest"}, + }, + }, + }, + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{ + "type": "tool_result", + "name": "search_web", + "content": "ok", + }, + }, + }, + } + + got := normalizeClaudeMessages(msgs) + if len(got) != 2 { + t.Fatalf("expected 2 messages, got %#v", got) + } + assistant, _ := got[0].(map[string]any) + tc, _ := assistant["tool_calls"].([]any) + call, _ := tc[0].(map[string]any) + callID, _ := call["id"].(string) + if !strings.HasPrefix(callID, "call_claude_") { + t.Fatalf("expected generated call id, got %#v", call) + } + toolMsg, _ := got[1].(map[string]any) + if toolMsg["tool_call_id"] != callID { + t.Fatalf("expected tool_result to reuse generated id, got %#v", toolMsg) + } +} + // ─── buildClaudeToolPrompt ─────────────────────────────────────────── func TestBuildClaudeToolPromptSingleTool(t *testing.T) { diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index c46e37a..fef1194 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -11,6 +11,11 @@ import ( func normalizeClaudeMessages(messages []any) []any { out := make([]any, 0, len(messages)) + state := &claudeToolCallState{ + nameByID: map[string]string{}, + lastIDByName: map[string]string{}, + callIDSequence: 0, + } for _, m := range messages { msg, ok := m.(map[string]any) if !ok { @@ -44,7 +49,7 @@ func normalizeClaudeMessages(messages []any) []any { case "tool_use": if role == "assistant" { flushText() - if toolMsg := normalizeClaudeToolUseToAssistant(b); toolMsg != nil { + if toolMsg := normalizeClaudeToolUseToAssistant(b, state); toolMsg != nil { out = append(out, toolMsg) } continue @@ -54,7 +59,7 @@ func normalizeClaudeMessages(messages []any) []any { } case "tool_result": flushText() - if toolMsg := normalizeClaudeToolResultToToolMessage(b); toolMsg != nil { + if toolMsg := normalizeClaudeToolResultToToolMessage(b, state); toolMsg != nil { out = append(out, toolMsg) } default: @@ -119,7 +124,7 @@ func formatClaudeToolResultForPrompt(block map[string]any) string { return string(b) } -func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any { +func normalizeClaudeToolUseToAssistant(block map[string]any, state *claudeToolCallState) map[string]any { if block == nil { return nil } @@ -127,13 +132,15 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any { if name == "" { return nil } - callID := strings.TrimSpace(fmt.Sprintf("%v", block["id"])) + callID := safeStringValue(block["id"]) if callID == "" { - callID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])) + callID = safeStringValue(block["tool_use_id"]) } if callID == "" { - callID = "call_claude" + callID = state.nextID() } + state.nameByID[callID] = name + state.lastIDByName[strings.ToLower(name)] = callID arguments := block["input"] if arguments == nil { arguments = map[string]any{} @@ -159,24 +166,34 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any { } } -func normalizeClaudeToolResultToToolMessage(block map[string]any) map[string]any { +func normalizeClaudeToolResultToToolMessage(block map[string]any, state *claudeToolCallState) map[string]any { if block == nil { return nil } - toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])) + name := safeStringValue(block["name"]) + toolCallID := safeStringValue(block["tool_use_id"]) if toolCallID == "" { - toolCallID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"])) + toolCallID = safeStringValue(block["tool_call_id"]) } if toolCallID == "" { - toolCallID = "call_claude" + if name != "" { + toolCallID = strings.TrimSpace(state.lastIDByName[strings.ToLower(name)]) + } + } + if toolCallID == "" { + toolCallID = state.nextID() } out := map[string]any{ "role": "tool", "tool_call_id": toolCallID, "content": normalizeClaudeToolResultContent(block["content"]), } - if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" { + if name != "" { out["name"] = name + state.nameByID[toolCallID] = name + state.lastIDByName[strings.ToLower(name)] = toolCallID + } else if inferred := strings.TrimSpace(state.nameByID[toolCallID]); inferred != "" { + out["name"] = inferred } return out } @@ -206,94 +223,3 @@ func formatClaudeBlockRaw(block map[string]any) string { } return string(b) } - -func hasSystemMessage(messages []any) bool { - for _, m := range messages { - msg, ok := m.(map[string]any) - if ok && msg["role"] == "system" { - return true - } - } - return false -} - -func extractClaudeToolNames(tools []any) []string { - out := make([]string, 0, len(tools)) - for _, t := range tools { - m, ok := t.(map[string]any) - if !ok { - continue - } - name, _, _ := extractClaudeToolMeta(m) - if name != "" { - out = append(out, name) - } - } - return out -} - -func extractClaudeToolMeta(m map[string]any) (string, string, any) { - name, _ := m["name"].(string) - desc, _ := m["description"].(string) - schemaObj := m["input_schema"] - if schemaObj == nil { - schemaObj = m["parameters"] - } - - if fn, ok := m["function"].(map[string]any); ok { - if strings.TrimSpace(name) == "" { - name, _ = fn["name"].(string) - } - if strings.TrimSpace(desc) == "" { - desc, _ = fn["description"].(string) - } - if schemaObj == nil { - if v, ok := fn["input_schema"]; ok { - schemaObj = v - } - } - if schemaObj == nil { - if v, ok := fn["parameters"]; ok { - schemaObj = v - } - } - } - return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj -} - -func toMessageMaps(v any) []map[string]any { - arr, ok := v.([]any) - if !ok { - return nil - } - out := make([]map[string]any, 0, len(arr)) - for _, item := range arr { - if m, ok := item.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -func extractMessageContent(v any) string { - switch x := v.(type) { - case string: - return x - case []any: - parts := make([]string, 0, len(x)) - for _, it := range x { - parts = append(parts, fmt.Sprintf("%v", it)) - } - return strings.Join(parts, "\n") - default: - return fmt.Sprintf("%v", x) - } -} - -func cloneMap(in map[string]any) map[string]any { - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v - } - return out -} diff --git a/internal/adapter/claude/tool_call_state.go b/internal/adapter/claude/tool_call_state.go new file mode 100644 index 0000000..595d089 --- /dev/null +++ b/internal/adapter/claude/tool_call_state.go @@ -0,0 +1,25 @@ +package claude + +import ( + "fmt" + "strings" +) + +type claudeToolCallState struct { + nameByID map[string]string + lastIDByName map[string]string + callIDSequence int +} + +func (s *claudeToolCallState) nextID() string { + s.callIDSequence++ + return fmt.Sprintf("call_claude_%d", s.callIDSequence) +} + +func safeStringValue(v any) string { + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} diff --git a/internal/adapter/gemini/convert_messages.go b/internal/adapter/gemini/convert_messages.go index ec3f174..f6af145 100644 --- a/internal/adapter/gemini/convert_messages.go +++ b/internal/adapter/gemini/convert_messages.go @@ -1,11 +1,20 @@ package gemini -import "strings" +import ( + "fmt" + "strings" +) const maxGeminiRawPromptChars = 1024 func geminiMessagesFromRequest(req map[string]any) []any { out := make([]any, 0, 8) + toolCallCounter := 0 + nextToolCallID := func() string { + toolCallCounter++ + return fmt.Sprintf("call_gemini_%d", toolCallCounter) + } + lastToolCallIDByName := map[string]string{} if sys := normalizeGeminiSystemInstruction(req["systemInstruction"]); strings.TrimSpace(sys) != "" { out = append(out, map[string]any{ "role": "system", @@ -61,8 +70,11 @@ func geminiMessagesFromRequest(req map[string]any) []any { if name := strings.TrimSpace(asString(fnCall["name"])); name != "" { callID := strings.TrimSpace(asString(fnCall["id"])) if callID == "" { - callID = "call_gemini" + if callID = strings.TrimSpace(asString(fnCall["call_id"])); callID == "" { + callID = nextToolCallID() + } } + lastToolCallIDByName[strings.ToLower(name)] = callID out = append(out, map[string]any{ "role": "assistant", "tool_calls": []any{ @@ -91,7 +103,10 @@ func geminiMessagesFromRequest(req map[string]any) []any { callID = strings.TrimSpace(asString(fnResp["tool_call_id"])) } if callID == "" { - callID = "call_gemini" + callID = strings.TrimSpace(lastToolCallIDByName[strings.ToLower(name)]) + } + if callID == "" { + callID = nextToolCallID() } content := fnResp["response"] if content == nil { diff --git a/internal/adapter/gemini/convert_messages_test.go b/internal/adapter/gemini/convert_messages_test.go index 4c98778..a5191b9 100644 --- a/internal/adapter/gemini/convert_messages_test.go +++ b/internal/adapter/gemini/convert_messages_test.go @@ -82,3 +82,48 @@ func TestGeminiMessagesFromRequestPreservesUnknownPartAsRawJSONText(t *testing.T t.Fatalf("expected raw base64 payload not to be embedded, got %q", content) } } + +func TestGeminiMessagesFromRequestBackfillsFunctionResponseCallIDByName(t *testing.T) { + req := map[string]any{ + "contents": []any{ + map[string]any{ + "role": "model", + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "search_web", + "args": map[string]any{"query": "docs"}, + }, + }, + }, + }, + map[string]any{ + "role": "user", + "parts": []any{ + map[string]any{ + "functionResponse": map[string]any{ + "name": "search_web", + "response": map[string]any{"ok": true}, + }, + }, + }, + }, + }, + } + + got := geminiMessagesFromRequest(req) + if len(got) != 2 { + t.Fatalf("expected two normalized messages, got %#v", got) + } + assistant, _ := got[0].(map[string]any) + tc, _ := assistant["tool_calls"].([]any) + call, _ := tc[0].(map[string]any) + callID, _ := call["id"].(string) + if !strings.HasPrefix(callID, "call_gemini_") { + t.Fatalf("expected generated call id prefix, got %#v", call) + } + toolMsg, _ := got[1].(map[string]any) + if toolMsg["tool_call_id"] != callID { + t.Fatalf("expected tool response to inherit generated call id, tool=%#v call=%#v", toolMsg, call) + } +} diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 9e04d85..23bfdff 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) - keywords := []string{"tool_calls", "\"function\"", "function.name:"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} bestKeyIdx := -1 for _, kw := range keywords { idx := strings.Index(lower, kw) @@ -240,7 +240,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix lower := strings.ToLower(captured) keyIdx := -1 - keywords := []string{"tool_calls", "\"function\"", "function.name:"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} for _, kw := range keywords { idx := strings.Index(lower, kw) if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index 0930c08..3ab1651 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -237,7 +237,10 @@ function isLikelyJSONToolPayloadCandidate(text) { return false; } const lower = trimmed.toLowerCase(); - return lower.includes('tool_calls') || lower.includes('"function"'); + return lower.includes('tool_calls') + || lower.includes('"function"') + || lower.includes('functioncall') + || lower.includes('"tool_use"'); } module.exports = { diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index c480033..2970613 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -85,6 +85,8 @@ function extractToolCallObjects(text) { while (true) { const idxToolCalls = lower.indexOf('tool_calls', offset); const idxFunction = lower.indexOf('"function"', offset); + const idxFunctionCall = lower.indexOf('functioncall', offset); + const idxToolUse = lower.indexOf('"tool_use"', offset); let idx = -1; let matched = ''; if (idxToolCalls >= 0 && (idxFunction < 0 || idxToolCalls <= idxFunction)) { @@ -94,6 +96,14 @@ function extractToolCallObjects(text) { idx = idxFunction; matched = '"function"'; } + if (idxFunctionCall >= 0 && (idx < 0 || idxFunctionCall < idx)) { + idx = idxFunctionCall; + matched = 'functioncall'; + } + if (idxToolUse >= 0 && (idx < 0 || idxToolUse < idx)) { + idx = idxToolUse; + matched = '"tool_use"'; + } if (idx < 0) { break; } @@ -327,6 +337,20 @@ function parseToolCallItem(m) { let name = toStringSafe(m.name); let inputRaw = m.input; let hasInput = Object.prototype.hasOwnProperty.call(m, 'input'); + const fnCall = m.functionCall && typeof m.functionCall === 'object' ? m.functionCall : null; + if (fnCall) { + if (!name) { + name = toStringSafe(fnCall.name); + } + if (!hasInput && Object.prototype.hasOwnProperty.call(fnCall, 'args')) { + inputRaw = fnCall.args; + hasInput = true; + } + if (!hasInput && Object.prototype.hasOwnProperty.call(fnCall, 'arguments')) { + inputRaw = fnCall.arguments; + hasInput = true; + } + } const fn = m.function && typeof m.function === 'object' ? m.function : null; if (fn) { diff --git a/internal/js/helpers/stream-tool-sieve/tool-keywords.js b/internal/js/helpers/stream-tool-sieve/tool-keywords.js index 29896dc..04a0163 100644 --- a/internal/js/helpers/stream-tool-sieve/tool-keywords.js +++ b/internal/js/helpers/stream-tool-sieve/tool-keywords.js @@ -4,6 +4,8 @@ const TOOL_SEGMENT_KEYWORDS = [ 'tool_calls', '"function"', 'function.name:', + 'functioncall', + '"tool_use"', ]; const XML_TOOL_SEGMENT_TAGS = [ diff --git a/internal/util/toolcalls_candidates.go b/internal/util/toolcalls_candidates.go index d7dfe92..0c486bf 100644 --- a/internal/util/toolcalls_candidates.go +++ b/internal/util/toolcalls_candidates.go @@ -64,7 +64,7 @@ func extractToolCallObjects(text string) []string { lower := strings.ToLower(text) out := []string{} offset := 0 - keywords := []string{"tool_calls", "\"function\"", "function.name:"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} for { bestIdx := -1 matchedKeyword := "" diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 7fe8068..6127592 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -196,18 +196,6 @@ func parseToolCallsPayload(payload string) []ParsedToolCall { return nil } -func isLikelyJSONToolPayloadCandidate(candidate string) bool { - trimmed := strings.TrimSpace(candidate) - if trimmed == "" { - return false - } - if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) { - return false - } - lower := strings.ToLower(trimmed) - return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "\"function\"") -} - func isLikelyChatMessageEnvelope(v map[string]any) bool { if v == nil { return false @@ -234,62 +222,11 @@ func looksLikeToolCallSyntax(text string) bool { lower := strings.ToLower(text) return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "\"function\"") || + strings.Contains(lower, "functioncall") || + strings.Contains(lower, "\"tool_use\"") || strings.Contains(lower, "test` calls := ParseToolCalls(text, []string{"search_web"}) diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index e53086e..fa23e64 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -108,6 +108,24 @@ test('parseToolCalls parses text-kv fallback payload', () => { assert.equal(calls[0].input.command, 'cd scripts && python check_syntax.py example.py'); }); +test('parseToolCalls supports Gemini functionCall JSON payload', () => { + const payload = JSON.stringify({ + functionCall: { name: 'search_web', args: { query: 'latest' } }, + }); + const calls = parseToolCalls(payload, ['search_web']); + assert.deepEqual(calls, [{ name: 'search_web', input: { query: 'latest' } }]); +}); + +test('parseToolCalls supports Claude tool_use JSON payload', () => { + const payload = JSON.stringify({ + type: 'tool_use', + name: 'read_file', + input: { path: 'README.md' }, + }); + const calls = parseToolCalls(payload, ['read_file']); + assert.deepEqual(calls, [{ name: 'read_file', input: { path: 'README.md' } }]); +}); + test('parseToolCalls parses multiple text-kv fallback payloads', () => { const text = [ 'function.name: read_file', From 8a74dbff9cfc1eafd70162850e452bdcddd2e3e6 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 1 Apr 2026 01:50:56 +0800 Subject: [PATCH 2/2] Fix lowercase functioncall detection in stream tool sieve --- internal/adapter/openai/tool_sieve_core.go | 4 ++-- .../adapter/openai/tool_sieve_xml_test.go | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 23bfdff..5d96503 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) - keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} bestKeyIdx := -1 for _, kw := range keywords { idx := strings.Index(lower, kw) @@ -240,7 +240,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix lower := strings.ToLower(captured) keyIdx := -1 - keywords := []string{"tool_calls", "\"function\"", "function.name:", "functionCall", "\"tool_use\""} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} for _, kw := range keywords { idx := strings.Index(lower, kw) if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index 9201189..0cf98e4 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -104,6 +104,7 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) { want int }{ {"tool_calls_tag", "some text \n", 10}, + {"gemini_function_call_json", `some text {"functionCall":{"name":"search","args":{"q":"latest"}}}`, 10}, {"tool_call_tag", "prefix \n", 7}, {"invoke_tag", "text body", 5}, {"function_call_tag", "body", 0}, @@ -119,6 +120,27 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) { } } +func TestProcessToolSieveDetectsGeminiFunctionCallPayload(t *testing.T) { + var state toolStreamSieveState + events := processToolSieveChunk(&state, `{"functionCall":{"name":"search_web","args":{"query":"latest"}}}`, []string{"search_web"}) + events = append(events, flushToolSieve(&state, []string{"search_web"})...) + + var textContent string + var toolCalls int + for _, evt := range events { + if evt.Content != "" { + textContent += evt.Content + } + toolCalls += len(evt.ToolCalls) + } + if toolCalls != 1 { + t.Fatalf("expected one tool call from functionCall payload, got events=%#v", events) + } + if strings.Contains(strings.ToLower(textContent), "functioncall") { + t.Fatalf("functionCall json leaked into text content: %q", textContent) + } +} + func TestFindPartialXMLToolTagStart(t *testing.T) { cases := []struct { name string