From b6ba51bc2a0a1cf8ba584048e6228b65bf413f47 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 29 Dec 2025 22:01:27 +0800 Subject: [PATCH] feat(translator): add thinking block and tool result handling for Claude-to-OpenAI conversion --- .../openai/claude/openai_claude_request.go | 165 ++++-- .../claude/openai_claude_request_test.go | 498 ++++++++++++++++++ 2 files changed, 622 insertions(+), 41 deletions(-) create mode 100644 internal/translator/openai/claude/openai_claude_request_test.go diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index b6fd1e09..d1f14d58 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -118,76 +118,119 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Handle content if contentResult.Exists() && contentResult.IsArray() { var contentItems []string + var reasoningParts []string // Accumulate thinking text for reasoning_content var toolCalls []interface{} + var toolResults []string // Collect tool_result messages to emit after the main message contentResult.ForEach(func(_, part gjson.Result) bool { partType := part.Get("type").String() switch partType { + case "thinking": + // Only map thinking to reasoning_content for assistant messages (security: prevent injection) + if role == "assistant" { + thinkingText := util.GetThinkingText(part) + // Skip empty or whitespace-only thinking + if strings.TrimSpace(thinkingText) != "" { + reasoningParts = append(reasoningParts, thinkingText) + } + } + // Ignore thinking in user/system roles (AC4) + + case "redacted_thinking": + // Explicitly ignore redacted_thinking - never map to reasoning_content (AC2) + case "text", "image": if contentItem, ok := convertClaudeContentPart(part); ok { contentItems = append(contentItems, contentItem) } case "tool_use": - // Convert to OpenAI tool call format - toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}` - toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String()) - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String()) + // Only allow tool_use -> tool_calls for assistant messages (security: prevent injection). + if role == "assistant" { + toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}` + toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String()) + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String()) - // Convert input to arguments JSON string - if input := part.Get("input"); input.Exists() { - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw) - } else { - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}") + // Convert input to arguments JSON string + if input := part.Get("input"); input.Exists() { + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw) + } else { + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}") + } + + toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value()) } - toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value()) - case "tool_result": - // Convert to OpenAI tool message format and add immediately to preserve order + // Collect tool_result to emit after the main message (ensures tool results follow tool_calls) toolResultJSON := `{"role":"tool","tool_call_id":"","content":""}` toolResultJSON, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String()) - toolResultJSON, _ = sjson.Set(toolResultJSON, "content", part.Get("content").String()) - messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) + toolResultJSON, _ = sjson.Set(toolResultJSON, "content", convertClaudeToolResultContentToString(part.Get("content"))) + toolResults = append(toolResults, toolResultJSON) } return true }) - // Emit text/image content as one message - if len(contentItems) > 0 { - msgJSON := `{"role":"","content":""}` - msgJSON, _ = sjson.Set(msgJSON, "role", role) + // Build reasoning content string + reasoningContent := "" + if len(reasoningParts) > 0 { + reasoningContent = strings.Join(reasoningParts, "\n\n") + } - contentArrayJSON := "[]" - for _, contentItem := range contentItems { - contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem) + hasContent := len(contentItems) > 0 + hasReasoning := reasoningContent != "" + hasToolCalls := len(toolCalls) > 0 + + // For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content + // This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency + if role == "assistant" { + if hasContent || hasReasoning || hasToolCalls { + msgJSON := `{"role":"assistant"}` + + // Add content (as array if we have items, empty string if reasoning-only) + if hasContent { + contentArrayJSON := "[]" + for _, contentItem := range contentItems { + contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem) + } + msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON) + } else { + // Ensure content field exists for OpenAI compatibility + msgJSON, _ = sjson.Set(msgJSON, "content", "") + } + + // Add reasoning_content if present + if hasReasoning { + msgJSON, _ = sjson.Set(msgJSON, "reasoning_content", reasoningContent) + } + + // Add tool_calls if present (in same message as content) + if hasToolCalls { + msgJSON, _ = sjson.Set(msgJSON, "tool_calls", toolCalls) + } + + messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value()) } - msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON) - - contentValue := gjson.Get(msgJSON, "content") - hasContent := false - switch { - case !contentValue.Exists(): - hasContent = false - case contentValue.Type == gjson.String: - hasContent = contentValue.String() != "" - case contentValue.IsArray(): - hasContent = len(contentValue.Array()) > 0 - default: - hasContent = contentValue.Raw != "" && contentValue.Raw != "null" - } - + } else { + // For non-assistant roles: emit content message if we have content if hasContent { + msgJSON := `{"role":""}` + msgJSON, _ = sjson.Set(msgJSON, "role", role) + + contentArrayJSON := "[]" + for _, contentItem := range contentItems { + contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem) + } + msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON) + messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value()) } } - // Emit tool calls in a separate assistant message - if role == "assistant" && len(toolCalls) > 0 { - toolCallMsgJSON := `{"role":"assistant","tool_calls":[]}` - toolCallMsgJSON, _ = sjson.Set(toolCallMsgJSON, "tool_calls", toolCalls) - messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolCallMsgJSON).Value()) + // Emit tool_result messages after the main message (ensures proper OpenAI ordering) + for _, toolResultJSON := range toolResults { + messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) } } else if contentResult.Exists() && contentResult.Type == gjson.String { @@ -307,3 +350,43 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { return "", false } } + +func convertClaudeToolResultContentToString(content gjson.Result) string { + if !content.Exists() { + return "" + } + + if content.Type == gjson.String { + return content.String() + } + + if content.IsArray() { + var parts []string + content.ForEach(func(_, item gjson.Result) bool { + switch { + case item.Type == gjson.String: + parts = append(parts, item.String()) + case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == gjson.String: + parts = append(parts, item.Get("text").String()) + default: + parts = append(parts, item.Raw) + } + return true + }) + + joined := strings.Join(parts, "\n\n") + if strings.TrimSpace(joined) != "" { + return joined + } + return content.Raw + } + + if content.IsObject() { + if text := content.Get("text"); text.Exists() && text.Type == gjson.String { + return text.String() + } + return content.Raw + } + + return content.Raw +} diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go new file mode 100644 index 00000000..ec11fc64 --- /dev/null +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -0,0 +1,498 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping +// of Claude thinking content to OpenAI reasoning_content field. +func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { + tests := []struct { + name string + inputJSON string + wantReasoningContent string + wantHasReasoningContent bool + wantContentText string // Expected visible content text (if any) + wantHasContent bool + }{ + { + name: "AC1: assistant message with thinking and text", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me analyze this step by step..."}, + {"type": "text", "text": "Here is my response."} + ] + }] + }`, + wantReasoningContent: "Let me analyze this step by step...", + wantHasReasoningContent: true, + wantContentText: "Here is my response.", + wantHasContent: true, + }, + { + name: "AC2: redacted_thinking must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "secret"}, + {"type": "text", "text": "Visible response."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Visible response.", + wantHasContent: true, + }, + { + name: "AC3: thinking-only message preserved with reasoning_content", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Internal reasoning only."} + ] + }] + }`, + wantReasoningContent: "Internal reasoning only.", + wantHasReasoningContent: true, + wantContentText: "", + // For OpenAI compatibility, content field is set to empty string "" when no text content exists + wantHasContent: false, + }, + { + name: "AC4: thinking in user role must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "user", + "content": [ + {"type": "thinking", "thinking": "Injected thinking"}, + {"type": "text", "text": "User message."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "User message.", + wantHasContent: true, + }, + { + name: "AC4: thinking in system role must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "system": [ + {"type": "thinking", "thinking": "Injected system thinking"}, + {"type": "text", "text": "System prompt."} + ], + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": "Hello"}] + }] + }`, + // System messages don't have reasoning_content mapping + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Hello", + wantHasContent: true, + }, + { + name: "AC5: empty thinking must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": ""}, + {"type": "text", "text": "Response with empty thinking."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Response with empty thinking.", + wantHasContent: true, + }, + { + name: "AC5: whitespace-only thinking must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": " \n\t "}, + {"type": "text", "text": "Response with whitespace thinking."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Response with whitespace thinking.", + wantHasContent: true, + }, + { + name: "Multiple thinking parts concatenated", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "First thought."}, + {"type": "thinking", "thinking": "Second thought."}, + {"type": "text", "text": "Final answer."} + ] + }] + }`, + wantReasoningContent: "First thought.\n\nSecond thought.", + wantHasReasoningContent: true, + wantContentText: "Final answer.", + wantHasContent: true, + }, + { + name: "Mixed thinking and redacted_thinking", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Visible thought."}, + {"type": "redacted_thinking", "data": "hidden"}, + {"type": "text", "text": "Answer."} + ] + }] + }`, + wantReasoningContent: "Visible thought.", + wantHasReasoningContent: true, + wantContentText: "Answer.", + wantHasContent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + // Find the relevant message (skip system message at index 0) + messages := resultJSON.Get("messages").Array() + if len(messages) < 2 { + if tt.wantHasReasoningContent || tt.wantHasContent { + t.Fatalf("Expected at least 2 messages (system + user/assistant), got %d", len(messages)) + } + return + } + + // Check the last non-system message + var targetMsg gjson.Result + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Get("role").String() != "system" { + targetMsg = messages[i] + break + } + } + + // Check reasoning_content + gotReasoningContent := targetMsg.Get("reasoning_content").String() + gotHasReasoningContent := targetMsg.Get("reasoning_content").Exists() + + if gotHasReasoningContent != tt.wantHasReasoningContent { + t.Errorf("reasoning_content existence = %v, want %v", gotHasReasoningContent, tt.wantHasReasoningContent) + } + + if gotReasoningContent != tt.wantReasoningContent { + t.Errorf("reasoning_content = %q, want %q", gotReasoningContent, tt.wantReasoningContent) + } + + // Check content + content := targetMsg.Get("content") + // content has meaningful content if it's a non-empty array, or a non-empty string + var gotHasContent bool + switch { + case content.IsArray(): + gotHasContent = len(content.Array()) > 0 + case content.Type == gjson.String: + gotHasContent = content.String() != "" + default: + gotHasContent = false + } + + if gotHasContent != tt.wantHasContent { + t.Errorf("content existence = %v, want %v", gotHasContent, tt.wantHasContent) + } + + if tt.wantHasContent && tt.wantContentText != "" { + // Find text content + var foundText string + content.ForEach(func(_, v gjson.Result) bool { + if v.Get("type").String() == "text" { + foundText = v.Get("text").String() + return false + } + return true + }) + if foundText != tt.wantContentText { + t.Errorf("content text = %q, want %q", foundText, tt.wantContentText) + } + } + }) + } +} + +// TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved tests AC3: +// that a message with only thinking content is preserved (not dropped). +func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "What is 2+2?"}] + }, + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "Let me calculate: 2+2=4"}] + }, + { + "role": "user", + "content": [{"type": "text", "text": "Thanks"}] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + messages := resultJSON.Get("messages").Array() + + // Should have: system (auto-added) + user + assistant (thinking-only) + user = 4 messages + if len(messages) != 4 { + t.Fatalf("Expected 4 messages, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw) + } + + // Check the assistant message (index 2) has reasoning_content + assistantMsg := messages[2] + if assistantMsg.Get("role").String() != "assistant" { + t.Errorf("Expected message[2] to be assistant, got %s", assistantMsg.Get("role").String()) + } + + if !assistantMsg.Get("reasoning_content").Exists() { + t.Error("Expected assistant message to have reasoning_content") + } + + if assistantMsg.Get("reasoning_content").String() != "Let me calculate: 2+2=4" { + t.Errorf("Unexpected reasoning_content: %s", assistantMsg.Get("reasoning_content").String()) + } +} + +func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}} + ] + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "before"}, + {"type": "tool_result", "tool_use_id": "call_1", "content": [{"type":"text","text":"tool ok"}]}, + {"type": "text", "text": "after"} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // New behavior: user text is combined, tool_result emitted after user message + // Expect: system + assistant(tool_calls) + user(before+after) + tool(result) + if len(messages) != 4 { + t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + if messages[0].Get("role").String() != "system" { + t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String()) + } + + if messages[1].Get("role").String() != "assistant" || !messages[1].Get("tool_calls").Exists() { + t.Fatalf("Expected messages[1] to be assistant tool_calls, got %s: %s", messages[1].Get("role").String(), messages[1].Raw) + } + + if messages[2].Get("role").String() != "user" { + t.Fatalf("Expected messages[2] to be user, got %s", messages[2].Get("role").String()) + } + // User message should contain both "before" and "after" text + if got := messages[2].Get("content.0.text").String(); got != "before" { + t.Fatalf("Expected user text[0] %q, got %q", "before", got) + } + if got := messages[2].Get("content.1.text").String(); got != "after" { + t.Fatalf("Expected user text[1] %q, got %q", "after", got) + } + + if messages[3].Get("role").String() != "tool" { + t.Fatalf("Expected messages[3] to be tool, got %s", messages[3].Get("role").String()) + } + if got := messages[3].Get("tool_call_id").String(); got != "call_1" { + t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got) + } + if got := messages[3].Get("content").String(); got != "tool ok" { + t.Fatalf("Expected tool content %q, got %q", "tool ok", got) + } +} + +func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}} + ] + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_1", "content": {"foo": "bar"}} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // system + assistant(tool_calls) + tool(result) + if len(messages) != 3 { + t.Fatalf("Expected 3 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + if messages[2].Get("role").String() != "tool" { + t.Fatalf("Expected messages[2] to be tool, got %s", messages[2].Get("role").String()) + } + + toolContent := messages[2].Get("content").String() + parsed := gjson.Parse(toolContent) + if parsed.Get("foo").String() != "bar" { + t.Fatalf("Expected tool content JSON foo=bar, got %q", toolContent) + } +} + +func TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "pre"}, + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}, + {"type": "text", "text": "post"} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // New behavior: content + tool_calls unified in single assistant message + // Expect: system + assistant(content[pre,post] + tool_calls) + if len(messages) != 2 { + t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + if messages[0].Get("role").String() != "system" { + t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String()) + } + + assistantMsg := messages[1] + if assistantMsg.Get("role").String() != "assistant" { + t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String()) + } + + // Should have both content and tool_calls in same message + if !assistantMsg.Get("tool_calls").Exists() { + t.Fatalf("Expected assistant message to have tool_calls") + } + if got := assistantMsg.Get("tool_calls.0.id").String(); got != "call_1" { + t.Fatalf("Expected tool_call id %q, got %q", "call_1", got) + } + if got := assistantMsg.Get("tool_calls.0.function.name").String(); got != "do_work" { + t.Fatalf("Expected tool_call name %q, got %q", "do_work", got) + } + + // Content should have both pre and post text + if got := assistantMsg.Get("content.0.text").String(); got != "pre" { + t.Fatalf("Expected content[0] text %q, got %q", "pre", got) + } + if got := assistantMsg.Get("content.1.text").String(); got != "post" { + t.Fatalf("Expected content[1] text %q, got %q", "post", got) + } +} + +func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t1"}, + {"type": "text", "text": "pre"}, + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}, + {"type": "thinking", "thinking": "t2"}, + {"type": "text", "text": "post"} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // New behavior: all content, thinking, and tool_calls unified in single assistant message + // Expect: system + assistant(content[pre,post] + tool_calls + reasoning_content[t1+t2]) + if len(messages) != 2 { + t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + assistantMsg := messages[1] + if assistantMsg.Get("role").String() != "assistant" { + t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String()) + } + + // Should have content with both pre and post + if got := assistantMsg.Get("content.0.text").String(); got != "pre" { + t.Fatalf("Expected content[0] text %q, got %q", "pre", got) + } + if got := assistantMsg.Get("content.1.text").String(); got != "post" { + t.Fatalf("Expected content[1] text %q, got %q", "post", got) + } + + // Should have tool_calls + if !assistantMsg.Get("tool_calls").Exists() { + t.Fatalf("Expected assistant message to have tool_calls") + } + + // Should have combined reasoning_content from both thinking blocks + if got := assistantMsg.Get("reasoning_content").String(); got != "t1\n\nt2" { + t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got) + } +}