From 4f571f8fffb914ed41b740cb13634903578915e9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:39:42 +0100 Subject: [PATCH] fix: inline synthetic paste attachments as bounded prompt text (#23523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Large pasted text that the UI collapses into an attachment chip was completely invisible to the LLM. Providers only accept specific MIME types (images, PDFs) in file content blocks — a `text/plain` `FilePart` is silently dropped, so the model received nothing for pasted content. ## Fix Detect paste-originated text files by their `pasted-text-{timestamp}.txt` filename pattern and convert them to `fantasy.TextPart` with a bounded 128 KiB inline body and truncation notice. Binary uploads and real uploaded text files keep their existing `FilePart` semantics. The detection uses the existing frontend naming convention (`pasted-text-YYYY-MM-DD-HH-MM-SS.txt`) combined with a text-like MIME check for defense-in-depth. A TODO marks this for migration to explicit origin metadata.
Review notes: intentionally skipped findings A 10-reviewer deep review was run on this change. The following findings were raised and intentionally dropped after cross-check. Documenting them here so future reviewers do not re-flag the same concerns: **"Unresolved file IDs cause silent data loss" (Edge Case Analyst P1)** — When a file ID is not in the resolver map, `name` stays empty and paste detection fails. This is pre-existing behavior for ALL file types (not introduced by this change). The resolver calls `GetChatFilesByIDs` which returns whatever rows exist; missing IDs simply fall through to an empty `FilePart`. The Contract Auditor independently traced this path and confirmed the fallback is safe. If the file was deleted between message construction and conversion, the model already saw nothing before this patch — this change does not make it worse. **"String builder pre-allocation overhead" (Performance Analyst P1)** — Misidentified scope. `formatSyntheticPasteText` is only called when `isSyntheticPaste` returns true (actual synthetic pastes), not for every file part. The `Grow()` call is correct and efficient. **"Constant naming violates Uber style" (Style Reviewer P1)** — Over-severity. `syntheticPasteInlineBudget` is standard Go camelCase for unexported constants, consistent with the Uber guide and surrounding code. **"`IsSyntheticPasteForTest` naming is misleading" (Style Reviewer P2)** — This is the standard Go `export_test.go` pattern. The `ForTest` suffix is conventional.
--- coderd/x/chatd/chatd.go | 1 + coderd/x/chatd/chatprompt/chatprompt.go | 64 +++++++ coderd/x/chatd/chatprompt/chatprompt_test.go | 158 ++++++++++++++++++ coderd/x/chatd/chatprompt/export_test.go | 4 + .../components/AgentChatInput.stories.tsx | 107 ++++++++++++ .../AgentsPage/utils/pasteHelpers.test.ts | 12 ++ 6 files changed, 346 insertions(+) create mode 100644 coderd/x/chatd/chatprompt/export_test.go diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b036b3a3f1..8d6e16df19 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2461,6 +2461,7 @@ func (p *Server) chatFileResolver() chatprompt.FileResolver { result := make(map[uuid.UUID]chatprompt.FileData, len(files)) for _, f := range files { result[f.ID] = chatprompt.FileData{ + Name: f.Name, Data: f.Data, MediaType: f.Mimetype, } diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index 55d2089945..244a693549 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "mime" "regexp" "strings" @@ -18,10 +19,22 @@ import ( "github.com/coder/coder/v2/codersdk" ) +const syntheticPasteInlineBudget = 128 * 1024 + +const syntheticPasteInlinePrefix = "[pasted-text] The user pasted text into the chat UI. The frontend collapsed it into an attachment, so the content is inlined below for direct model consumption.\n\n" + +var syntheticPasteTruncationWarning = fmt.Sprintf( + "\n\n[pasted-text] The pasted text was truncated to %d bytes before sending to the model.", + syntheticPasteInlineBudget, +) + var toolCallIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]`) +var syntheticPasteFileNamePattern = regexp.MustCompile(`^pasted-text-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.txt$`) + // FileData holds resolved file content for LLM prompt building. type FileData struct { + Name string Data []byte MediaType string } @@ -1120,6 +1133,43 @@ func safeToolCallArgs(input string) json.RawMessage { return raw } +// TODO: Replace filename-based detection with explicit origin metadata. +func isSyntheticPaste(name string, mediaType string) bool { + if !syntheticPasteFileNamePattern.MatchString(name) { + return false + } + parsedMediaType, _, err := mime.ParseMediaType(mediaType) + if err == nil { + mediaType = parsedMediaType + } + if strings.HasPrefix(mediaType, "text/") { + return true + } + switch mediaType { + case "application/json", "application/xml", "application/javascript", "application/x-yaml": + return true + default: + return false + } +} + +func formatSyntheticPasteText(name string, body []byte) string { + const syntheticPasteNameLabel = "Synthetic attachment name: " + const syntheticPasteNameSuffix = "\n\n" + + var sb strings.Builder + sb.Grow(len(syntheticPasteInlinePrefix) + len(name) + min(len(body), syntheticPasteInlineBudget) + len(syntheticPasteTruncationWarning) + len(syntheticPasteNameLabel) + len(syntheticPasteNameSuffix)) + _, _ = sb.WriteString(syntheticPasteInlinePrefix) + if name != "" { + _, _ = fmt.Fprintf(&sb, "%s%s%s", syntheticPasteNameLabel, name, syntheticPasteNameSuffix) + } + _, _ = sb.WriteString(string(body[:min(len(body), syntheticPasteInlineBudget)])) + if len(body) > syntheticPasteInlineBudget { + _, _ = sb.WriteString(syntheticPasteTruncationWarning) + } + return sb.String() +} + // fileReferencePartToText formats a file-reference SDK part as // plain text for LLM consumption. LLMs don't understand // file-reference natively, so we convert to a readable text @@ -1220,14 +1270,28 @@ func partsToMessageParts( case codersdk.ChatMessagePartTypeFile: data := part.Data mediaType := part.MediaType + var name string if part.FileID.Valid { if fd, ok := resolved[part.FileID.UUID]; ok { data = fd.Data + name = fd.Name if mediaType == "" { mediaType = fd.MediaType } } } + // Providers only accept a small set of MIME types in file + // content blocks, typically images and PDFs. A synthetic + // paste sent as a text/plain FilePart is dropped or rejected, + // so the model sees nothing. Converting it to TextPart keeps + // the pasted content visible to every provider. + if isSyntheticPaste(name, mediaType) { + result = append(result, fantasy.TextPart{ + Text: formatSyntheticPasteText(name, data), + ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata), + }) + continue + } result = append(result, fantasy.FilePart{ Data: data, MediaType: mediaType, diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index 30d0d66e31..93b9a8ef8c 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "strings" "testing" "charm.land/fantasy" @@ -1768,3 +1769,160 @@ func TestConvertMessagesWithFiles_FiltersEmptyTextAndReasoningParts(t *testing.T require.Empty(t, prompt, "all-empty message should be dropped entirely") }) } + +func TestConvertMessagesWithFiles_PasteTextBecomesTextPart(t *testing.T) { + t.Parallel() + + fileID := uuid.New() + prompt := convertSingleResolvedFileMessage(t, fileID, chatprompt.FileData{ + Name: "pasted-text-2025-01-01-12-00-00.txt", + Data: []byte("hello world"), + MediaType: "text/plain", + }) + + require.Len(t, prompt, 1) + require.Len(t, prompt[0].Content, 1) + + textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[0]) + require.True(t, ok, "expected TextPart") + + _, isFilePart := fantasy.AsMessagePart[fantasy.FilePart](prompt[0].Content[0]) + require.False(t, isFilePart, "synthetic pasted text should not remain a FilePart") + require.Contains(t, textPart.Text, "The user pasted text into the chat UI") + require.Contains(t, textPart.Text, "hello world") +} + +func TestConvertMessagesWithFiles_PasteTextTruncatesAtBudget(t *testing.T) { + t.Parallel() + + fileID := uuid.New() + body := bytes.Repeat([]byte("x"), 200000) + prompt := convertSingleResolvedFileMessage(t, fileID, chatprompt.FileData{ + Name: "pasted-text-2025-01-01-12-00-00.txt", + Data: body, + MediaType: "text/plain", + }) + + require.Len(t, prompt, 1) + require.Len(t, prompt[0].Content, 1) + + textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[0]) + require.True(t, ok, "expected TextPart") + require.Contains(t, textPart.Text, "The pasted text was truncated to 131072 bytes") + + const attachmentHeader = "Synthetic attachment name: pasted-text-2025-01-01-12-00-00.txt\n\n" + bodyStart := strings.Index(textPart.Text, attachmentHeader) + require.NotEqual(t, -1, bodyStart, "expected synthetic attachment header") + bodyStart += len(attachmentHeader) + + warningIndex := strings.Index(textPart.Text, "\n\n[pasted-text] The pasted text was truncated to 131072 bytes before sending to the model.") + require.NotEqual(t, -1, warningIndex, "expected truncation warning") + require.Equal(t, string(body[:128*1024]), textPart.Text[bodyStart:warningIndex]) +} + +func TestConvertMessagesWithFiles_BinaryPasteNameStillStaysFilePart(t *testing.T) { + t.Parallel() + + fileID := uuid.New() + prompt := convertSingleResolvedFileMessage(t, fileID, chatprompt.FileData{ + Name: "pasted-text-2025-01-01-12-00-00.txt", + Data: []byte("not-really-a-png"), + MediaType: "image/png", + }) + + require.Len(t, prompt, 1) + require.Len(t, prompt[0].Content, 1) + + filePart, ok := fantasy.AsMessagePart[fantasy.FilePart](prompt[0].Content[0]) + require.True(t, ok, "expected FilePart") + + _, isTextPart := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[0]) + require.False(t, isTextPart, "binary media should stay a FilePart") + require.Equal(t, "image/png", filePart.MediaType) +} + +func TestConvertMessagesWithFiles_NonPasteTextFileStillStaysFilePart(t *testing.T) { + t.Parallel() + + fileID := uuid.New() + prompt := convertSingleResolvedFileMessage(t, fileID, chatprompt.FileData{ + Name: "report.txt", + Data: []byte("plain text report"), + MediaType: "text/plain", + }) + + require.Len(t, prompt, 1) + require.Len(t, prompt[0].Content, 1) + + filePart, ok := fantasy.AsMessagePart[fantasy.FilePart](prompt[0].Content[0]) + require.True(t, ok, "expected FilePart") + + _, isTextPart := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[0]) + require.False(t, isTextPart, "non-synthetic text files should stay FilePart attachments") + require.Equal(t, []byte("plain text report"), filePart.Data) +} + +func TestConvertMessagesWithFiles_IsSyntheticPaste(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fileName string + mediaType string + want bool + }{ + {name: "plain text", fileName: "pasted-text-2025-01-01-12-00-00.txt", mediaType: "text/plain", want: true}, + {name: "markdown", fileName: "pasted-text-2025-01-01-12-00-00.txt", mediaType: "text/markdown", want: true}, + {name: "json", fileName: "pasted-text-2025-01-01-12-00-00.txt", mediaType: "application/json", want: true}, + {name: "binary mime", fileName: "pasted-text-2025-01-01-12-00-00.txt", mediaType: "image/png", want: false}, + {name: "non synthetic name", fileName: "report.txt", mediaType: "text/plain", want: false}, + {name: "malformed timestamp", fileName: "pasted-text-2025-01-01.txt", mediaType: "text/plain", want: false}, + {name: "wrong extension", fileName: "pasted-text-2025-01-01-12-00-00.md", mediaType: "text/plain", want: false}, + {name: "empty name", fileName: "", mediaType: "text/plain", want: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, chatprompt.IsSyntheticPasteForTest(tt.fileName, tt.mediaType)) + }) + } +} + +func convertSingleResolvedFileMessage(t *testing.T, fileID uuid.UUID, fileData chatprompt.FileData) []fantasy.Message { + t.Helper() + + rawContent := mustJSON(t, []json.RawMessage{ + mustJSON(t, map[string]any{ + "type": "file", + "data": map[string]any{ + "media_type": fileData.MediaType, + "file_id": fileID.String(), + }, + }), + }) + + resolver := func(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]chatprompt.FileData, error) { + result := make(map[uuid.UUID]chatprompt.FileData) + for _, id := range ids { + if id == fileID { + result[id] = fileData + } + } + return result, nil + } + + prompt, err := chatprompt.ConvertMessagesWithFiles( + context.Background(), + []database.ChatMessage{{ + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityBoth, + Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true}, + }}, + resolver, + slogtest.Make(t, nil), + ) + require.NoError(t, err) + return prompt +} diff --git a/coderd/x/chatd/chatprompt/export_test.go b/coderd/x/chatd/chatprompt/export_test.go new file mode 100644 index 0000000000..63c3e1f9ca --- /dev/null +++ b/coderd/x/chatd/chatprompt/export_test.go @@ -0,0 +1,4 @@ +package chatprompt + +// IsSyntheticPasteForTest exposes isSyntheticPaste for external tests. +var IsSyntheticPasteForTest = isSyntheticPaste diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index f2c85545a9..99b32adb9d 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -345,6 +345,113 @@ export const AttachmentsOnly: Story = { })(), }; +const LARGE_PASTE_MARKER = "__PASTE_MARKER_TEST__"; + +const largePasteText = Array.from({ length: 12 }, (_, i) => + i === 6 ? LARGE_PASTE_MARKER : `line ${i + 1} of pasted content`, +).join("\n"); + +function dispatchPasteWithText(element: HTMLElement, text: string): void { + const dt = new DataTransfer(); + dt.setData("text/plain", text); + const event = new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "clipboardData", { + value: dt, + writable: false, + }); + element.dispatchEvent(event); +} + +function getPasteTarget(container: HTMLElement): HTMLElement { + const element = container.querySelector( + '[data-testid="chat-message-input"]', + ) as HTMLElement; + if (element?.getAttribute("contenteditable") === "true") { + return element; + } + + const contentEditable = element?.querySelector( + '[contenteditable="true"]', + ) as HTMLElement; + return contentEditable ?? element; +} + +export const LargePasteCreatesAttachmentPreview: Story = { + args: { + attachments: [], + onAttach: fn(), + onRemoveAttachment: fn(), + }, + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, + play: async ({ canvasElement, args }) => { + const target = getPasteTarget(canvasElement); + await waitFor(() => { + expect(target.getAttribute("contenteditable")).toBe("true"); + }); + target.focus(); + + dispatchPasteWithText(target, largePasteText); + + await waitFor(() => { + expect(args.onAttach).toHaveBeenCalledTimes(1); + }); + + const callArgs = (args.onAttach as ReturnType).mock.calls[0]; + const files = callArgs[0] as File[]; + expect(files).toHaveLength(1); + expect(files[0].type).toBe("text/plain"); + expect(files[0].name).toMatch( + /^pasted-text-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.txt$/, + ); + expect(target.textContent).not.toContain(LARGE_PASTE_MARKER); + }, +}; + +export const CtrlShiftVBypassesAttachmentCollapse: Story = { + args: { + attachments: [], + onAttach: fn(), + onRemoveAttachment: fn(), + }, + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, + play: async ({ canvasElement, args }) => { + const target = getPasteTarget(canvasElement); + await waitFor(() => { + expect(target.getAttribute("contenteditable")).toBe("true"); + }); + target.focus(); + + const keyDown = new KeyboardEvent("keydown", { + key: "v", + code: "KeyV", + shiftKey: true, + ctrlKey: true, + metaKey: false, + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(keyDown); + dispatchPasteWithText(target, largePasteText); + + await waitFor(() => { + expect(target.textContent).toContain(LARGE_PASTE_MARKER); + }); + + expect(args.onAttach).not.toHaveBeenCalled(); + }, +}; + // ── MCP server fixtures ──────────────────────────────────────── const now = "2026-03-19T12:00:00.000Z"; diff --git a/site/src/pages/AgentsPage/utils/pasteHelpers.test.ts b/site/src/pages/AgentsPage/utils/pasteHelpers.test.ts index 3a2f7401e4..b4e91acdd0 100644 --- a/site/src/pages/AgentsPage/utils/pasteHelpers.test.ts +++ b/site/src/pages/AgentsPage/utils/pasteHelpers.test.ts @@ -106,6 +106,10 @@ describe("isLargePaste", () => { expect(isLargePaste("Hello world")).toBe(false); }); + it("returns false for empty text", () => { + expect(isLargePaste("")).toBe(false); + }); + it("returns false for 9 lines of short text", () => { const text = Array(9).fill("short line").join("\n"); expect(isLargePaste(text)).toBe(false); @@ -126,6 +130,14 @@ describe("isLargePaste", () => { expect(isLargePaste(text)).toBe(false); }); + it("returns false when text has 9 lines and 999 characters", () => { + const text = [...Array(8).fill("x".repeat(110)), "x".repeat(111)].join( + "\n", + ); + expect(text.length).toBe(999); + expect(isLargePaste(text)).toBe(false); + }); + it("returns true for text meeting both thresholds", () => { const text = Array(15).fill("x".repeat(100)).join("\n"); expect(isLargePaste(text)).toBe(true);