From 4a2eb54dc6bf943196be4fb515e6a9407a4db143 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Jul 2026 23:47:37 +0800 Subject: [PATCH] feat(translator): group consecutive tool results in Claude request conversion - Added logic to merge consecutive tool responses into a single user message for better grouping. - Updated `ConvertOpenAIRequestToClaude` to track previous roles and adjust message blocks accordingly. - Introduced a comprehensive test to validate tool result grouping behavior and content preservation. Closes: #4656 --- .../chat-completions/claude_openai_request.go | 10 ++- .../claude_openai_request_test.go | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index e0957b2d..9c483598 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -165,6 +165,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { systemBlocks := make([][]byte, 0) messageBlocks := make([][]byte, 0) + previousRole := "" messages.ForEach(func(_, message gjson.Result) bool { role := message.Get("role").String() contentResult := message.Get("content") @@ -274,8 +275,15 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream msg, _ = sjson.SetBytes(msg, "content.0.content", toolResultContent) } msg = common.AttachMessageCacheControl(msg, message) - messageBlocks = append(messageBlocks, msg) + if previousRole == "tool" && len(messageBlocks) > 0 { + toolResult := gjson.GetBytes(msg, "content.0") + lastIdx := len(messageBlocks) - 1 + messageBlocks[lastIdx], _ = sjson.SetRawBytes(messageBlocks[lastIdx], "content.-1", []byte(toolResult.Raw)) + } else { + messageBlocks = append(messageBlocks, msg) + } } + previousRole = role return true }) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index 801b8e39..7c19f246 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -44,6 +44,70 @@ func TestConvertOpenAIRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T } } +func TestConvertOpenAIRequestToClaude_GroupsConsecutiveParallelToolResults(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "user", "content": "Use both tools."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "tool_a", "arguments": "{}"}}, + {"id": "call_2", "type": "function", "function": {"name": "tool_b", "arguments": "{}"}} + ] + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "one", + "cache_control": {"type": "ephemeral"} + }, + {"role": "tool", "tool_call_id": "call_2", "content": "two"}, + {"role": "assistant", "content": "Done."} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + if len(messages) != 4 { + t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[2].Get("role").String(); got != "user" { + t.Fatalf("Expected grouped tool result role %q, got %q", "user", got) + } + toolResults := messages[2].Get("content").Array() + if len(toolResults) != 2 { + t.Fatalf("Expected 2 grouped tool results, got %d. Content: %s", len(toolResults), messages[2].Get("content").Raw) + } + wants := []struct { + id string + content string + }{ + {id: "call_1", content: "one"}, + {id: "call_2", content: "two"}, + } + for i, want := range wants { + if got := toolResults[i].Get("type").String(); got != "tool_result" { + t.Fatalf("tool result %d type = %q, want tool_result", i, got) + } + if got := toolResults[i].Get("tool_use_id").String(); got != want.id { + t.Fatalf("tool result %d tool_use_id = %q, want %q", i, got, want.id) + } + if got := toolResults[i].Get("content").String(); got != want.content { + t.Fatalf("tool result %d content = %q, want %q", i, got, want.content) + } + } + if got := toolResults[0].Get("cache_control.type").String(); got != "ephemeral" { + t.Fatalf("first tool result cache_control.type = %q, want ephemeral", got) + } + if got := messages[3].Get("content.0.text").String(); got != "Done." { + t.Fatalf("following assistant message text = %q, want Done.", got) + } +} + func TestConvertOpenAIRequestToClaude_DropsTemperature(t *testing.T) { inputJSON := `{ "model": "gpt-4.1", -- 2.51.2