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);