Files
ds2api/internal/adapter/openai/message_normalize_test.go

327 lines
9.8 KiB
Go

package openai
import (
"strings"
"testing"
"ds2api/internal/util"
)
func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *testing.T) {
raw := []any{
map[string]any{"role": "system", "content": "You are helpful"},
map[string]any{"role": "user", "content": "查北京天气"},
map[string]any{
"role": "assistant",
"content": nil,
"tool_calls": []any{
map[string]any{
"id": "call_1",
"type": "function",
"function": map[string]any{
"name": "get_weather",
"arguments": "{\"city\":\"beijing\"}",
},
},
},
},
map[string]any{
"role": "tool",
"tool_call_id": "call_1",
"name": "get_weather",
"content": "{\"temp\":18}",
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 4 {
t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized))
}
assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(assistantContent, "<tool_calls>") {
t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent)
}
if !strings.Contains(assistantContent, "<tool_name>get_weather</tool_name>") {
t.Fatalf("expected tool name in preserved history, got %q", assistantContent)
}
if !strings.Contains(normalized[3]["content"].(string), `"temp":18`) {
t.Fatalf("tool result should be transparently forwarded, got %#v", normalized[3]["content"])
}
prompt := util.MessagesPrepare(normalized)
if !strings.Contains(prompt, "<tool_calls>") {
t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt)
}
}
func TestNormalizeOpenAIMessagesForPrompt_ToolObjectContentPreserved(t *testing.T) {
raw := []any{
map[string]any{
"role": "tool",
"tool_call_id": "call_2",
"name": "get_weather",
"content": map[string]any{
"temp": 18,
"condition": "sunny",
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"temp":18`) || !strings.Contains(got, `"condition":"sunny"`) {
t.Fatalf("expected serialized object in tool content, got %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_ToolArrayBlocksJoined(t *testing.T) {
raw := []any{
map[string]any{
"role": "tool",
"tool_call_id": "call_3",
"name": "read_file",
"content": []any{
map[string]any{"type": "input_text", "text": "line-1"},
map[string]any{"type": "output_text", "text": "line-2"},
map[string]any{"type": "image_url", "image_url": "https://example.com/a.png"},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `line-1`) || !strings.Contains(got, `line-2`) {
t.Fatalf("expected tool content blocks preserved, got %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_FunctionRoleCompatible(t *testing.T) {
raw := []any{
map[string]any{
"role": "function",
"tool_call_id": "call_4",
"name": "legacy_tool",
"content": map[string]any{
"ok": true,
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
if normalized[0]["role"] != "tool" {
t.Fatalf("expected function role normalized as tool, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"ok":true`) || strings.Contains(got, `"name":"legacy_tool"`) {
t.Fatalf("unexpected normalized function-role content: %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_EmptyToolContentPreservedAsNull(t *testing.T) {
raw := []any{
map[string]any{
"role": "tool",
"tool_call_id": "call_5",
"name": "noop_tool",
"content": "",
},
map[string]any{
"role": "assistant",
"content": "done",
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 2 {
t.Fatalf("expected tool completion turn to be preserved, got %#v", normalized)
}
if normalized[0]["role"] != "tool" {
t.Fatalf("expected tool role preserved, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if got != "null" {
t.Fatalf("expected empty tool content normalized as null string, got %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSeparated(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"tool_calls": []any{
map[string]any{
"id": "call_search",
"type": "function",
"function": map[string]any{
"name": "search_web",
"arguments": `{"query":"latest ai news"}`,
},
},
map[string]any{
"id": "call_eval",
"type": "function",
"function": map[string]any{
"name": "eval_javascript",
"arguments": `{"code":"1+1"}`,
},
},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if strings.Count(content, "<tool_call>") != 2 {
t.Fatalf("expected two preserved tool call blocks, got %q", content)
}
if !strings.Contains(content, "<tool_name>search_web</tool_name>") || !strings.Contains(content, "<tool_name>eval_javascript</tool_name>") {
t.Fatalf("expected both tool names in preserved history, got %q", content)
}
}
func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"tool_calls": []any{
map[string]any{
"id": "call_1",
"function": map[string]any{
"name": "search_web",
"arguments": `{}{"query":"测试工具调用"}`,
},
},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected assistant tool_call-only content preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, `{}{"query":"测试工具调用"}`) {
t.Fatalf("expected concatenated tool arguments preserved, got %q", content)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDropped(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"tool_calls": []any{
map[string]any{
"id": "call_missing_name",
"type": "function",
"function": map[string]any{
"arguments": `{"path":"README.MD"}`,
},
},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 {
t.Fatalf("expected assistant tool_calls without text to be dropped when name is missing, got %#v", normalized)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLiteral(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"content": nil,
"tool_calls": []any{
map[string]any{
"id": "call_screenshot",
"function": map[string]any{
"name": "send_file_to_user",
"arguments": `{"file_path":"/tmp/a.png"}`,
},
},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected nil-content assistant tool_call-only message preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if strings.Contains(content, "null") {
t.Fatalf("expected no null literal injection, got %q", content)
}
if !strings.Contains(content, "<tool_calls>") {
t.Fatalf("expected assistant tool history in normalized content, got %q", content)
}
}
func TestNormalizeOpenAIMessagesForPrompt_DeveloperRoleMapsToSystem(t *testing.T) {
raw := []any{
map[string]any{"role": "developer", "content": "必须先走工具调用"},
map[string]any{"role": "user", "content": "你好"},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 2 {
t.Fatalf("expected 2 normalized messages, got %d", len(normalized))
}
if normalized[0]["role"] != "system" {
t.Fatalf("expected developer role converted to system, got %#v", normalized[0]["role"])
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantArrayContentFallbackWhenTextEmpty(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{"type": "text", "text": "", "content": "工具说明文本"},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
content, _ := normalized[0]["content"].(string)
if content != "工具说明文本" {
t.Fatalf("expected content fallback text preserved, got %q", content)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantReasoningContentPreserved(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"content": "visible answer",
"reasoning_content": "internal reasoning",
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized assistant message, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, "[reasoning_content]") {
t.Fatalf("expected labeled reasoning block in assistant content, got %q", content)
}
if !strings.Contains(content, "internal reasoning") {
t.Fatalf("expected reasoning text in assistant content, got %q", content)
}
if !strings.Contains(content, "visible answer") {
t.Fatalf("expected visible answer in assistant content, got %q", content)
}
if reasoningIdx := strings.Index(content, "[reasoning_content]"); reasoningIdx < 0 || reasoningIdx > strings.Index(content, "visible answer") {
t.Fatalf("expected reasoning block before visible answer, got %q", content)
}
}