package anthropic import ( "encoding/json" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // --- flytoMessageToAPI --- func TestFlytoMessageToAPI_PureText(t *testing.T) { msg := flyto.UserText("hello world") rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } if rm.Role != "user" { t.Errorf("Role = %q, want user", rm.Role) } // pure text should serialize as a JSON string, not an array var text string if err := json.Unmarshal(rm.Content, &text); err != nil { t.Fatalf("Content should be a JSON string for pure text: %v", err) } if text != "hello world" { t.Errorf("text = %q", text) } } func TestFlytoMessageToAPI_MultiBlock(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.TextBlock("thinking..."), flyto.TextBlock("answer"), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } // multi-block should serialize as a JSON array var blocks []json.RawMessage if err := json.Unmarshal(rm.Content, &blocks); err != nil { t.Fatalf("Content should be a JSON array for multi-block: %v", err) } if len(blocks) != 2 { t.Fatalf("expected 2 blocks, got %d", len(blocks)) } } func TestFlytoMessageToAPI_ToolUse(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.ToolUseBlock("call_1", "Bash", map[string]any{"command": "ls"}), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } if rm.Role != "assistant" { t.Errorf("Role = %q", rm.Role) } var blocks []map[string]any if err := json.Unmarshal(rm.Content, &blocks); err != nil { t.Fatalf("unmarshal blocks: %v", err) } if len(blocks) != 1 { t.Fatalf("expected 1 block, got %d", len(blocks)) } if blocks[0]["type"] != "tool_use" { t.Errorf("type = %v", blocks[0]["type"]) } if blocks[0]["id"] != "call_1" { t.Errorf("id = %v", blocks[0]["id"]) } if blocks[0]["name"] != "Bash" { t.Errorf("name = %v", blocks[0]["name"]) } } func TestFlytoMessageToAPI_ToolResult(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleUser, Blocks: []flyto.Block{ flyto.ToolResultBlock("call_1", "file1.go\nfile2.go", false), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } var blocks []map[string]any if err := json.Unmarshal(rm.Content, &blocks); err != nil { t.Fatalf("unmarshal blocks: %v", err) } if blocks[0]["type"] != "tool_result" { t.Errorf("type = %v", blocks[0]["type"]) } if blocks[0]["tool_use_id"] != "call_1" { t.Errorf("tool_use_id = %v", blocks[0]["tool_use_id"]) } } func TestFlytoMessageToAPI_ToolResultError(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleUser, Blocks: []flyto.Block{ flyto.ToolResultBlock("call_e", "not found", true), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } var blocks []map[string]any _ = json.Unmarshal(rm.Content, &blocks) if blocks[0]["is_error"] != true { t.Errorf("is_error = %v, want true", blocks[0]["is_error"]) } } func TestFlytoMessageToAPI_ThinkingBlock(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleAssistant, Blocks: []flyto.Block{ { Type: flyto.BlockThinking, ThinkingText: "Let me analyze...", ProviderMetadata: map[string]string{"thinking_signature": "sig123"}, }, flyto.TextBlock("The answer is 42."), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } var blocks []map[string]any if err := json.Unmarshal(rm.Content, &blocks); err != nil { t.Fatalf("unmarshal blocks: %v", err) } if len(blocks) != 2 { t.Fatalf("expected 2 blocks, got %d", len(blocks)) } // first block should be thinking if blocks[0]["type"] != "thinking" { t.Errorf("first block type = %v, want thinking", blocks[0]["type"]) } if blocks[0]["signature"] != "sig123" { t.Errorf("signature = %v, want sig123", blocks[0]["signature"]) } } // TestFlytoMessageToAPI_ImageBlockBase64 locks the wire shape for // user-inline images: engine constructs flyto.ImageBlockBase64(mediaType, data), // provider translates to api.ContentBlock{Type:"image", Source:{Type:"base64", // MediaType, Data}}. Shape verified 2026-04 against Anthropic Messages API // docs (tool_result content array, standalone image blocks). // // TestFlytoMessageToAPI_ImageBlockBase64 锁定用户粘图的 wire shape: engine // 构造 flyto.ImageBlockBase64, provider 转为 api.ContentBlock image source // 对象. shape 2026-04 对齐 Anthropic Messages API spec. func TestFlytoMessageToAPI_ImageBlockBase64(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleUser, Blocks: []flyto.Block{ flyto.TextBlock("What is in this image?"), flyto.ImageBlockBase64("image/png", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } var blocks []map[string]any if err := json.Unmarshal(rm.Content, &blocks); err != nil { t.Fatalf("unmarshal blocks: %v", err) } if len(blocks) != 2 { t.Fatalf("expected 2 blocks, got %d", len(blocks)) } if blocks[1]["type"] != "image" { t.Errorf("second block type = %v, want image", blocks[1]["type"]) } src, ok := blocks[1]["source"].(map[string]any) if !ok { t.Fatalf("image block missing source map, got %+v", blocks[1]) } if src["type"] != "base64" { t.Errorf("source.type = %v, want base64", src["type"]) } if src["media_type"] != "image/png" { t.Errorf("source.media_type = %v, want image/png", src["media_type"]) } if data, _ := src["data"].(string); data == "" { t.Errorf("source.data should carry base64 bytes") } } // TestFlytoMessageToAPI_ImageBlockURL locks URL-form image wire shape. // // TestFlytoMessageToAPI_ImageBlockURL 锁定 URL 形式图片 block 的 wire shape. func TestFlytoMessageToAPI_ImageBlockURL(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleUser, Blocks: []flyto.Block{ flyto.ImageBlockURL("https://example.com/cat.jpg"), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } var blocks []map[string]any if err := json.Unmarshal(rm.Content, &blocks); err != nil { t.Fatalf("unmarshal: %v", err) } if len(blocks) != 1 || blocks[0]["type"] != "image" { t.Fatalf("expected single image block, got %+v", blocks) } src, ok := blocks[0]["source"].(map[string]any) if !ok { t.Fatalf("missing source") } if src["type"] != "url" { t.Errorf("source.type = %v, want url", src["type"]) } if src["url"] != "https://example.com/cat.jpg" { t.Errorf("source.url = %v", src["url"]) } // base64-only fields (media_type, data) must omit when url-form. if _, hasMT := src["media_type"]; hasMT { t.Errorf("url-form source should not carry media_type, got %v", src["media_type"]) } if _, hasData := src["data"]; hasData { t.Errorf("url-form source should not carry data field") } } // TestFlytoMessageToAPI_ImageBlockMissingSource ensures the provider returns // an error (not silently drop) when an ill-formed BlockImage arrives without // ImageSource. Prevents the L699/022ea20-class "choke-point silently swallows // field" regression. // // TestFlytoMessageToAPI_ImageBlockMissingSource 锁定 provider 对畸形 // BlockImage (缺 ImageSource) 返 error 而非静默丢弃. 防 L699/022ea20 同类 // choke-point 吞字段 bug 回归. func TestFlytoMessageToAPI_ImageBlockMissingSource(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleUser, Blocks: []flyto.Block{{Type: flyto.BlockImage}}, // 刻意缺 ImageSource } _, err := flytoMessageToAPI(msg) if err == nil { t.Fatal("expected error for BlockImage missing ImageSource, got nil") } } // TestFlytoMessageToAPI_ToolResultWithImage locks path B: a tool returns an // image via Result.Data, engine wraps it in ToolResultBlocks (text + image), // provider emits array-form "content":[...] wire with embedded image source. // Spec: Anthropic tool_result content accepts array of content blocks // including image blocks with base64 source. // // TestFlytoMessageToAPI_ToolResultWithImage 锁路径 B: 工具经 Result.Data 返图, // engine 包成 ToolResultBlocks (text + image), provider 发 array 形式 // "content":[...] wire 含 image source. spec: Anthropic tool_result content // 接受含 base64 source image block 的 content block 数组. func TestFlytoMessageToAPI_ToolResultWithImage(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleUser, Blocks: []flyto.Block{ flyto.ToolResultBlocks("toolu_read1", []flyto.Block{ flyto.TextBlock("Screenshot of dashboard:"), flyto.ImageBlockBase64("image/jpeg", "base64imgbytes=="), }, false), }, } rm, err := flytoMessageToAPI(msg) if err != nil { t.Fatalf("flytoMessageToAPI: %v", err) } var blocks []map[string]any if err := json.Unmarshal(rm.Content, &blocks); err != nil { t.Fatalf("unmarshal: %v", err) } if len(blocks) != 1 || blocks[0]["type"] != "tool_result" { t.Fatalf("expected single tool_result block, got %+v", blocks) } content, ok := blocks[0]["content"].([]any) if !ok { t.Fatalf("tool_result.content must be array when image present, got %T: %v", blocks[0]["content"], blocks[0]["content"]) } if len(content) != 2 { t.Fatalf("expected 2 content items (text + image), got %d", len(content)) } // 第 2 项是 image 且 source 正确展开. imgBlock, _ := content[1].(map[string]any) if imgBlock["type"] != "image" { t.Errorf("second content item type = %v, want image", imgBlock["type"]) } src, _ := imgBlock["source"].(map[string]any) if src == nil || src["media_type"] != "image/jpeg" || src["data"] != "base64imgbytes==" { t.Errorf("nested image source malformed: %+v", src) } } // TestFlytoMessageToAPI_ToolResultNestedImageMissingSource surfaces an error // when ResultBlocks carries a malformed image (no ImageSource). Prevents // silent-drop at the nested level. // // TestFlytoMessageToAPI_ToolResultNestedImageMissingSource ResultBlocks 里 // 嵌套畸形 image (缺 ImageSource) 时返 error, 防嵌套层静默丢弃. func TestFlytoMessageToAPI_ToolResultNestedImageMissingSource(t *testing.T) { msg := flyto.Message{ Role: flyto.RoleUser, Blocks: []flyto.Block{ flyto.ToolResultBlocks("toolu_read1", []flyto.Block{ {Type: flyto.BlockImage}, // 缺 ImageSource }, false), }, } _, err := flytoMessageToAPI(msg) if err == nil { t.Fatal("expected error for nested BlockImage missing ImageSource, got nil") } } // --- wrapFenceStrip --- func TestWrapFenceStrip(t *testing.T) { src := make(chan flyto.Event, 3) src <- &flyto.TextEvent{Text: "```json\n{\"key\":\"value\"}\n```"} src <- &flyto.TextDeltaEvent{Text: "passthrough"} // non-TextEvent passes through src <- &flyto.UsageEvent{InputTokens: 100} close(src) dst := wrapFenceStrip(src) var events []flyto.Event for evt := range dst { events = append(events, evt) } if len(events) != 3 { t.Fatalf("expected 3 events, got %d", len(events)) } // TextEvent should have fences stripped te, ok := events[0].(*flyto.TextEvent) if !ok { t.Fatalf("first event type = %T, want *TextEvent", events[0]) } if te.Text != `{"key":"value"}` { t.Errorf("stripped text = %q", te.Text) } // TextDeltaEvent should pass through unchanged if _, ok := events[1].(*flyto.TextDeltaEvent); !ok { t.Errorf("second event type = %T, want *TextDeltaEvent", events[1]) } // UsageEvent should pass through unchanged if _, ok := events[2].(*flyto.UsageEvent); !ok { t.Errorf("third event type = %T, want *UsageEvent", events[2]) } } func TestWrapFenceStrip_EmptyChannel(t *testing.T) { src := make(chan flyto.Event) close(src) dst := wrapFenceStrip(src) // channel should close without hanging select { case _, ok := <-dst: if ok { t.Error("expected closed channel") } case <-time.After(2 * time.Second): t.Fatal("wrapFenceStrip hung on empty channel") } }