Files
coder/coderd/x/chatd/chatprompt/title_test.go
T
Michael Suchacz 2ad5af5b54 fix(coderd): use pasted-text attachments as chat title input (#27067)
Closes https://linear.app/codercom/issue/CODAGT-268

## Problem

The chat UI collapses large pastes (>=10 lines or >=1000 chars) into a
synthetic `pasted-text-*.txt` attachment. A chat created with only such
an attachment had no title input anywhere: the create path derived
`titleSource` only from text and file-reference parts (so the chat was
named "New Chat"), async auto-titling extracted text the same way and
silently skipped generation, and the manual propose/regenerate paths
returned an empty title for the same reason. The regular prompt path
already inlines these files for the model; only the title paths were
blind.

## Fix

Add a single title-input derivation in `chatprompt` and use it
everywhere:

- `chatprompt.TitleText` joins text and file-reference parts (unchanged
formatting), and falls back to synthetic pasted-text attachment content
(truncated to a 16 KiB title budget) when they yield nothing.
- `chatprompt.SyntheticPasteFileIDs` identifies paste attachments;
`chatprompt.FallbackTitle` consolidates the previously duplicated
`chatTitleFromMessage` / `fallbackChatTitle`.
- Chat creation captures paste blob references while validating file
parts (the file row was already loaded there) and derives `titleSource`
via `TitleText`. Only the create path derives titles; message send and
edit reuse the same validation without copying any blob data.
- `GenerateChatTitleAsync` and the manual propose/regenerate paths
resolve paste content via `titlePasteText`, which only queries when a
visible user message has no other title text, so chats with typed text
never incur a file fetch.
- Title-path paste fetches are bounded: a new
`GetChatFileDataPrefixesByIDs` query returns only a `substr` prefix
(`chatprompt.TitlePasteBytePrefix`, 64 KiB = 4 bytes x the 16 Ki-rune
title budget) so full blobs (up to 10 MiB each) never leave the database
for titling, and `chatprompt.TitlePasteText` applies the same bound to
the create path which already holds the loaded row.

Deliberate side effect: because generation-time extraction now matches
create-time `titleSource` exactly, file-reference-only chats also become
eligible for AI titles. They were previously skipped by the same
derivation mismatch.

Non-goals: no frontend changes (attachment chip UX stays as is), and
non-synthetic user-uploaded `.txt` files still yield "New Chat".

## Testing

- Unit tests for `TitleText`, `TitlePasteText`, `SyntheticPasteFileIDs`,
`FallbackTitle`, `titleInput`, `titlePasteText`, and paste-aware
`extractManualTitleTurns`.
- Real-database test for `GetChatFileDataPrefixesByIDs` (prefix shorter
and longer than stored data) plus dbauthz coverage for the new query.
- Integration tests: paste-only create gets a fallback title from the
paste content, async title generation fires with the paste content as
input, and `RegenerateChatTitle` works on a paste-only chat.

> This PR was written by [Mux](https://mux.coder.com) on Mike's behalf.
2026-07-08 21:37:30 +02:00

279 lines
7.6 KiB
Go

package chatprompt_test
import (
"bytes"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/codersdk"
)
func TestTitleText(t *testing.T) {
t.Parallel()
pasteFileID := uuid.New()
otherPasteFileID := uuid.New()
syntheticPasteFile := func(id uuid.UUID) codersdk.ChatMessagePart {
return codersdk.ChatMessageFile(id, "text/plain", "pasted-text-2026-01-02-03-04-05.txt")
}
tests := []struct {
name string
parts []codersdk.ChatMessagePart
pasteText map[uuid.UUID]string
want string
}{
{
name: "joins trimmed text parts",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageText(" fix the flaky test "),
codersdk.ChatMessageReasoning("skip me"),
codersdk.ChatMessageText(" in coderd "),
},
want: "fix the flaky test in coderd",
},
{
name: "formats file reference with line range and content fence",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageFileReference("main.go", 3, 7, "fmt.Println(\"hi\")\n"),
},
want: "[file-reference] main.go:3-7\n```main.go\nfmt.Println(\"hi\")\n```",
},
{
name: "formats single line file reference without content",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageFileReference("main.go", 3, 3, " "),
},
want: "[file-reference] main.go:3",
},
{
name: "joins text and file reference parts in order",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("explain this"),
codersdk.ChatMessageFileReference("app.ts", 1, 1, ""),
},
want: "explain this [file-reference] app.ts:1",
},
{
name: "falls back to paste content for file only messages",
parts: []codersdk.ChatMessagePart{
syntheticPasteFile(pasteFileID),
},
pasteText: map[uuid.UUID]string{pasteFileID: " pasted panic log\nsecond line "},
want: "pasted panic log\nsecond line",
},
{
name: "text wins over paste content",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("typed context"),
syntheticPasteFile(pasteFileID),
},
pasteText: map[uuid.UUID]string{pasteFileID: "pasted content"},
want: "typed context",
},
{
name: "joins multiple pastes in part order",
parts: []codersdk.ChatMessagePart{
syntheticPasteFile(pasteFileID),
syntheticPasteFile(otherPasteFileID),
},
pasteText: map[uuid.UUID]string{
pasteFileID: "first paste",
otherPasteFileID: "second paste",
},
want: "first paste\n\nsecond paste",
},
{
name: "ignores file parts without resolved paste content",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageFile(uuid.New(), "image/png", "photo.png"),
},
pasteText: map[uuid.UUID]string{pasteFileID: "unrelated"},
want: "",
},
{
name: "ignores whitespace only paste content",
parts: []codersdk.ChatMessagePart{
syntheticPasteFile(pasteFileID),
},
pasteText: map[uuid.UUID]string{pasteFileID: " \n\t "},
want: "",
},
{
name: "empty parts yield empty text",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chatprompt.TitleText(tt.parts, tt.pasteText))
})
}
}
func TestTitleText_TruncatesPasteContentRuneSafe(t *testing.T) {
t.Parallel()
pasteFileID := uuid.New()
parts := []codersdk.ChatMessagePart{
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
}
// Multi-byte runes ensure truncation cannot split a UTF-8 sequence.
content := strings.Repeat("é", chatprompt.SyntheticPasteTitleBudgetForTest+10)
got := chatprompt.TitleText(parts, map[uuid.UUID]string{pasteFileID: content})
require.Len(t, []rune(got), chatprompt.SyntheticPasteTitleBudgetForTest)
require.True(t, strings.HasPrefix(content, got))
}
func TestTitlePasteText(t *testing.T) {
t.Parallel()
t.Run("ShortDataCopiedWhole", func(t *testing.T) {
t.Parallel()
require.Equal(t, "hello paste", chatprompt.TitlePasteText([]byte("hello paste")))
})
t.Run("LongDataBounded", func(t *testing.T) {
t.Parallel()
data := bytes.Repeat([]byte("a"), chatprompt.TitlePasteBytePrefix+4096)
require.Len(t, chatprompt.TitlePasteText(data), chatprompt.TitlePasteBytePrefix)
})
t.Run("MatchesFullContentDerivation", func(t *testing.T) {
t.Parallel()
pasteFileID := uuid.New()
parts := []codersdk.ChatMessagePart{
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
}
// Three-byte runes make the byte-prefix cut land mid-rune
// (TitlePasteBytePrefix % 3 != 0); TitleText's rune truncation
// must still produce the same result as the full content.
content := strings.Repeat("€", chatprompt.TitlePasteBytePrefix/3+16)
bounded := chatprompt.TitleText(parts, map[uuid.UUID]string{
pasteFileID: chatprompt.TitlePasteText([]byte(content)),
})
full := chatprompt.TitleText(parts, map[uuid.UUID]string{
pasteFileID: content,
})
require.Equal(t, full, bounded)
require.Len(t, []rune(bounded), chatprompt.SyntheticPasteTitleBudgetForTest)
})
}
func TestSyntheticPasteFileIDs(t *testing.T) {
t.Parallel()
pasteFileID := uuid.New()
otherPasteFileID := uuid.New()
noIDPart := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
MediaType: "text/plain",
Name: "pasted-text-2026-01-02-03-04-05.txt",
}
tests := []struct {
name string
parts []codersdk.ChatMessagePart
want []uuid.UUID
}{
{
name: "collects synthetic paste file ids",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
codersdk.ChatMessageFile(otherPasteFileID, "text/plain; charset=utf-8", "pasted-text-2026-12-31-23-59-59.txt"),
},
want: []uuid.UUID{pasteFileID, otherPasteFileID},
},
{
name: "skips files without the synthetic name pattern",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageFile(uuid.New(), "text/plain", "notes.txt"),
},
want: nil,
},
{
name: "skips files with non text media types",
parts: []codersdk.ChatMessagePart{
codersdk.ChatMessageFile(uuid.New(), "image/png", "pasted-text-2026-01-02-03-04-05.txt"),
},
want: nil,
},
{
name: "skips file parts without a file id",
parts: []codersdk.ChatMessagePart{noIDPart},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chatprompt.SyntheticPasteFileIDs(tt.parts))
})
}
}
func TestFallbackTitle(t *testing.T) {
t.Parallel()
longWord := strings.Repeat("x", 30)
tests := []struct {
name string
message string
want string
}{
{
name: "empty message yields default title",
message: " \n ",
want: "New Chat",
},
{
name: "short message is kept verbatim",
message: "fix the flaky test",
want: "fix the flaky test",
},
{
name: "collapses whitespace between words",
message: "fix\nthe\tflaky test",
want: "fix the flaky test",
},
{
name: "truncates to six words with ellipsis",
message: "one two three four five six seven",
want: "one two three four five six…",
},
{
name: "caps six long words at eighty runes keeping the ellipsis",
message: strings.Repeat(longWord+" ", 7),
want: strings.Repeat("x", 30) + " " + strings.Repeat("x", 30) + " " + strings.Repeat("x", 17) + "…",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := chatprompt.FallbackTitle(tt.message)
require.Equal(t, tt.want, got)
require.LessOrEqual(t, len([]rune(got)), 80)
})
}
}