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 13f7c6788f..8dfe7d28f3 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 c718bd259a..4cb302d241 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) + }) + } +}