package engine import ( "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/query" ) // ============================================================ // Pipeline 测试 // ============================================================ // TestNewNormalizePipeline_Empty 空 Pipeline 不崩溃 func TestNewNormalizePipeline_Empty(t *testing.T) { p := NewNormalizePipeline() messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "hello"}}}, } result := p.Run(messages) if len(result) != 1 { t.Errorf("empty pipeline should pass through, got %d messages", len(result)) } } // TestNewNormalizePipeline_NilMessages nil 消息不崩溃 func TestNewNormalizePipeline_NilMessages(t *testing.T) { p := DefaultNormalizePipeline() result := p.Run(nil) if result != nil { t.Errorf("expected nil, got %v", result) } } // TestNewNormalizePipeline_EmptyMessages 空切片不崩溃 func TestNewNormalizePipeline_EmptyMessages(t *testing.T) { p := DefaultNormalizePipeline() result := p.Run([]query.Message{}) if len(result) != 0 { t.Errorf("expected 0, got %d", len(result)) } } // TestPipeline_PriorityOrder 验证步骤按 Priority 排序执行 func TestPipeline_PriorityOrder(t *testing.T) { var order []string p := NewNormalizePipeline( &trackingNormalizer{name: "c", priority: 30, order: &order}, &trackingNormalizer{name: "a", priority: 10, order: &order}, &trackingNormalizer{name: "b", priority: 20, order: &order}, ) messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "test"}}}, } p.Run(messages) if len(order) != 3 { t.Fatalf("expected 3 steps, got %d", len(order)) } if order[0] != "a" || order[1] != "b" || order[2] != "c" { t.Errorf("expected [a, b, c], got %v", order) } } // TestPipeline_AddAndRemove 动态添加和移除步骤 func TestPipeline_AddAndRemove(t *testing.T) { p := NewNormalizePipeline() p.Add(&EmptyMessageFilter{}) p.Add(&ConsecutiveRoleMerger{}) if !p.Remove("empty_message") { t.Error("should have removed empty_message") } if p.Remove("nonexistent") { t.Error("should not have removed nonexistent") } if len(p.steps) != 1 { t.Errorf("expected 1 step, got %d", len(p.steps)) } if p.steps[0].Name() != "consecutive_role" { t.Errorf("expected consecutive_role, got %s", p.steps[0].Name()) } } // trackingNormalizer 用于追踪执行顺序的测试辅助. type trackingNormalizer struct { name string priority int order *[]string } func (n *trackingNormalizer) Name() string { return n.name } func (n *trackingNormalizer) Priority() int { return n.priority } func (n *trackingNormalizer) Normalize(messages []query.Message) []query.Message { *n.order = append(*n.order, n.name) return messages } // ============================================================ // OrphanToolResultRemover 测试 // ============================================================ func TestOrphanToolResultRemover_RemovesOrphan(t *testing.T) { r := &OrphanToolResultRemover{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "hello"}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentToolUse, ID: "tool_1", Name: "bash"}, }, }, { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentToolResult, ToolUseID: "tool_1", Text: "ok"}, {Type: query.ContentToolResult, ToolUseID: "tool_999", Text: "orphan"}, }, }, } result := r.Normalize(messages) if len(result) != 3 { t.Fatalf("expected 3 messages, got %d", len(result)) } if len(result[2].Content) != 1 { t.Fatalf("expected 1 content block, got %d", len(result[2].Content)) } if result[2].Content[0].ToolUseID != "tool_1" { t.Errorf("expected tool_1, got %s", result[2].Content[0].ToolUseID) } } func TestOrphanToolResultRemover_RemovesEntireMessage(t *testing.T) { r := &OrphanToolResultRemover{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentToolResult, ToolUseID: "nonexistent", Text: "orphan"}, }, }, } result := r.Normalize(messages) if len(result) != 0 { t.Errorf("expected 0 messages, got %d", len(result)) } } func TestOrphanToolResultRemover_PreservesMetadata(t *testing.T) { r := &OrphanToolResultRemover{} messages := []query.Message{ { Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentToolUse, ID: "t1", Name: "bash"}}, Metadata: map[string]any{"key": "value"}, }, { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentToolResult, ToolUseID: "t1", Text: "ok"}}, }, } result := r.Normalize(messages) if result[0].Metadata["key"] != "value" { t.Error("metadata should be preserved") } } // ============================================================ // ErrorContentStripper 测试 // ============================================================ func TestErrorContentStripper_StripsOnError(t *testing.T) { s := &ErrorContentStripper{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentImage, Text: "my_photo", SizeBytes: 100}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "Error: image too large for API", IsError: true}, }, }, } result := s.Normalize(messages) // 图片应被剥离,替换为文本提示 if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } if result[0].Content[0].Type != query.ContentText { t.Errorf("expected text replacement, got %s", result[0].Content[0].Type) } } func TestErrorContentStripper_NoErrorKeepsAll(t *testing.T) { s := &ErrorContentStripper{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentImage, Text: "good_photo"}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "Nice photo!"}, }, }, } result := s.Normalize(messages) if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } if result[0].Content[0].Type != query.ContentImage { t.Error("image should be preserved when no error") } } func TestErrorContentStripper_CustomPatterns(t *testing.T) { s := &ErrorContentStripper{ Patterns: map[string][]query.ContentType{ "sensor malfunction": {query.ContentDocument}, }, } messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentDocument, Text: "sensor_data"}, {Type: query.ContentText, Text: "please analyze"}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "Sensor Malfunction detected in data", IsError: true}, }, }, } result := s.Normalize(messages) if len(result[0].Content) != 1 { t.Fatalf("expected 1 content block after strip, got %d", len(result[0].Content)) } if result[0].Content[0].Type != query.ContentText { t.Error("text should be preserved, document stripped") } } // ============================================================ // OrphanThinkingFilter 测试 // ============================================================ func TestOrphanThinkingFilter_RemovesThinkingOnly(t *testing.T) { f := &OrphanThinkingFilter{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "hello"}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentThinking, Text: "let me think..."}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentThinking, Text: "thinking"}, {Type: query.ContentText, Text: "response"}, }, }, } result := f.Normalize(messages) if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } // 第二条 assistant(有 thinking + text)应保留 if len(result[1].Content) != 2 { t.Error("assistant with thinking+text should be preserved") } } func TestOrphanThinkingFilter_KeepsUserMessages(t *testing.T) { f := &OrphanThinkingFilter{} // user 消息不应受影响(虽然实际上 user 不会有 thinking) messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentThinking, Text: "hypothetical"}, }, }, } result := f.Normalize(messages) if len(result) != 1 { t.Error("user messages should not be filtered") } } // ============================================================ // EmptyMessageFilter 测试 // ============================================================ func TestEmptyMessageFilter_RemovesEmpty(t *testing.T) { f := &EmptyMessageFilter{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "hello"}, }, }, { Role: query.RoleAssistant, Content: nil, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: ""}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "response"}, }, }, } result := f.Normalize(messages) if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } } func TestEmptyMessageFilter_KeepsToolBlocks(t *testing.T) { f := &EmptyMessageFilter{} messages := []query.Message{ { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: ""}, // 空文本 {Type: query.ContentToolUse, ID: "t1", Name: "bash"}, }, }, } result := f.Normalize(messages) if len(result) != 1 { t.Error("message with tool_use should be kept even with empty text") } } // ============================================================ // WhitespaceAssistantFilter 测试 // ============================================================ func TestWhitespaceAssistantFilter_RemovesWhitespace(t *testing.T) { f := &WhitespaceAssistantFilter{} messages := []query.Message{ { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "\n\n"}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: " "}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "actual response"}, }, }, } result := f.Normalize(messages) if len(result) != 1 { t.Fatalf("expected 1 message, got %d", len(result)) } if result[0].Content[0].Text != "actual response" { t.Error("wrong message kept") } } func TestWhitespaceAssistantFilter_KeepsUserWhitespace(t *testing.T) { f := &WhitespaceAssistantFilter{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "\n"}, }, }, } result := f.Normalize(messages) if len(result) != 1 { t.Error("user whitespace messages should be kept") } } func TestWhitespaceAssistantFilter_KeepsNonTextBlocks(t *testing.T) { f := &WhitespaceAssistantFilter{} messages := []query.Message{ { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: " "}, {Type: query.ContentToolUse, ID: "t1", Name: "bash"}, }, }, } result := f.Normalize(messages) if len(result) != 1 { t.Error("assistant with tool_use should be kept") } } // ============================================================ // ToolUseInputNormalizer 测试 // ============================================================ func TestToolUseInputNormalizer_StripsInternalFields(t *testing.T) { n := &ToolUseInputNormalizer{} messages := []query.Message{ { Role: query.RoleAssistant, Content: []query.Content{ { Type: query.ContentToolUse, ID: "t1", Name: "bash", Input: map[string]any{"command": "ls", "plan": "list files", "_debug": true}, }, }, }, } result := n.Normalize(messages) input := result[0].Content[0].Input if _, ok := input["plan"]; ok { t.Error("plan field should be stripped") } if _, ok := input["_debug"]; ok { t.Error("_debug field should be stripped") } if _, ok := input["command"]; !ok { t.Error("command field should be preserved") } } func TestToolUseInputNormalizer_Aliases(t *testing.T) { n := &ToolUseInputNormalizer{ Aliases: map[string]string{"scan_barcode": "barcode_scanner"}, } messages := []query.Message{ { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentToolUse, ID: "t1", Name: "scan_barcode"}, }, }, } result := n.Normalize(messages) if result[0].Content[0].Name != "barcode_scanner" { t.Errorf("expected barcode_scanner, got %s", result[0].Content[0].Name) } } func TestToolUseInputNormalizer_SkipsUserMessages(t *testing.T) { n := &ToolUseInputNormalizer{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "hello"}, }, }, } result := n.Normalize(messages) if len(result) != 1 || result[0].Content[0].Text != "hello" { t.Error("user messages should pass through unchanged") } } // ============================================================ // ConsecutiveRoleMerger 测试 // ============================================================ func TestConsecutiveRoleMerger_MergesUser(t *testing.T) { m := &ConsecutiveRoleMerger{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "first"}}, }, { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "second"}}, }, { Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "response"}}, }, } result := m.Normalize(messages) if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } if len(result[0].Content) != 2 { t.Fatalf("expected 2 content blocks, got %d", len(result[0].Content)) } if result[0].Content[0].Text != "first" || result[0].Content[1].Text != "second" { t.Error("merged content should preserve order") } } func TestConsecutiveRoleMerger_MergesAssistant(t *testing.T) { m := &ConsecutiveRoleMerger{} messages := []query.Message{ { Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "part1"}}, }, { Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "part2"}}, }, } result := m.Normalize(messages) if len(result) != 1 { t.Fatalf("expected 1 message, got %d", len(result)) } if len(result[0].Content) != 2 { t.Error("should merge content blocks") } } func TestConsecutiveRoleMerger_NoMergeAlternating(t *testing.T) { m := &ConsecutiveRoleMerger{} messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "q1"}}}, {Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "a1"}}}, {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "q2"}}}, } result := m.Normalize(messages) if len(result) != 3 { t.Errorf("alternating messages should not be merged, got %d", len(result)) } } func TestConsecutiveRoleMerger_MergesMetadata(t *testing.T) { m := &ConsecutiveRoleMerger{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "a"}}, Metadata: map[string]any{"key1": "v1"}, }, { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "b"}}, Metadata: map[string]any{"key2": "v2"}, }, } result := m.Normalize(messages) if len(result) != 1 { t.Fatalf("expected 1 message, got %d", len(result)) } if result[0].Metadata["key1"] != "v1" || result[0].Metadata["key2"] != "v2" { t.Error("metadata should be merged") } } func TestConsecutiveRoleMerger_SingleMessage(t *testing.T) { m := &ConsecutiveRoleMerger{} messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "only"}}}, } result := m.Normalize(messages) if len(result) != 1 { t.Errorf("single message should pass through, got %d", len(result)) } } // ============================================================ // ImageValidator 测试 // ============================================================ func TestImageValidator_ReplacesOversize(t *testing.T) { v := &ImageValidator{MaxSizeBytes: 1024} // 1KB limit for test messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentImage, Text: "huge_photo", SizeBytes: 2048}, }, }, } result := v.Normalize(messages) if result[0].Content[0].Type != query.ContentText { t.Error("oversize image should be replaced with text") } if result[0].Content[0].Text == "" { t.Error("replacement text should not be empty") } } func TestImageValidator_KeepsSmallImages(t *testing.T) { v := &ImageValidator{MaxSizeBytes: 1024} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentImage, Text: "small_photo", SizeBytes: 512}, }, }, } result := v.Normalize(messages) if result[0].Content[0].Type != query.ContentImage { t.Error("small image should be preserved") } } func TestImageValidator_DefaultMaxSize(t *testing.T) { v := &ImageValidator{} // 使用默认值 if v.maxSize() != DefaultMaxImageSizeBytes { t.Errorf("expected default %d, got %d", DefaultMaxImageSizeBytes, v.maxSize()) } } func TestImageValidator_ZeroSizeImage(t *testing.T) { v := &ImageValidator{MaxSizeBytes: 1024} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentImage, Text: "unknown_size", SizeBytes: 0}, }, }, } result := v.Normalize(messages) // SizeBytes=0 不超过限制,保留 if result[0].Content[0].Type != query.ContentImage { t.Error("zero-size image should be preserved (size unknown)") } } // ============================================================ // AttachmentReorderer 测试 // ============================================================ func TestAttachmentReorderer_ReordersAttachments(t *testing.T) { r := &AttachmentReorderer{} messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentToolResult, ToolUseID: "t1", Text: "result"}}, }, { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "attachment data"}}, Metadata: map[string]any{"is_attachment": true}, }, } result := r.Normalize(messages) if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } // 附件应该在前 if result[0].Metadata == nil || result[0].Metadata["is_attachment"] != true { t.Error("attachment should be first") } } func TestAttachmentReorderer_NoAttachments(t *testing.T) { r := &AttachmentReorderer{} messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "hello"}}}, {Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "hi"}}}, } result := r.Normalize(messages) if len(result) != 2 { t.Errorf("expected 2 messages, got %d", len(result)) } } func TestAttachmentReorderer_DoesNotCrossRoleBoundary(t *testing.T) { r := &AttachmentReorderer{} messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "q"}}}, {Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "a"}}}, { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "attachment"}}, Metadata: map[string]any{"is_attachment": true}, }, } result := r.Normalize(messages) // assistant 消息应该保持在中间 if result[1].Role != query.RoleAssistant { t.Error("assistant message should stay in place") } } // ============================================================ // Pipeline 集成测试 // ============================================================ func TestDefaultPipeline_IntegrationTest(t *testing.T) { messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "hello"}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentToolUse, ID: "tool_1", Name: "bash"}, }, }, { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentToolResult, ToolUseID: "tool_1", Text: "ok"}, {Type: query.ContentToolResult, ToolUseID: "tool_999", Text: "orphan"}, }, }, // 空 assistant 消息 { Role: query.RoleAssistant, Content: nil, }, // 纯 thinking assistant { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentThinking, Text: "hmm"}, }, }, // 正常 assistant { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "final response"}, }, }, } result := DefaultNormalizePipeline().Run(messages) // 验证: // 1. orphan tool_result 被移除 // 2. 空 assistant 被移除 // 3. 纯 thinking assistant 被移除 // 4. 连续 assistant 被合并(如果有的话) // 最终应该是:user, assistant(tool_use), user(tool_result), assistant(final) if len(result) != 4 { t.Fatalf("expected 4 messages, got %d", len(result)) } // 第三条消息应该只有一个 tool_result if len(result[2].Content) != 1 { t.Errorf("expected 1 content block in msg[2], got %d", len(result[2].Content)) } if result[2].Content[0].ToolUseID != "tool_1" { t.Errorf("expected tool_1, got %s", result[2].Content[0].ToolUseID) } // 最后一条应该是 final response if result[3].Content[0].Text != "final response" { t.Errorf("expected 'final response', got '%s'", result[3].Content[0].Text) } } // TestDefaultPipeline_BackwardCompatible 验证向后兼容 func TestDefaultPipeline_BackwardCompatible(t *testing.T) { // 与早期方案 NormalizeMessagesForAPI 测试相同的用例 messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "first"}, }, }, { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "second"}, }, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentText, Text: "response"}, }, }, } result := NormalizeMessagesForAPI(messages) // 两个连续 user 消息应被合并 if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } if len(result[0].Content) != 2 { t.Fatalf("expected 2 content blocks in merged msg, got %d", len(result[0].Content)) } } // ============================================================ // formatBytes 辅助函数测试 // ============================================================ func TestFormatBytes(t *testing.T) { tests := []struct { input int64 expected string }{ {0, "0B"}, {512, "512B"}, {1024, "1.0KB"}, {1536, "1.5KB"}, {1048576, "1.0MB"}, {1073741824, "1.0GB"}, } for _, tt := range tests { got := formatBytes(tt.input) if got != tt.expected { t.Errorf("formatBytes(%d) = %s, want %s", tt.input, got, tt.expected) } } }