From de119ea47f9b51a02aafaa4499ef04584288fc47 Mon Sep 17 00:00:00 2001 From: Dong Chen Date: Wed, 30 Sep 2026 13:02:36 -0700 Subject: [PATCH] openai: keep tool message content parts in one message FromChatRequest splits array content into one api.Message per part. That is intended for user content, but a tool message is a single result: the split also dropped its tool_call_id and tool name, since those were only copied when the message had tool_calls. Renderers that resolve tool results by ID then see extra, unnamed results; for example, Kimi K3 fails with "tool message 2 has no resolvable tool name" on a tool result with two text parts, which the OpenAI spec allows. Merge the parts of a tool message into one message with the text joined, the images kept, and ToolCallID and ToolName set, matching how function_call_output items are converted for /v1/responses. --- openai/openai.go | 11 +++++++++ openai/openai_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/openai/openai.go b/openai/openai.go index 010481518..efed4e048 100644 --- a/openai/openai.go +++ b/openai/openai.go @@ -634,6 +634,7 @@ func FromChatRequest(r ChatCompletionRequest, thinking ...*model.Thinking) (*api } messages = append(messages, api.Message{Role: msg.Role, Content: content, Thinking: msg.Reasoning, ToolCalls: toolCalls, ToolName: toolName, ToolCallID: msg.ToolCallID}) case []any: + start := len(messages) for _, c := range content { data, ok := c.(map[string]any) if !ok { @@ -682,6 +683,16 @@ func FromChatRequest(r ChatCompletionRequest, thinking ...*model.Thinking) (*api return nil, errors.New("invalid message format") } } + // a tool message is a single result, so keep its parts together and + // preserve the tool call it answers + if strings.ToLower(msg.Role) == "tool" && len(messages) > start { + tool := api.Message{Role: msg.Role, ToolName: toolName, ToolCallID: msg.ToolCallID} + for _, part := range messages[start:] { + tool.Content += part.Content + tool.Images = append(tool.Images, part.Images...) + } + messages = append(messages[:start], tool) + } // since we might have added multiple messages above, if we have tools // calls we'll add them to the last message if len(messages) > 0 && len(msg.ToolCalls) > 0 { diff --git a/openai/openai_test.go b/openai/openai_test.go index c92e19563..9f32d1df8 100644 --- a/openai/openai_test.go +++ b/openai/openai_test.go @@ -157,6 +157,60 @@ func TestFromChatRequest_WithImage(t *testing.T) { } } +func TestFromChatRequest_ToolMessageArrayContent(t *testing.T) { + imgData, _ := base64.StdEncoding.DecodeString(image) + + req := ChatCompletionRequest{ + Model: "test-model", + Messages: []Message{ + {Role: "user", Content: "What's in the screenshot?"}, + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{Name: "screenshot", Arguments: "{}"}, + }}, + }, + { + Role: "tool", + ToolCallID: "call_1", + Content: []any{ + map[string]any{"type": "text", "text": "Captured "}, + map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": prefix + image}, + }, + map[string]any{"type": "text", "text": "screen.png"}, + }, + }, + }, + } + + result, err := FromChatRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(result.Messages)) + } + + tool := result.Messages[2] + if tool.Role != "tool" || tool.ToolCallID != "call_1" || tool.ToolName != "screenshot" { + t.Errorf("expected tool message for call_1/screenshot, got role=%q id=%q name=%q", tool.Role, tool.ToolCallID, tool.ToolName) + } + if tool.Content != "Captured screen.png" { + t.Errorf("expected content 'Captured screen.png', got %q", tool.Content) + } + if len(tool.Images) != 1 || string(tool.Images[0]) != string(imgData) { + t.Errorf("expected the tool image to be kept, got %d images", len(tool.Images)) + } +} + func TestFromCompleteRequest_Basic(t *testing.T) { temp := float32(0.8) req := CompletionRequest{