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.
This commit is contained in:
Michael Suchacz
2026-07-08 21:37:30 +02:00
committed by GitHub
parent d16f254714
commit 2ad5af5b54
21 changed files with 1063 additions and 115 deletions
+5 -1
View File
@@ -2302,6 +2302,10 @@ func (p *Server) generateManualTitleCandidate(
if len(messages) == 0 {
return manualTitleCandidateResult{}, nil
}
pasteText, err := titlePasteText(ctx, store, messages)
if err != nil {
return manualTitleCandidateResult{}, xerrors.Errorf("get pasted-text attachments for manual title: %w", err)
}
modelOpts := modelBuildOptionsFromMessages(messages)
// Manual title routes can run over messages that lack API key attribution.
// Fall back to the authenticated caller's delegated key for AI Gateway routing.
@@ -2336,7 +2340,7 @@ func (p *Server) generateManualTitleCandidate(
)
}
title, usage, err := generateManualTitle(titleCtx, messages, titleModel)
title, usage, err := generateManualTitle(titleCtx, messages, pasteText, titleModel)
finishDebugRun(err)
result.title = title
result.usage = usage
+2 -2
View File
@@ -787,7 +787,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
LastModelConfigID: modelConfigID,
Status: database.ChatStatusRunning,
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
Title: fallbackChatTitle(userPrompt),
Title: chatprompt.FallbackTitle(userPrompt),
}
providerID := uuid.New()
modelConfig := database.ChatModelConfig{
@@ -954,7 +954,7 @@ func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing
OwnerID: ownerID,
LastModelConfigID: modelConfigID,
Status: database.ChatStatusWaiting,
Title: fallbackChatTitle(userPrompt),
Title: chatprompt.FallbackTitle(userPrompt),
}
modelConfig := database.ChatModelConfig{
ID: modelConfigID,
+6 -3
View File
@@ -1249,8 +1249,11 @@ func executeToolParsedCommands(toolName string, args json.RawMessage) [][]string
return steps
}
// IsSyntheticPaste reports whether a file name and media type identify
// a synthetic pasted-text attachment created by the chat UI.
//
// TODO: Replace filename-based detection with explicit origin metadata.
func isSyntheticPaste(name string, mediaType string) bool {
func IsSyntheticPaste(name string, mediaType string) bool {
if !syntheticPasteFileNamePattern.MatchString(name) {
return false
}
@@ -1554,7 +1557,7 @@ func partsToMessageParts(
// 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) {
if IsSyntheticPaste(name, mediaType) {
result = append(result, fantasy.TextPart{
Text: formatSyntheticPasteText(name, data),
ProviderOptions: opts,
@@ -1585,7 +1588,7 @@ func partsToMessageParts(
// When the target provider would drop a text-family file part,
// inline the content as text so the model still sees it.
//
// This must run after the isSyntheticPaste check above;
// This must run after the IsSyntheticPaste check above;
// synthetic pastes use a truncating path and must not fall
// through to the non-truncating inline path.
if acceptsFilePart != nil &&
+1 -1
View File
@@ -2382,7 +2382,7 @@ func TestConvertMessagesWithFiles_IsSyntheticPaste(t *testing.T) {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chatprompt.IsSyntheticPasteForTest(tt.fileName, tt.mediaType))
require.Equal(t, tt.want, chatprompt.IsSyntheticPaste(tt.fileName, tt.mediaType))
})
}
}
+3 -2
View File
@@ -7,8 +7,9 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// IsSyntheticPasteForTest exposes isSyntheticPaste for external tests.
var IsSyntheticPasteForTest = isSyntheticPaste
// SyntheticPasteTitleBudgetForTest exposes syntheticPasteTitleBudget
// for external tests.
const SyntheticPasteTitleBudgetForTest = syntheticPasteTitleBudget
// ToolResultPartToMessagePartForTest exposes toolResultPartToMessagePart
// for external tests.
+115
View File
@@ -0,0 +1,115 @@
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)
}
+278
View File
@@ -0,0 +1,278 @@
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)
})
}
}
+77 -36
View File
@@ -17,6 +17,7 @@ import (
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
fantasyvercel "charm.land/fantasy/providers/vercel"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
@@ -155,7 +156,14 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat)
)
return
}
if _, ok := titleInput(chat, messages); !ok {
pasteText, err := titlePasteText(ctx, p.db, messages)
if err != nil {
logger.Debug(ctx, "failed to load pasted-text attachments for automatic title generation",
slog.Error(err),
)
return
}
if _, ok := titleInput(chat, messages, pasteText); !ok {
return
}
// Detach from request; bind to server so Close cancels it.
@@ -175,6 +183,7 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat)
turnCtx,
chat,
messages,
pasteText,
string(route.Provider.Type),
modelConfig.Model,
model,
@@ -205,6 +214,7 @@ func (p *Server) maybeGenerateChatTitle(
ctx context.Context,
chat database.Chat,
messages []database.ChatMessage,
pasteText map[uuid.UUID]string,
fallbackProvider string,
fallbackModelName string,
fallbackModel fantasy.LanguageModel,
@@ -214,7 +224,7 @@ func (p *Server) maybeGenerateChatTitle(
logger slog.Logger,
debugSvc *chatdebug.Service,
) {
input, ok := titleInput(chat, messages)
input, ok := titleInput(chat, messages, pasteText)
if !ok {
return
}
@@ -553,13 +563,16 @@ func validateGeneratedTitle(title string) error {
return nil
}
// titleInput returns the first user message text and whether title
// generation should proceed. It returns false when the chat already
// has assistant/tool replies, has more than one visible user message,
// or the current title doesn't look like a candidate for replacement.
// titleInput returns the first user message title text and whether
// title generation should proceed. It returns false when the chat
// already has assistant/tool replies, has more than one visible user
// message, or the current title doesn't look like a candidate for
// replacement. pasteText carries resolved pasted-text attachment
// content (see titlePasteText) so paste-only messages stay eligible.
func titleInput(
chat database.Chat,
messages []database.ChatMessage,
pasteText map[uuid.UUID]string,
) (string, bool) {
userCount := 0
firstUserText := ""
@@ -579,9 +592,7 @@ func titleInput(
if err != nil {
return "", false
}
firstUserText = strings.TrimSpace(
contentBlocksToText(parsed),
)
firstUserText = chatprompt.TitleText(parsed, pasteText)
}
}
}
@@ -595,13 +606,58 @@ func titleInput(
return firstUserText, true
}
if currentTitle != fallbackChatTitle(firstUserText) {
if currentTitle != chatprompt.FallbackTitle(firstUserText) {
return "", false
}
return firstUserText, true
}
// titlePasteText resolves synthetic pasted-text attachment content
// for visible user messages whose text and file-reference parts yield
// no title input, fetching only bounded prefixes for
// chatprompt.TitleText. It returns nil without touching the database
// when every user message already has text.
func titlePasteText(
ctx context.Context,
store database.Store,
messages []database.ChatMessage,
) (map[uuid.UUID]string, error) {
var ids []uuid.UUID
for _, message := range messages {
if message.Visibility == database.ChatMessageVisibilityModel {
continue
}
if message.Role != database.ChatMessageRoleUser {
continue
}
parsed, err := chatprompt.ParseContent(message)
if err != nil {
continue
}
if chatprompt.TitleText(parsed, nil) != "" {
continue
}
ids = append(ids, chatprompt.SyntheticPasteFileIDs(parsed)...)
}
if len(ids) == 0 {
return nil, nil //nolint:nilnil // Nil map cleanly signals no paste content to resolve.
}
rows, err := store.GetChatFileDataPrefixesByIDs(ctx, database.GetChatFileDataPrefixesByIDsParams{
IDs: ids,
PrefixBytes: chatprompt.TitlePasteBytePrefix,
})
if err != nil {
return nil, xerrors.Errorf("get pasted-text chat file prefixes: %w", err)
}
pasteText := make(map[uuid.UUID]string, len(rows))
for _, row := range rows {
pasteText[row.ID] = chatprompt.TitlePasteText(row.DataPrefix)
}
return pasteText, nil
}
func normalizeTitleOutput(title string) string {
title = normalizeShortTextOutput(title)
if title == "" {
@@ -610,29 +666,6 @@ func normalizeTitleOutput(title string) string {
return truncateRunes(title, 80)
}
func fallbackChatTitle(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 truncateRunes(title, maxRunes-1) + "…"
}
return truncateRunes(title, maxRunes)
}
// contentBlocksToText concatenates the text parts of SDK chat
// message parts into a single space-separated string.
func contentBlocksToText(parts []codersdk.ChatMessagePart) string {
@@ -670,7 +703,14 @@ type manualTitleTurn struct {
text string
}
func extractManualTitleTurns(messages []database.ChatMessage) []manualTitleTurn {
// extractManualTitleTurns flattens visible user and assistant
// messages into title turns. pasteText carries resolved pasted-text
// attachment content (see titlePasteText) so paste-only user messages
// still produce turns.
func extractManualTitleTurns(
messages []database.ChatMessage,
pasteText map[uuid.UUID]string,
) []manualTitleTurn {
turns := make([]manualTitleTurn, 0, len(messages))
for _, message := range messages {
if message.Visibility == database.ChatMessageVisibilityModel {
@@ -692,7 +732,7 @@ func extractManualTitleTurns(messages []database.ChatMessage) []manualTitleTurn
continue
}
text := strings.TrimSpace(contentBlocksToText(parts))
text := chatprompt.TitleText(parts, pasteText)
if text == "" {
continue
}
@@ -802,9 +842,10 @@ func renderManualTitlePrompt(
func generateManualTitle(
ctx context.Context,
messages []database.ChatMessage,
pasteText map[uuid.UUID]string,
fallbackModel fantasy.LanguageModel,
) (string, fantasy.Usage, error) {
turns := extractManualTitleTurns(messages)
turns := extractManualTitleTurns(messages, pasteText)
selected := selectManualTitleTurnIndexes(turns)
firstUserIndex := slices.IndexFunc(turns, func(turn manualTitleTurn) bool {
+170 -5
View File
@@ -2,6 +2,7 @@ package chatd
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -11,13 +12,17 @@ import (
"charm.land/fantasy"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
@@ -27,11 +32,24 @@ import (
func Test_extractManualTitleTurns(t *testing.T) {
t.Parallel()
pasteFileID := uuid.New()
tests := []struct {
name string
messages []database.ChatMessage
want []manualTitleTurn
name string
messages []database.ChatMessage
pasteText map[uuid.UUID]string
want []manualTitleTurn
}{
{
name: "paste only user message resolves via paste text",
messages: []database.ChatMessage{
mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
),
},
pasteText: map[uuid.UUID]string{pasteFileID: "pasted panic output"},
want: []manualTitleTurn{{role: "user", text: "pasted panic output"}},
},
{
name: "filters to visible user and assistant text turns",
messages: []database.ChatMessage{
@@ -82,7 +100,7 @@ func Test_extractManualTitleTurns(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := extractManualTitleTurns(tt.messages)
got := extractManualTitleTurns(tt.messages, tt.pasteText)
require.Equal(t, tt.want, got)
})
}
@@ -363,6 +381,149 @@ func Test_renderManualTitlePrompt(t *testing.T) {
}
}
func Test_titleInput(t *testing.T) {
t.Parallel()
pasteFileID := uuid.New()
pasteContent := "pasted stack trace with details"
pasteMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
)
textMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
codersdk.ChatMessageText("summarize build logs"),
)
tests := []struct {
name string
chat database.Chat
messages []database.ChatMessage
pasteText map[uuid.UUID]string
wantInput string
wantOK bool
}{
{
name: "text message with fallback title is eligible",
chat: database.Chat{Title: chatprompt.FallbackTitle("summarize build logs")},
messages: []database.ChatMessage{textMessage},
wantInput: "summarize build logs",
wantOK: true,
},
{
name: "paste only message with resolved paste text is eligible",
chat: database.Chat{Title: chatprompt.FallbackTitle(pasteContent)},
messages: []database.ChatMessage{pasteMessage},
pasteText: map[uuid.UUID]string{pasteFileID: pasteContent},
wantInput: pasteContent,
wantOK: true,
},
{
name: "paste only message without resolved paste text is skipped",
chat: database.Chat{Title: "New Chat"},
messages: []database.ChatMessage{pasteMessage},
wantOK: false,
},
{
name: "paste only message with user renamed title is skipped",
chat: database.Chat{Title: "my custom name"},
messages: []database.ChatMessage{pasteMessage},
pasteText: map[uuid.UUID]string{pasteFileID: pasteContent},
wantOK: false,
},
{
name: "assistant reply disables generation",
chat: database.Chat{Title: chatprompt.FallbackTitle(pasteContent)},
messages: []database.ChatMessage{
pasteMessage,
mustChatMessage(t, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth,
codersdk.ChatMessageText("done"),
),
},
pasteText: map[uuid.UUID]string{pasteFileID: pasteContent},
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
input, ok := titleInput(tt.chat, tt.messages, tt.pasteText)
require.Equal(t, tt.wantOK, ok)
require.Equal(t, tt.wantInput, input)
})
}
}
func Test_titlePasteText(t *testing.T) {
t.Parallel()
pasteFileID := uuid.New()
pasteMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
)
t.Run("skips fetch when user messages have text", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
// No GetChatFileDataPrefixesByIDs expectation: a fetch would
// fail the test.
db := dbmock.NewMockStore(ctrl)
pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{
mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
codersdk.ChatMessageText("typed text"),
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
),
})
require.NoError(t, err)
require.Nil(t, pasteText)
})
t.Run("resolves paste content for paste only user messages", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
db.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), database.GetChatFileDataPrefixesByIDsParams{
IDs: []uuid.UUID{pasteFileID},
PrefixBytes: chatprompt.TitlePasteBytePrefix,
}).Return([]database.GetChatFileDataPrefixesByIDsRow{
{ID: pasteFileID, DataPrefix: []byte("pasted content")},
}, nil)
pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{pasteMessage})
require.NoError(t, err)
require.Equal(t, map[uuid.UUID]string{pasteFileID: "pasted content"}, pasteText)
})
t.Run("propagates fetch errors", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
db.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), gomock.Any()).Return(nil, sql.ErrConnDone)
_, err := titlePasteText(context.Background(), db, []database.ChatMessage{pasteMessage})
require.ErrorIs(t, err, sql.ErrConnDone)
})
t.Run("ignores non synthetic file only messages", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{
mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
codersdk.ChatMessageFile(uuid.New(), "image/png", "photo.png"),
),
})
require.NoError(t, err)
require.Nil(t, pasteText)
})
}
func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
t.Parallel()
@@ -390,7 +551,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
OrganizationID: org.ID,
OwnerID: owner.ID,
LastModelConfigID: modelConfig.ID,
Title: fallbackChatTitle(userPrompt),
Title: chatprompt.FallbackTitle(userPrompt),
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
})
@@ -422,6 +583,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
ctx,
chat,
[]database.ChatMessage{message},
nil,
"openai",
"test-model",
model,
@@ -488,6 +650,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
title, _, err := generateManualTitle(
context.Background(),
messages,
nil,
model,
)
require.NoError(t, err)
@@ -524,6 +687,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
_, _, err := generateManualTitle(
context.Background(),
messages,
nil,
model,
)
require.NoError(t, err)
@@ -557,6 +721,7 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T)
_, usage, err := generateManualTitle(
context.Background(),
messages,
nil,
model,
)
require.ErrorContains(t, err, "generated title was empty")
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
@@ -62,6 +63,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) {
ctx,
chat,
messages,
nil,
"openai",
"fallback-chat-model",
fallbackModel,
@@ -111,6 +113,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideReadDBError(t *testing.T)
ctx,
chat,
messages,
nil,
"openai",
"fallback-chat-model",
fallbackModel,
@@ -159,6 +162,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideMalformedFallsThrough(t *
ctx,
chat,
messages,
nil,
"openai",
"fallback-chat-model",
fallbackModel,
@@ -232,6 +236,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) {
ctx,
chat,
messages,
nil,
"openai",
"fallback-chat-model",
fallbackModel,
@@ -273,6 +278,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi
ctx,
chat,
messages,
nil,
"openai",
"fallback-chat-model",
fallbackModel,
@@ -326,6 +332,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(
ctx,
chat,
messages,
nil,
"openai",
"fallback-chat-model",
fallbackModel,
@@ -667,7 +674,7 @@ func titleOverrideTestChatAndMessages(t *testing.T) (database.Chat, []database.C
chat := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
Title: fallbackChatTitle(userPrompt),
Title: chatprompt.FallbackTitle(userPrompt),
}
message := mustChatMessage(
t,