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.
This commit is contained in:
Luis Pater
2026-06-17 13:23:51 +08:00
parent 644ba74bff
commit f23fb122e7
2 changed files with 86 additions and 5 deletions
@@ -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{}
@@ -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{}