mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
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 `` 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:
@@ -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
|
||||
 (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("", filename, att.MarkdownURL)
|
||||
markdown = fmt.Sprintf("", 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user