From f23fb122e77aba129141af1af7aa281fedd665aa Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 17 Jun 2026 13:23:51 +0800 Subject: [PATCH] feat(translator): ensure tool uses stay adjacent to tool results in message generation - Refactored `ConvertOpenAIResponsesRequestToClaude` logic to align tool use with corresponding tool results. - Introduced helper functions for appending and flushing pending reasoning and tool use messages. - Expanded tests to validate message order and content consistency when processing tool calls and results. --- .../claude_openai-responses_request.go | 45 ++++++++++++++++-- .../claude_openai-responses_request_test.go | 46 +++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 61f5c1a0..1fa00ae2 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -170,6 +170,14 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte // input array processing var pendingReasoningParts []string + type pendingToolUseMessage struct { + callID string + raw []byte + } + var pendingToolUseMessages []pendingToolUseMessage + appendMessage := func(msg []byte) { + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + } flushPendingReasoning := func() { if len(pendingReasoningParts) == 0 { return @@ -178,9 +186,28 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte for _, partJSON := range pendingReasoningParts { asst, _ = sjson.SetRawBytes(asst, "content.-1", []byte(partJSON)) } - out, _ = sjson.SetRawBytes(out, "messages.-1", asst) + appendMessage(asst) pendingReasoningParts = nil } + flushPendingToolUses := func() { + for _, pending := range pendingToolUseMessages { + appendMessage(pending.raw) + } + pendingToolUseMessages = nil + } + flushPendingToolUseFor := func(callID string) { + if len(pendingToolUseMessages) == 0 { + return + } + for i, pending := range pendingToolUseMessages { + if pending.callID == callID { + appendMessage(pending.raw) + pendingToolUseMessages = append(pendingToolUseMessages[:i], pendingToolUseMessages[i+1:]...) + return + } + } + flushPendingToolUses() + } if input := root.Get("input"); input.Exists() && input.IsArray() { input.ForEach(func(_, item gjson.Result) bool { @@ -294,6 +321,9 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } hasReasoningParts := false + if role != "assistant" { + flushPendingToolUses() + } if len(pendingReasoningParts) > 0 { if role == "assistant" { if len(partsJSON) == 0 && textAggregate.Len() > 0 { @@ -322,12 +352,12 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte msg, _ = sjson.SetRawBytes(msg, "content.-1", []byte(partJSON)) } } - out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + appendMessage(msg) } else if textAggregate.Len() > 0 || role == "system" { msg := []byte(`{"role":"","content":""}`) msg, _ = sjson.SetBytes(msg, "role", role) msg, _ = sjson.SetBytes(msg, "content", textAggregate.String()) - out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + appendMessage(msg) } case "reasoning": @@ -360,12 +390,16 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } pendingReasoningParts = nil asst, _ = sjson.SetRawBytes(asst, "content.-1", toolUse) - out, _ = sjson.SetRawBytes(out, "messages.-1", asst) + pendingToolUseMessages = append(pendingToolUseMessages, pendingToolUseMessage{ + callID: callID, + raw: asst, + }) case "function_call_output": flushPendingReasoning() // Map to user tool_result callID := item.Get("call_id").String() + flushPendingToolUseFor(callID) outputStr := item.Get("output").String() toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`) toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", callID) @@ -373,12 +407,13 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte usr := []byte(`{"role":"user","content":[]}`) usr, _ = sjson.SetRawBytes(usr, "content.-1", toolResult) - out, _ = sjson.SetRawBytes(out, "messages.-1", usr) + appendMessage(usr) } return true }) } flushPendingReasoning() + flushPendingToolUses() includedToolNames := map[string]struct{}{} toolNameMap := map[string]string{} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index da3cfc39..aa38627c 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -125,6 +125,52 @@ func TestConvertOpenAIResponsesRequestToClaude_DropsIncompatibleReasoningSignatu } } +func TestConvertOpenAIResponsesRequestToClaude_KeepsToolUseAdjacentToToolResult(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "input":[ + { + "type":"function_call", + "call_id":"call_00_awGuheXs4aRbtedNK8LE3743", + "name":"js", + "arguments":"{\"code\":\"nodeRepl.write('ok')\",\"title\":\"List Obsidian vault contents\"}" + }, + { + "type":"message", + "role":"assistant", + "content":[{"type":"output_text","text":"I'll check your Obsidian vault for articles."}] + }, + { + "type":"function_call_output", + "call_id":"call_00_awGuheXs4aRbtedNK8LE3743", + "output":"Wall time: 0.1963 seconds\nOutput:\n[{\"type\":\"text\",\"text\":\"\"}]" + } + ] + }`) + + out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) + root := gjson.ParseBytes(out) + + if got := root.Get("messages.0.role").String(); got != "assistant" { + t.Fatalf("first message role = %q, want assistant. Output: %s", got, string(out)) + } + if got := root.Get("messages.0.content").String(); got != "I'll check your Obsidian vault for articles." { + t.Fatalf("first message content = %q, want assistant text. Output: %s", got, string(out)) + } + if got := root.Get("messages.1.content.0.type").String(); got != "tool_use" { + t.Fatalf("second message first content type = %q, want tool_use. Output: %s", got, string(out)) + } + if got := root.Get("messages.1.content.0.id").String(); got != "call_00_awGuheXs4aRbtedNK8LE3743" { + t.Fatalf("tool_use id = %q, want call_00_awGuheXs4aRbtedNK8LE3743. Output: %s", got, string(out)) + } + if got := root.Get("messages.2.content.0.type").String(); got != "tool_result" { + t.Fatalf("third message first content type = %q, want tool_result. Output: %s", got, string(out)) + } + if got := root.Get("messages.2.content.0.tool_use_id").String(); got != "call_00_awGuheXs4aRbtedNK8LE3743" { + t.Fatalf("tool_result id = %q, want call_00_awGuheXs4aRbtedNK8LE3743. Output: %s", got, string(out)) + } +} + func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) { t.Helper() channelBlock := []byte{}