From 31b985bbfd25008d6afbf22ed8790b2309e2878d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:14:50 +0800 Subject: [PATCH] fix(chat): render agent-produced files as attachment cards, not raw links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat upload command handed the agent a bare `[name](url)` markdown snippet. Pasted mid-sentence it renders as a plain text link (not a card), and the referenced URL hides the auto-bound standalone attachment — so a file the agent produced could end up showing as nothing. Return the block-level `!file[name](url)` card syntax instead (images keep `![name](url)` inline), and markdown-escape the filename so names with `[`/`]` don't truncate the label. The prompt and CLI help now state the file auto-attaches below the reply and the snippet is optional, only for placement. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/cmd/multica/cmd_attachment.go | 37 ++++++++++----- server/cmd/multica/cmd_attachment_test.go | 55 ++++++++++++++++++++--- server/internal/daemon/prompt.go | 2 +- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/server/cmd/multica/cmd_attachment.go b/server/cmd/multica/cmd_attachment.go index 2df822abb7..acbcfab1b1 100644 --- a/server/cmd/multica/cmd_attachment.go +++ b/server/cmd/multica/cmd_attachment.go @@ -34,14 +34,14 @@ var attachmentDownloadCmd = &cobra.Command{ var attachmentUploadCmd = &cobra.Command{ Use: "upload ", Short: "Upload a file to attach to your chat reply", - Long: `Upload a local file so it appears attached to the reply of the current chat task. + Long: `Upload a local file so it is attached to the reply of the current chat task. Intended for agents running inside a chat task: the file is tagged with the task and, when the task completes, the server binds it to the assistant reply -it produces. The command prints the attachment id, a durable markdown_url, and -a ready-to-paste markdown snippet — embed the snippet in your reply to place -the image inline, or omit it and the file still shows as an attachment card -below the reply. +it produces — it appears as an attachment card below your reply even if you +paste nothing. The command also returns a markdown snippet you may paste on its +own line to place the item: files use !file[name](url) (a card), images use +![name](url) (inline). The task id is read from MULTICA_TASK_ID (set by the daemon inside a task); override it with --task when needed.`, @@ -91,12 +91,14 @@ func runAttachmentUpload(cmd *cobra.Command, args []string) error { } filename := filepath.Base(path) - // Image content types get image markdown so they render inline in the - // reply; every other file gets a normal link so it shows as a proper file - // reference instead of a broken-image icon. - markdown := fmt.Sprintf("[%s](%s)", filename, att.MarkdownURL) + // Escape markdown label metacharacters in the filename so a name like + // `report[v2].pdf` does not truncate the snippet's label. Files render as a + // block-level attachment card via `!file[...]( )`; images render inline via + // `![...]( )`. + label := escapeMarkdownLabel(filename) + markdown := fmt.Sprintf("!file[%s](%s)", label, att.MarkdownURL) if strings.HasPrefix(att.ContentType, "image/") { - markdown = fmt.Sprintf("![%s](%s)", filename, att.MarkdownURL) + markdown = fmt.Sprintf("![%s](%s)", label, att.MarkdownURL) } fmt.Fprintln(os.Stderr, "Uploaded:", filename) @@ -108,6 +110,21 @@ func runAttachmentUpload(cmd *cobra.Command, args []string) error { }) } +// escapeMarkdownLabel escapes the metacharacters a markdown link/image label +// may not contain unescaped ([ ] ( ) and backslash), so a filename like +// `report[v2].pdf` stays a single valid label instead of truncating the +// snippet. Kept in sync with the renderers' unescape set +// (packages/ui/markdown/file-cards.ts). +func escapeMarkdownLabel(s string) string { + return strings.NewReplacer( + `\`, `\\`, + `[`, `\[`, + `]`, `\]`, + `(`, `\(`, + `)`, `\)`, + ).Replace(s) +} + func runAttachmentDownload(cmd *cobra.Command, args []string) error { client, err := newAPIClient(cmd) if err != nil { diff --git a/server/cmd/multica/cmd_attachment_test.go b/server/cmd/multica/cmd_attachment_test.go index f8d98b0617..7448f0f677 100644 --- a/server/cmd/multica/cmd_attachment_test.go +++ b/server/cmd/multica/cmd_attachment_test.go @@ -143,10 +143,10 @@ func TestRunAttachmentUploadSendsTaskIDAndPrintsContract(t *testing.T) { } } -// TestRunAttachmentUploadNonImageUsesLinkMarkdown covers the content-type -// branch: a non-image file must emit a plain link, not `![...]`, which would -// render as a broken image in the reply. -func TestRunAttachmentUploadNonImageUsesLinkMarkdown(t *testing.T) { +// TestRunAttachmentUploadNonImageUsesFileCardMarkdown covers the content-type +// branch: a non-image file emits the block-level `!file[...]( )` card snippet, +// not image `![...]` markdown. +func TestRunAttachmentUploadNonImageUsesFileCardMarkdown(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(1 << 20); err != nil { t.Fatalf("parse multipart: %v", err) @@ -176,12 +176,55 @@ func TestRunAttachmentUploadNonImageUsesLinkMarkdown(t *testing.T) { if err != nil { t.Fatalf("runAttachmentUpload: %v", err) } - if !strings.Contains(out, `[report.pdf](https://public.example/api/attachments/att-doc/download)`) { - t.Fatalf("stdout missing link markdown: %q", out) + // Non-image uses the block-level file-card snippet, never image markdown. + if !strings.Contains(out, `!file[report.pdf](https://public.example/api/attachments/att-doc/download)`) { + t.Fatalf("stdout missing file-card markdown snippet: %q", out) } if strings.Contains(out, `![report.pdf]`) { t.Fatalf("non-image must not use image markdown: %q", out) } + if !strings.Contains(out, `"markdown_url": "https://public.example/api/attachments/att-doc/download"`) { + t.Fatalf("stdout missing markdown_url: %q", out) + } +} + +// TestRunAttachmentUploadEscapesFilename covers filenames carrying markdown +// label metacharacters (`[`, `]`): the snippet must escape them so the label +// does not truncate — otherwise the card fails to render and the standalone +// attachment is hidden by the referenced URL, showing nothing. +func TestRunAttachmentUploadEscapesFilename(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "att-esc", + "filename": "a]b.pdf", + "content_type": "application/pdf", + "markdown_url": "https://public.example/api/attachments/att-esc/download", + }) + })) + defer srv.Close() + setCLITestServerEnv(t, srv.URL) + t.Setenv("MULTICA_TOKEN", "mat_test-token") + + dir := t.TempDir() + docPath := filepath.Join(dir, "a]b.pdf") + if err := os.WriteFile(docPath, []byte("%PDF-1.4 bytes"), 0o644); err != nil { + t.Fatalf("write temp doc: %v", err) + } + + cmd := newAttachmentUploadTestCmd() + _ = cmd.Flags().Set("task", "task-abc") + + out, err := captureStdout(t, func() error { return runAttachmentUpload(cmd, []string{docPath}) }) + if err != nil { + t.Fatalf("runAttachmentUpload: %v", err) + } + // The `]` in the filename must be escaped inside the label. + if !strings.Contains(out, `!file[a\\]b.pdf](https://public.example/api/attachments/att-esc/download)`) { + t.Fatalf("stdout missing escaped file-card snippet: %q", out) + } } func TestRunAttachmentUploadRequiresTask(t *testing.T) { diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index 7e4afd82fb..2aca679602 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -315,7 +315,7 @@ func buildChatPrompt(task Task) string { // Web/mobile chat only — for IM-channel chats the reply is delivered to // that platform, not the Multica chat UI, so this binding does not apply. if task.ChatChannelType == "" { - b.WriteString("\nTo include an image or file in your reply, run `multica attachment upload `. It returns a `markdown` snippet — paste it where you want the image inline, or skip it and the file still appears as an attachment card under your reply. Upload files you produced locally; do not paste raw URLs.\n") + b.WriteString("\nTo include a file or image you produced in your reply, run `multica attachment upload `. The file binds to your reply automatically and appears as an attachment card below it even if you paste nothing. The command also returns a `markdown` snippet you may paste on its own line to place the item where you want it (files render as a card, images inline).\n") } return b.String() }