From 77beafa6d3274510db71604fc963717cb6f04741 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Wed, 29 Apr 2026 19:02:14 -0700 Subject: [PATCH] fix(media): skip path tag append for JSON content (Feishu cards/posts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When content is structured JSON (interactive cards, post messages), injectPathTags now skips the fallback append — only placeholder replacement is attempted. This prevents corrupting JSON payloads like {"schema":"2.0",...} with appended [image:/path] tags. Adds looksLikeJSON() helper and three test cases covering JSON objects, arrays, and an end-to-end resolveMediaRefs scenario with Feishu card content. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/agent/agent_media.go | 14 ++++++++++++++ pkg/agent/agent_test.go | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/pkg/agent/agent_media.go b/pkg/agent/agent_media.go index db9d5072b..9ec3ec585 100644 --- a/pkg/agent/agent_media.go +++ b/pkg/agent/agent_media.go @@ -242,7 +242,12 @@ func buildPathTag(mime, localPath string) string { // or appends if no matching generic tag is found. Channels emit a few different // placeholder formats — [image], [image: photo], [image: filename.jpg] — so we // match all of them via regex while leaving path tags ([image:/path]) untouched. +// +// When content is structured data (e.g., JSON from Feishu interactive cards or +// post messages), tags are only injected via placeholder replacement — never +// appended — to avoid corrupting the payload. func injectPathTags(content string, tags []string) string { + isStructured := looksLikeJSON(content) for _, tag := range tags { var pattern *regexp.Regexp switch { @@ -263,6 +268,10 @@ func injectPathTags(content string, tags []string) string { } } + if isStructured { + continue + } + if content == "" { content = tag } else { @@ -271,3 +280,8 @@ func injectPathTags(content string, tags []string) string { } return content } + +func looksLikeJSON(s string) bool { + s = strings.TrimSpace(s) + return len(s) > 1 && (s[0] == '{' || s[0] == '[') +} diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 26da280e1..e1ee0fafb 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -5040,6 +5040,47 @@ func TestInjectPathTags_DoesNotReplacePathTag(t *testing.T) { } } +func TestInjectPathTags_SkipsAppendForJSONContent(t *testing.T) { + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + got := injectPathTags(jsonContent, []string{"[image:/tmp/photo.png]"}) + if got != jsonContent { + t.Fatalf("expected JSON content unchanged, got %q", got) + } +} + +func TestInjectPathTags_JSONArrayContent(t *testing.T) { + jsonContent := `[{"tag":"text","text":"hello"}]` + got := injectPathTags(jsonContent, []string{"[file:/tmp/doc.pdf]"}) + if got != jsonContent { + t.Fatalf("expected JSON array content unchanged, got %q", got) + } +} + +func TestResolveMediaRefs_JSONContentPreservesStructure(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "card_img.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + ref, _ := store.Store(pngPath, media.MediaMeta{ContentType: "image/png"}, "test") + + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + messages := []providers.Message{ + {Role: "user", Content: jsonContent, Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if result[0].Content != jsonContent { + t.Fatalf("expected JSON content preserved, got %q", result[0].Content) + } +} + func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir()