Files
coder/coderd/x/chatd/chatprompt/title.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

116 lines
3.7 KiB
Go

package chatprompt
import (
"strings"
"github.com/google/uuid"
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/codersdk"
)
// syntheticPasteTitleBudget caps, in runes, how much of a pasted-text
// attachment feeds title generation. It is far smaller than
// syntheticPasteInlineBudget because it only seeds a short title, not
// the model prompt.
const syntheticPasteTitleBudget = 16 * 1024
// TitlePasteBytePrefix caps, in bytes, how much of a pasted-text blob
// feeds title derivation: four bytes per rune (the UTF-8 maximum)
// covers syntheticPasteTitleBudget runes. Database callers pass it to
// GetChatFileDataPrefixesByIDs to bound the fetch itself.
const TitlePasteBytePrefix = 4 * syntheticPasteTitleBudget
// TitlePasteText converts pasted-text content to TitleText input,
// copying at most TitlePasteBytePrefix bytes. Every caller that
// builds a pasteText map must use it so all derivation paths feed
// TitleText identical strings.
func TitlePasteText(data []byte) string {
return string(data[:min(len(data), TitlePasteBytePrefix)])
}
// TitleText derives title-generation input from message parts. Text
// and file-reference parts are joined in part order. When they yield
// nothing, the content of synthetic pasted-text attachments is used
// instead, looked up in pasteText by file ID and truncated to
// syntheticPasteTitleBudget runes per file.
//
// The chat-creation fallback title and both title-generation paths
// must derive their input through this function: auto-titling only
// proceeds when the current title equals FallbackTitle of this exact
// string, so a drift between derivations silently disables it.
func TitleText(parts []codersdk.ChatMessagePart, pasteText map[uuid.UUID]string) string {
texts := make([]string, 0, len(parts))
for _, part := range parts {
switch part.Type {
case codersdk.ChatMessagePartTypeText:
text := strings.TrimSpace(part.Text)
if text == "" {
continue
}
texts = append(texts, text)
case codersdk.ChatMessagePartTypeFileReference:
texts = append(texts, fileReferencePartToText(part))
}
}
if joined := strings.TrimSpace(strings.Join(texts, " ")); joined != "" {
return joined
}
pastes := make([]string, 0, len(pasteText))
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeFile || !part.FileID.Valid {
continue
}
content := strings.TrimSpace(pasteText[part.FileID.UUID])
if content == "" {
continue
}
pastes = append(pastes, stringutil.Truncate(content, syntheticPasteTitleBudget))
}
return strings.TrimSpace(strings.Join(pastes, "\n\n"))
}
// SyntheticPasteFileIDs returns the file IDs of file parts that are
// synthetic pasted-text attachments created by the chat UI. Callers
// resolve these to file content and pass the result to TitleText.
func SyntheticPasteFileIDs(parts []codersdk.ChatMessagePart) []uuid.UUID {
var ids []uuid.UUID
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeFile || !part.FileID.Valid {
continue
}
if !IsSyntheticPaste(part.Name, part.MediaType) {
continue
}
ids = append(ids, part.FileID.UUID)
}
return ids
}
// FallbackTitle derives a deterministic chat title from title text:
// the first six words, ellipsized when truncated, capped at 80 runes.
// Empty input yields "New Chat".
func FallbackTitle(message string) string {
const maxWords = 6
const maxRunes = 80
words := strings.Fields(message)
if len(words) == 0 {
return "New Chat"
}
truncated := false
if len(words) > maxWords {
words = words[:maxWords]
truncated = true
}
title := strings.Join(words, " ")
if truncated {
return stringutil.Truncate(title, maxRunes-1) + "…"
}
return stringutil.Truncate(title, maxRunes)
}