fix(chat): render agent-produced files as attachment cards, not raw links

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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-10 15:14:50 +08:00
parent e2e09ce9a8
commit 31b985bbfd
3 changed files with 77 additions and 17 deletions

View File

@@ -34,14 +34,14 @@ var attachmentDownloadCmd = &cobra.Command{
var attachmentUploadCmd = &cobra.Command{
Use: "upload <path>",
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 {

View File

@@ -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) {

View File

@@ -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 <local-path>`. 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 <local-path>`. 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()
}