fix: inline synthetic paste attachments as bounded prompt text (#23523)

## 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.

<details>
<summary>Review notes: intentionally skipped findings</summary>

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.

</details>
This commit is contained in:
Michael Suchacz
2026-03-24 21:39:42 +01:00
committed by GitHub
parent 5823dc0243
commit 4f571f8fff
6 changed files with 346 additions and 0 deletions
+1
View File
@@ -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,
}
+64
View File
@@ -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,
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
package chatprompt
// IsSyntheticPasteForTest exposes isSyntheticPaste for external tests.
var IsSyntheticPasteForTest = isSyntheticPaste
@@ -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<typeof fn>).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";
@@ -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);