From 783735607d863bc182a00b939e498cc22e92f2f2 Mon Sep 17 00:00:00 2001 From: Roger Chien <20276663+rjchien728@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:15:40 +0800 Subject: [PATCH] fix(relaykit): keep tool calls when merging adjacent assistant messages for Claude (#7921) * fix(relaykit): keep tool calls when merging adjacent assistant messages Adjacent assistant messages with string content were merged keeping only the second message's tool calls, so tool results for the first message's calls had no matching tool_use and Claude rejected the request. - concatenate both messages' tool calls in input order - skip the "..." placeholder of a tool-call message in the merged text * fix(relaykit): preserve literal ellipses in assistant message merges --------- Co-authored-by: seefs001 --- .../oai_chat/to_claude_messages_req.go | 26 ++- .../oai_chat/to_claude_messages_req_test.go | 159 ++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go index 13f7c6788..8dfe7d28f 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go +++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req.go @@ -2,6 +2,7 @@ package oaichat import ( "fmt" + "strings" "context" @@ -127,6 +128,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te lastMessage := dto.Message{ Role: "tool", } + lastMessageHasPlaceholder := false for _, message := range textRequest.Messages { switch message.Role { case "": @@ -159,15 +161,35 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te } if lastMessage.Role == message.Role && lastMessage.Role != "tool" { if lastMessage.IsStringContent() && message.IsStringContent() { - fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent())) + previousToolCalls := lastMessage.ParseToolCalls() + currentToolCalls := fmtMessage.ParseToolCalls() + if len(previousToolCalls) > 0 || len(currentToolCalls) > 0 { + // Keep both messages' tool calls so later tool results still + // match a tool_use block. + texts := make([]string, 0, 2) + // Omit only text injected for an empty tool-call message. + isToolPlaceholder := lastMessageHasPlaceholder && len(previousToolCalls) > 0 + if previousText := lastMessage.StringContent(); previousText != "" && !isToolPlaceholder { + texts = append(texts, previousText) + } + if currentText := message.StringContent(); currentText != "" { + texts = append(texts, currentText) + } + fmtMessage.SetStringContent(strings.Join(texts, " ")) + fmtMessage.SetToolCalls(append(previousToolCalls, currentToolCalls...)) + } else { + fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent())) + } formatMessages = formatMessages[:len(formatMessages)-1] } } - if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") { + hasPlaceholder := fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") + if hasPlaceholder { fmtMessage.SetStringContent("...") } formatMessages = append(formatMessages, fmtMessage) lastMessage = fmtMessage + lastMessageHasPlaceholder = hasPlaceholder } claudeMessages := make([]dto.ClaudeMessage, 0) diff --git a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go index c718bd259..4cb302d24 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go +++ b/relaykit/relayconvert/internal/oai_chat/to_claude_messages_req_test.go @@ -79,3 +79,162 @@ func TestOpenAIChatRequestToClaudeMessagesNormalizesToolInputSchema(t *testing.T }) } } + +func TestOpenAIChatRequestToClaudeMessagesMergesAdjacentAssistantToolCalls(t *testing.T) { + toolCall := func(id string) []dto.ToolCallRequest { + return []dto.ToolCallRequest{{ + ID: id, + Type: "function", + Function: dto.FunctionRequest{Name: "lookup_" + id, Arguments: `{"q":"` + id + `"}`}, + }} + } + assistant := func(content any, toolCalls []dto.ToolCallRequest) dto.Message { + message := dto.Message{Role: "assistant", Content: content} + if toolCalls != nil { + message.SetToolCalls(toolCalls) + } + return message + } + + tests := []struct { + name string + messages []dto.Message + wantTexts []string + wantToolUses []string + }{ + { + name: "both assistants have tool calls", + messages: []dto.Message{ + {Role: "user", Content: "Look up a and b."}, + assistant("", toolCall("call_a")), + assistant("", toolCall("call_b")), + {Role: "tool", ToolCallId: "call_a", Content: "result a"}, + {Role: "tool", ToolCallId: "call_b", Content: "result b"}, + }, + wantTexts: []string{"..."}, + wantToolUses: []string{"call_a", "call_b"}, + }, + { + name: "tool calls followed by plain text", + messages: []dto.Message{ + {Role: "user", Content: "Look up a."}, + assistant(nil, toolCall("call_a")), + assistant("Checking a.", nil), + {Role: "tool", ToolCallId: "call_a", Content: "result a"}, + }, + wantTexts: []string{"Checking a."}, + wantToolUses: []string{"call_a"}, + }, + { + name: "literal ellipsis with tool calls is preserved", + messages: []dto.Message{ + {Role: "user", Content: "Look up a and b."}, + assistant("...", toolCall("call_a")), + assistant("Checking b.", toolCall("call_b")), + {Role: "tool", ToolCallId: "call_a", Content: "result a"}, + {Role: "tool", ToolCallId: "call_b", Content: "result b"}, + }, + wantTexts: []string{"... Checking b."}, + wantToolUses: []string{"call_a", "call_b"}, + }, + { + name: "literal ellipsis replacing a placeholder survives the next merge", + messages: []dto.Message{ + {Role: "user", Content: "Look up a."}, + assistant(nil, toolCall("call_a")), + assistant("...", nil), + assistant("Checking a.", nil), + {Role: "tool", ToolCallId: "call_a", Content: "result a"}, + }, + wantTexts: []string{"... Checking a."}, + wantToolUses: []string{"call_a"}, + }, + { + name: "literal ellipsis before tool calls survives repeated merges", + messages: []dto.Message{ + {Role: "user", Content: "Look up a."}, + assistant("...", nil), + assistant("", toolCall("call_a")), + assistant("Checking a.", nil), + {Role: "tool", ToolCallId: "call_a", Content: "result a"}, + }, + wantTexts: []string{"... Checking a."}, + wantToolUses: []string{"call_a"}, + }, + { + name: "three tool-only assistants keep one placeholder", + messages: []dto.Message{ + {Role: "user", Content: "Look up a, b and c."}, + assistant(nil, toolCall("call_a")), + assistant("", toolCall("call_b")), + assistant("", toolCall("call_c")), + {Role: "tool", ToolCallId: "call_a", Content: "result a"}, + {Role: "tool", ToolCallId: "call_b", Content: "result b"}, + {Role: "tool", ToolCallId: "call_c", Content: "result c"}, + }, + wantTexts: []string{"..."}, + wantToolUses: []string{"call_a", "call_b", "call_c"}, + }, + { + name: "both assistants are plain text", + messages: []dto.Message{ + {Role: "user", Content: "Hello."}, + assistant("First.", nil), + assistant("Second.", nil), + }, + wantTexts: []string{"First. Second."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + maxTokens := uint(1024) + got, err := OpenAIChatRequestToClaudeMessages(context.Background(), nil, dto.GeneralOpenAIRequest{ + Model: "claude-test", + MaxTokens: &maxTokens, + Messages: tt.messages, + }) + require.NoError(t, err) + + var wantToolResults []dto.ClaudeMediaMessage + for _, message := range tt.messages { + if message.Role == "tool" { + wantToolResults = append(wantToolResults, dto.ClaudeMediaMessage{ + Type: "tool_result", + ToolUseId: message.ToolCallId, + Content: message.Content, + }) + } + } + if len(wantToolResults) > 0 { + require.Len(t, got.Messages, 3) + assert.Equal(t, "user", got.Messages[2].Role) + assert.Equal(t, wantToolResults, got.Messages[2].Content) + } else { + require.Len(t, got.Messages, 2) + } + assert.Equal(t, "assistant", got.Messages[1].Role) + + var texts, toolUses []string + switch content := got.Messages[1].Content.(type) { + case string: + texts = append(texts, content) + case []dto.ClaudeMediaMessage: + for _, block := range content { + switch block.Type { + case "text": + texts = append(texts, block.GetText()) + case "tool_use": + toolUses = append(toolUses, block.Id) + assert.Equal(t, "lookup_"+block.Id, block.Name) + assert.Equal(t, map[string]any{"q": block.Id}, block.Input) + } + } + default: + require.Failf(t, "unexpected assistant content type", "%T", content) + } + assert.Equal(t, tt.wantTexts, texts) + assert.Equal(t, tt.wantToolUses, toolUses) + }) + } +}