From 505619f10327433ad8a8627e76aa74d91b4ce887 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:43:28 +0000 Subject: [PATCH 1/7] fix(coderd): use pasted-text attachments as chat title input A chat created with only a pasted-text attachment (the chat UI collapses large pastes into synthetic .txt files) had no title input: the create path, async auto-titling, and manual title generation all derived text only from text and file-reference parts, leaving such chats permanently named "New Chat" with generation silently skipped. Add chatprompt.TitleText as the single title-input derivation, with synthetic paste content as a fallback when text parts yield nothing, plus chatprompt.SyntheticPasteFileIDs and chatprompt.FallbackTitle to consolidate the duplicated fallback-title logic. Wire it through chat creation, GenerateChatTitleAsync, and the manual propose/regenerate paths, which resolve paste file content only when a user message has no other title text. --- coderd/exp_chats.go | 67 ++--- coderd/exp_chats_test.go | 161 +++++++++++- coderd/x/chatd/chatd.go | 6 +- coderd/x/chatd/chatd_internal_test.go | 4 +- coderd/x/chatd/chatprompt/chatprompt.go | 9 +- coderd/x/chatd/chatprompt/chatprompt_test.go | 2 +- coderd/x/chatd/chatprompt/export_test.go | 5 +- coderd/x/chatd/chatprompt/title.go | 123 +++++++++ coderd/x/chatd/chatprompt/title_test.go | 238 ++++++++++++++++++ coderd/x/chatd/quickgen.go | 111 +++++--- coderd/x/chatd/quickgen_internal_test.go | 171 ++++++++++++- .../x/chatd/title_override_internal_test.go | 9 +- 12 files changed, 805 insertions(+), 101 deletions(-) create mode 100644 coderd/x/chatd/chatprompt/title.go create mode 100644 coderd/x/chatd/chatprompt/title_test.go diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 83c95c15787..400d970980d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -49,6 +49,7 @@ import ( "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "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/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattool" @@ -1117,7 +1118,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } - title := chatTitleFromMessage(titleSource) + title := chatprompt.FallbackTitle(titleSource) modelConfigID, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, apiKey.UserID, req) if modelConfigError != nil { @@ -6345,7 +6346,7 @@ func createChatInputFromParts( var fileIDs []uuid.UUID content := make([]codersdk.ChatMessagePart, 0, len(parts)) - textParts := make([]string, 0, len(parts)) + var pasteText map[uuid.UUID]string for i, part := range parts { switch strings.ToLower(strings.TrimSpace(string(part.Type))) { case string(codersdk.ChatInputPartTypeText): @@ -6357,7 +6358,6 @@ func createChatInputFromParts( } } content = append(content, codersdk.ChatMessageText(text)) - textParts = append(textParts, text) case string(codersdk.ChatInputPartTypeFile): if part.FileID == uuid.Nil { return nil, "", nil, &codersdk.Response{ @@ -6366,8 +6366,9 @@ func createChatInputFromParts( } } // Validate that the file exists and get its media type. - // File data is not loaded here; it's resolved at LLM - // dispatch time via chatFileResolver. + // The loaded file data is only retained for synthetic + // pastes below; LLM dispatch re-resolves file content via + // chatFileResolver. chatFile, err := db.GetChatFileByID(ctx, part.FileID) if err != nil { if httpapi.Is404Error(err) { @@ -6389,6 +6390,14 @@ func createChatInputFromParts( } content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype, chatFile.Name)) fileIDs = append(fileIDs, part.FileID) + // Pasted-text attachments feed title derivation when the + // message has no text parts. + if chatprompt.IsSyntheticPaste(chatFile.Name, chatFile.Mimetype) { + if pasteText == nil { + pasteText = make(map[uuid.UUID]string) + } + pasteText[part.FileID] = string(chatFile.Data) + } // file-reference parts carry inline code snippets, not uploaded // files. They have no FileID and are excluded from file tracking. case string(codersdk.ChatInputPartTypeFileReference): @@ -6399,17 +6408,6 @@ func createChatInputFromParts( } } content = append(content, codersdk.ChatMessageFileReference(part.FileName, part.StartLine, part.EndLine, part.Content)) - // Build text representation for title generation. - lineRange := fmt.Sprintf("%d", part.StartLine) - if part.StartLine != part.EndLine { - lineRange = fmt.Sprintf("%d-%d", part.StartLine, part.EndLine) - } - var sb strings.Builder - _, _ = fmt.Fprintf(&sb, "[file-reference] %s:%s", part.FileName, lineRange) - if strings.TrimSpace(part.Content) != "" { - _, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, strings.TrimSpace(part.Content)) - } - textParts = append(textParts, sb.String()) default: return nil, "", nil, &codersdk.Response{ Message: "Invalid input part.", @@ -6431,42 +6429,13 @@ func createChatInputFromParts( Detail: fmt.Sprintf("%s must include at least one text or file part.", fieldName), } } - titleSource := strings.TrimSpace(strings.Join(textParts, " ")) + // The shared derivation keeps this create-time titleSource + // identical to the extraction used by title generation, which + // gates auto-titling on that equality (see chatprompt.TitleText). + titleSource := chatprompt.TitleText(content, pasteText) return content, titleSource, fileIDs, nil } -func chatTitleFromMessage(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 { - title += "…" - } - return truncateRunes(title, maxRunes) -} - -func truncateRunes(value string, maxLen int) string { - if maxLen <= 0 { - return "" - } - - runes := []rune(value) - if len(runes) <= maxLen { - return value - } - - return string(runes[:maxLen]) -} - // linkFilesToChat inserts file-link rows into the chat_file_links // join table. Cap enforcement and dedup are handled atomically in // SQL. On success returns (nil, false). On failure returns the full diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index d79e2f83e9b..a804ea70315 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -7465,7 +7465,8 @@ func TestChatMessageWithFiles(t *testing.T) { }) require.NoError(t, err) - // With no text, chatTitleFromMessage("") returns "New Chat". + // With no text and no pasted-text attachment, the fallback + // title derivation yields "New Chat". require.Equal(t, "New Chat", chat.Title) require.Len(t, chat.Files, 1) f := chat.Files[0] @@ -7477,6 +7478,41 @@ func TestChatMessageWithFiles(t *testing.T) { require.NotZero(t, f.CreatedAt) }) + t.Run("PasteOnlyOnCreate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + // Upload a synthetic pasted-text attachment as created by the + // chat UI when a large paste is collapsed into a file. + uploadResp, err := client.UploadChatFile( + ctx, + firstUser.OrganizationID, + "text/plain", + "pasted-text-2026-01-02-03-04-05.txt", + strings.NewReader("Fix the flaky test in coderd please"), + ) + require.NoError(t, err) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeFile, + FileID: uploadResp.ID, + }, + }, + }) + require.NoError(t, err) + + // The fallback title derives from the pasted attachment + // content instead of "New Chat". + require.Equal(t, "Fix the flaky test in coderd…", chat.Title) + }) + t.Run("InvalidFileID", func(t *testing.T) { t.Parallel() @@ -8616,6 +8652,30 @@ func TestRegenerateChatTitle(t *testing.T) { require.False(t, persisted.WorkerID.Valid) }) + t.Run("PasteOnlyChat", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createTitleGenerationModelConfig(t, client) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "New Chat", + Status: database.ChatStatusCompleted, + }) + // The chat's only user message is a synthetic pasted-text + // attachment with no text parts. + seedPasteOnlyTitleSourceMessage(ctx, t, db, chat, modelConfig.ID, "pasted stack trace for title") + + updated, err := client.RegenerateChatTitle(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, "Test Chat", updated.Title) + }) + t.Run("NoDefaultModelConfig", func(t *testing.T) { t.Parallel() @@ -9000,6 +9060,68 @@ func TestPostChats_AutomaticTitleGeneration(t *testing.T) { coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) } +func TestPostChats_AutomaticTitleGenerationPasteOnly(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + const pasteContent = "panic: runtime error: invalid memory address or nil pointer dereference" + + // titleRequested is signaled when the provider receives a structured + // title-generation request whose input carries the pasted attachment + // content. Without paste-aware title input the request is never + // issued because the message has no text parts. + titleRequested := make(chan struct{}, 1) + baseURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if req.Stream { + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Hello from test server.")...) + } + if bytes.Contains(req.RawBody, []byte("propose_title")) && + bytes.Contains(req.RawBody, []byte("nil pointer dereference")) { + select { + case titleRequested <- struct{}{}: + default: + } + } + return chattest.OpenAINonStreamingResponse(`{"title": "Generated Title"}`) + }) + + client, api := newChatClientWithAPI(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfigWithBaseURL(t, client, baseURL) + + uploadResp, err := client.UploadChatFile( + ctx, + firstUser.OrganizationID, + "text/plain", + "pasted-text-2026-01-02-03-04-05.txt", + strings.NewReader(pasteContent), + ) + require.NoError(t, err) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeFile, + FileID: uploadResp.ID, + }}, + }) + require.NoError(t, err) + // The create response carries the synchronous fallback title derived + // from the pasted attachment content. + require.Equal(t, "panic: runtime error: invalid memory address…", chat.Title) + + select { + case <-titleRequested: + case <-ctx.Done(): + t.Fatal("timed out waiting for automatic title generation to be triggered") + } + + // Drain background work so the detached goroutine finishes before the test + // (and its fake provider) tears down. + coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) +} + func TestGetChatDiffStatus(t *testing.T) { t.Parallel() @@ -10988,6 +11110,43 @@ func seedManualTitleSourceMessage( }) } +// seedPasteOnlyTitleSourceMessage inserts a user message whose only +// content is a synthetic pasted-text attachment, mirroring a chat +// created from a large paste with no typed text. +func seedPasteOnlyTitleSourceMessage( + ctx context.Context, + t testing.TB, + db database.Store, + chat database.Chat, + modelConfigID uuid.UUID, + pasteContent string, +) { + t.Helper() + + const pasteFileName = "pasted-text-2026-01-02-03-04-05.txt" + file, err := db.InsertChatFile(dbauthz.AsSystemRestricted(ctx), database.InsertChatFileParams{ + OwnerID: chat.OwnerID, + OrganizationID: chat.OrganizationID, + Name: pasteFileName, + Mimetype: "text/plain", + Data: []byte(pasteContent), + }) + require.NoError(t, err) + + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageFile(file.ID, "text/plain", pasteFileName), + }) + require.NoError(t, err) + _ = dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: chat.OwnerID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityBoth, + Content: content, + }) +} + // createTitleGenerationModelConfig provisions a model config on the openai // provider type, which routes structured title generation through the // Responses API. The chattest fake answers it with {"title": "Test Chat"}. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index f57058f1363..4f27f2cd079 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -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 diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 930e395fdc3..98adc25ccef 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -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, diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index 29028e6d256..ab112f6a8fb 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -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 && diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index fa9a58869ba..17a22c144f7 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -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)) }) } } diff --git a/coderd/x/chatd/chatprompt/export_test.go b/coderd/x/chatd/chatprompt/export_test.go index 588664a0a70..16b3090c2b5 100644 --- a/coderd/x/chatd/chatprompt/export_test.go +++ b/coderd/x/chatd/chatprompt/export_test.go @@ -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. diff --git a/coderd/x/chatd/chatprompt/title.go b/coderd/x/chatd/chatprompt/title.go new file mode 100644 index 00000000000..021c4a330b9 --- /dev/null +++ b/coderd/x/chatd/chatprompt/title.go @@ -0,0 +1,123 @@ +package chatprompt + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + + "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 + +// 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: + lineRange := fmt.Sprintf("%d", part.StartLine) + if part.StartLine != part.EndLine { + lineRange = fmt.Sprintf("%d-%d", part.StartLine, part.EndLine) + } + var sb strings.Builder + _, _ = fmt.Fprintf(&sb, "[file-reference] %s:%s", part.FileName, lineRange) + if strings.TrimSpace(part.Content) != "" { + _, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, strings.TrimSpace(part.Content)) + } + texts = append(texts, sb.String()) + } + } + 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, truncateTitleRunes(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 truncateTitleRunes(title, maxRunes-1) + "…" + } + + return truncateTitleRunes(title, maxRunes) +} + +func truncateTitleRunes(value string, maxLen int) string { + if maxLen <= 0 { + return "" + } + + runes := []rune(value) + if len(runes) <= maxLen { + return value + } + + return string(runes[:maxLen]) +} diff --git a/coderd/x/chatd/chatprompt/title_test.go b/coderd/x/chatd/chatprompt/title_test.go new file mode 100644 index 00000000000..294ba6027c8 --- /dev/null +++ b/coderd/x/chatd/chatprompt/title_test.go @@ -0,0 +1,238 @@ +package chatprompt_test + +import ( + "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 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) + }) + } +} diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 91f37a87463..fbe72363d38 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -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,42 +606,62 @@ func titleInput( return firstUserText, true } - if currentTitle != fallbackChatTitle(firstUserText) { + if currentTitle != chatprompt.FallbackTitle(firstUserText) { return "", false } return firstUserText, true } -func normalizeTitleOutput(title string) string { - title = normalizeShortTextOutput(title) - if title == "" { - return "" +// titlePasteText resolves synthetic pasted-text attachment content for +// visible user messages whose text and file-reference parts alone +// yield no title input. The result maps file IDs to raw file content +// for chatprompt.TitleText. It returns nil without touching the +// database when every user message already has text, so typical chats +// never incur a file fetch. +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)...) } - 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" + if len(ids) == 0 { + return nil, nil //nolint:nilnil // Nil map cleanly signals no paste content to resolve. } - truncated := false - if len(words) > maxWords { - words = words[:maxWords] - truncated = true + files, err := store.GetChatFilesByIDs(ctx, ids) + if err != nil { + return nil, xerrors.Errorf("get pasted-text chat files: %w", err) } - - title := strings.Join(words, " ") - if truncated { - return truncateRunes(title, maxRunes-1) + "…" + pasteText := make(map[uuid.UUID]string, len(files)) + for _, file := range files { + pasteText[file.ID] = string(file.Data) } + return pasteText, nil +} - return truncateRunes(title, maxRunes) +func normalizeTitleOutput(title string) string { + title = normalizeShortTextOutput(title) + if title == "" { + return "" + } + return truncateRunes(title, 80) } // contentBlocksToText concatenates the text parts of SDK chat @@ -670,7 +701,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 +730,7 @@ func extractManualTitleTurns(messages []database.ChatMessage) []manualTitleTurn continue } - text := strings.TrimSpace(contentBlocksToText(parts)) + text := chatprompt.TitleText(parts, pasteText) if text == "" { continue } @@ -802,9 +840,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 { diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index f2cfef1a245..8b1f954787f 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -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,145 @@ 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 GetChatFilesByIDs 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().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{pasteFileID}).Return([]database.ChatFile{ + {ID: pasteFileID, Data: []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().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{pasteFileID}).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 +547,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 +579,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) { ctx, chat, []database.ChatMessage{message}, + nil, "openai", "test-model", model, @@ -488,6 +646,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) { title, _, err := generateManualTitle( context.Background(), messages, + nil, model, ) require.NoError(t, err) @@ -524,6 +683,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) { _, _, err := generateManualTitle( context.Background(), messages, + nil, model, ) require.NoError(t, err) @@ -557,6 +717,7 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T) _, usage, err := generateManualTitle( context.Background(), messages, + nil, model, ) require.ErrorContains(t, err, "generated title was empty") diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index 83fd36d9ce4..af83be53134 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -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, From 9c7a80633925177a71530fc11f0ac50d066e9a30 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:52:24 +0000 Subject: [PATCH 2/7] refactor(coderd/x/chatd/chatprompt): reuse existing helpers in title derivation --- coderd/x/chatd/chatprompt/title.go | 32 +++++------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/coderd/x/chatd/chatprompt/title.go b/coderd/x/chatd/chatprompt/title.go index 021c4a330b9..6fa85f9a9af 100644 --- a/coderd/x/chatd/chatprompt/title.go +++ b/coderd/x/chatd/chatprompt/title.go @@ -1,11 +1,11 @@ package chatprompt import ( - "fmt" "strings" "github.com/google/uuid" + stringutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/codersdk" ) @@ -36,16 +36,7 @@ func TitleText(parts []codersdk.ChatMessagePart, pasteText map[uuid.UUID]string) } texts = append(texts, text) case codersdk.ChatMessagePartTypeFileReference: - lineRange := fmt.Sprintf("%d", part.StartLine) - if part.StartLine != part.EndLine { - lineRange = fmt.Sprintf("%d-%d", part.StartLine, part.EndLine) - } - var sb strings.Builder - _, _ = fmt.Fprintf(&sb, "[file-reference] %s:%s", part.FileName, lineRange) - if strings.TrimSpace(part.Content) != "" { - _, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, strings.TrimSpace(part.Content)) - } - texts = append(texts, sb.String()) + texts = append(texts, fileReferencePartToText(part)) } } if joined := strings.TrimSpace(strings.Join(texts, " ")); joined != "" { @@ -61,7 +52,7 @@ func TitleText(parts []codersdk.ChatMessagePart, pasteText map[uuid.UUID]string) if content == "" { continue } - pastes = append(pastes, truncateTitleRunes(content, syntheticPasteTitleBudget)) + pastes = append(pastes, stringutil.Truncate(content, syntheticPasteTitleBudget)) } return strings.TrimSpace(strings.Join(pastes, "\n\n")) } @@ -103,21 +94,8 @@ func FallbackTitle(message string) string { title := strings.Join(words, " ") if truncated { - return truncateTitleRunes(title, maxRunes-1) + "…" - } - - return truncateTitleRunes(title, maxRunes) -} - -func truncateTitleRunes(value string, maxLen int) string { - if maxLen <= 0 { - return "" - } - - runes := []rune(value) - if len(runes) <= maxLen { - return value + return stringutil.Truncate(title, maxRunes-1) + "…" } - return string(runes[:maxLen]) + return stringutil.Truncate(title, maxRunes) } From ae3d9f39cee20f14b0ddce6509496d5abaa17b95 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:33:11 +0000 Subject: [PATCH 3/7] fix(coderd/exp_chats.go): defer paste blob string copies in chat input parsing Mixed messages with text parts and synthetic pasted-text attachments copied every paste blob to a string that TitleText then ignored. Retain blob references during part validation and materialize strings only when text and file-reference parts yield no title input. --- coderd/exp_chats.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 400d970980d..9756e044010 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6346,7 +6346,7 @@ func createChatInputFromParts( var fileIDs []uuid.UUID content := make([]codersdk.ChatMessagePart, 0, len(parts)) - var pasteText map[uuid.UUID]string + var pasteData map[uuid.UUID][]byte for i, part := range parts { switch strings.ToLower(strings.TrimSpace(string(part.Type))) { case string(codersdk.ChatInputPartTypeText): @@ -6391,12 +6391,14 @@ func createChatInputFromParts( content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype, chatFile.Name)) fileIDs = append(fileIDs, part.FileID) // Pasted-text attachments feed title derivation when the - // message has no text parts. + // message has no other title text. Only the blob reference + // is retained here; copying to string is deferred until the + // fallback is known to be needed, since blobs can be large. if chatprompt.IsSyntheticPaste(chatFile.Name, chatFile.Mimetype) { - if pasteText == nil { - pasteText = make(map[uuid.UUID]string) + if pasteData == nil { + pasteData = make(map[uuid.UUID][]byte) } - pasteText[part.FileID] = string(chatFile.Data) + pasteData[part.FileID] = chatFile.Data } // file-reference parts carry inline code snippets, not uploaded // files. They have no FileID and are excluded from file tracking. @@ -6432,7 +6434,17 @@ func createChatInputFromParts( // The shared derivation keeps this create-time titleSource // identical to the extraction used by title generation, which // gates auto-titling on that equality (see chatprompt.TitleText). - titleSource := chatprompt.TitleText(content, pasteText) + // Paste blobs are materialized as strings only when text and + // file-reference parts yield nothing, so mixed messages never + // copy attachment data they will not use. + titleSource := chatprompt.TitleText(content, nil) + if titleSource == "" && len(pasteData) > 0 { + pasteText := make(map[uuid.UUID]string, len(pasteData)) + for id, data := range pasteData { + pasteText[id] = string(data) + } + titleSource = chatprompt.TitleText(content, pasteText) + } return content, titleSource, fileIDs, nil } From 2b57aadad3eaf9ca714ef03952a1792045a7f604 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:57:34 +0000 Subject: [PATCH 4/7] fix(coderd/exp_chats.go): derive paste titles only on the chat create path --- coderd/exp_chats.go | 71 +++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 9756e044010..c28653473a4 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6328,17 +6328,40 @@ func createChatInputFromRequest(ctx context.Context, db database.Store, req code []uuid.UUID, *codersdk.Response, ) { - return createChatInputFromParts(ctx, db, req.Content, "content") + content, pasteData, fileIDs, inputError := createChatInputFromParts(ctx, db, req.Content, "content") + if inputError != nil { + return nil, "", nil, inputError + } + // The shared derivation keeps this create-time titleSource + // identical to the extraction used by title generation, which + // gates auto-titling on that equality (see chatprompt.TitleText). + // Paste blobs are materialized as strings only when text and + // file-reference parts yield nothing, so mixed messages never + // copy attachment data they will not use. + titleSource := chatprompt.TitleText(content, nil) + if titleSource == "" && len(pasteData) > 0 { + pasteText := make(map[uuid.UUID]string, len(pasteData)) + for id, data := range pasteData { + pasteText[id] = string(data) + } + titleSource = chatprompt.TitleText(content, pasteText) + } + return content, titleSource, fileIDs, nil } +// createChatInputFromParts validates input parts and converts them to +// message content. The returned map holds raw pasted-text blobs keyed +// by file ID; only the create path derives a title from it (see +// createChatInputFromRequest), message send and edit callers discard +// it without copying any blob data. func createChatInputFromParts( ctx context.Context, db database.Store, parts []codersdk.ChatInputPart, fieldName string, -) ([]codersdk.ChatMessagePart, string, []uuid.UUID, *codersdk.Response) { +) ([]codersdk.ChatMessagePart, map[uuid.UUID][]byte, []uuid.UUID, *codersdk.Response) { if len(parts) == 0 { - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Content is required.", Detail: "Content cannot be empty.", } @@ -6352,7 +6375,7 @@ func createChatInputFromParts( case string(codersdk.ChatInputPartTypeText): text := strings.TrimSpace(part.Text) if text == "" { - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Invalid input part.", Detail: fmt.Sprintf("%s[%d].text cannot be empty.", fieldName, i), } @@ -6360,7 +6383,7 @@ func createChatInputFromParts( content = append(content, codersdk.ChatMessageText(text)) case string(codersdk.ChatInputPartTypeFile): if part.FileID == uuid.Nil { - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Invalid input part.", Detail: fmt.Sprintf("%s[%d].file_id is required for file parts.", fieldName, i), } @@ -6372,28 +6395,28 @@ func createChatInputFromParts( chatFile, err := db.GetChatFileByID(ctx, part.FileID) if err != nil { if httpapi.Is404Error(err) { - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Invalid input part.", Detail: fmt.Sprintf("%s[%d].file_id references a file that does not exist.", fieldName, i), } } - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Internal error.", Detail: fmt.Sprintf("Failed to retrieve file for %s[%d].", fieldName, i), } } if !chatfiles.IsAllowedPromptInputMediaType(chatFile.Mimetype) { - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Invalid input part.", Detail: fmt.Sprintf("%s[%d].file_id references a file type that cannot be used as prompt input. Allowed types: %s.", fieldName, i, chatfiles.AllowedPromptInputMediaTypesString()), } } content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype, chatFile.Name)) fileIDs = append(fileIDs, part.FileID) - // Pasted-text attachments feed title derivation when the - // message has no other title text. Only the blob reference - // is retained here; copying to string is deferred until the - // fallback is known to be needed, since blobs can be large. + // Pasted-text attachments feed create-time title derivation + // when the message has no other title text. Only the blob + // reference is retained here; blobs are never copied on the + // message send and edit paths, which discard this map. if chatprompt.IsSyntheticPaste(chatFile.Name, chatFile.Mimetype) { if pasteData == nil { pasteData = make(map[uuid.UUID][]byte) @@ -6404,14 +6427,14 @@ func createChatInputFromParts( // files. They have no FileID and are excluded from file tracking. case string(codersdk.ChatInputPartTypeFileReference): if part.FileName == "" { - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Invalid input part.", Detail: fmt.Sprintf("%s[%d].file_name cannot be empty for file-reference.", fieldName, i), } } content = append(content, codersdk.ChatMessageFileReference(part.FileName, part.StartLine, part.EndLine, part.Content)) default: - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Invalid input part.", Detail: fmt.Sprintf( "%s[%d].type %q is not supported.", @@ -6423,29 +6446,13 @@ func createChatInputFromParts( } } - // Allow file-only messages. The titleSource may be empty - // when only file parts are provided, callers handle this. if len(content) == 0 { - return nil, "", nil, &codersdk.Response{ + return nil, nil, nil, &codersdk.Response{ Message: "Content is required.", Detail: fmt.Sprintf("%s must include at least one text or file part.", fieldName), } } - // The shared derivation keeps this create-time titleSource - // identical to the extraction used by title generation, which - // gates auto-titling on that equality (see chatprompt.TitleText). - // Paste blobs are materialized as strings only when text and - // file-reference parts yield nothing, so mixed messages never - // copy attachment data they will not use. - titleSource := chatprompt.TitleText(content, nil) - if titleSource == "" && len(pasteData) > 0 { - pasteText := make(map[uuid.UUID]string, len(pasteData)) - for id, data := range pasteData { - pasteText[id] = string(data) - } - titleSource = chatprompt.TitleText(content, pasteText) - } - return content, titleSource, fileIDs, nil + return content, pasteData, fileIDs, nil } // linkFilesToChat inserts file-link rows into the chat_file_links From a93b02ed91b41bae182ce3f5092ef8169604652d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:06:29 +0000 Subject: [PATCH 5/7] fix(coderd): bound paste blob string copies for title derivation --- coderd/exp_chats.go | 2 +- coderd/x/chatd/chatprompt/export_test.go | 4 +++ coderd/x/chatd/chatprompt/title.go | 18 ++++++++++- coderd/x/chatd/chatprompt/title_test.go | 40 ++++++++++++++++++++++++ coderd/x/chatd/quickgen.go | 10 +++--- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index c28653473a4..bd70c38d8bc 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6342,7 +6342,7 @@ func createChatInputFromRequest(ctx context.Context, db database.Store, req code if titleSource == "" && len(pasteData) > 0 { pasteText := make(map[uuid.UUID]string, len(pasteData)) for id, data := range pasteData { - pasteText[id] = string(data) + pasteText[id] = chatprompt.TitlePasteText(data) } titleSource = chatprompt.TitleText(content, pasteText) } diff --git a/coderd/x/chatd/chatprompt/export_test.go b/coderd/x/chatd/chatprompt/export_test.go index 16b3090c2b5..9eb431eb6a6 100644 --- a/coderd/x/chatd/chatprompt/export_test.go +++ b/coderd/x/chatd/chatprompt/export_test.go @@ -11,6 +11,10 @@ import ( // for external tests. const SyntheticPasteTitleBudgetForTest = syntheticPasteTitleBudget +// TitlePasteBytePrefixForTest exposes titlePasteBytePrefix for +// external tests. +const TitlePasteBytePrefixForTest = titlePasteBytePrefix + // ToolResultPartToMessagePartForTest exposes toolResultPartToMessagePart // for external tests. var ToolResultPartToMessagePartForTest = toolResultPartToMessagePart diff --git a/coderd/x/chatd/chatprompt/title.go b/coderd/x/chatd/chatprompt/title.go index 6fa85f9a9af..49533e3e71c 100644 --- a/coderd/x/chatd/chatprompt/title.go +++ b/coderd/x/chatd/chatprompt/title.go @@ -15,11 +15,27 @@ import ( // the model prompt. const syntheticPasteTitleBudget = 16 * 1024 +// titlePasteBytePrefix caps, in bytes, how much of a pasted-text blob +// is copied to a string for title derivation. Four bytes per rune (the +// UTF-8 maximum) guarantees the prefix still spans at least +// syntheticPasteTitleBudget complete runes, so TitleText's rune +// truncation yields the same result it would on the full content. +const titlePasteBytePrefix = 4 * syntheticPasteTitleBudget + +// TitlePasteText converts a pasted-text blob to TitleText input, +// copying at most titlePasteBytePrefix bytes instead of the whole +// blob. 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. +// syntheticPasteTitleBudget runes per file. Map values must come from +// TitlePasteText. // // The chat-creation fallback title and both title-generation paths // must derive their input through this function: auto-titling only diff --git a/coderd/x/chatd/chatprompt/title_test.go b/coderd/x/chatd/chatprompt/title_test.go index 294ba6027c8..7d0cc930817 100644 --- a/coderd/x/chatd/chatprompt/title_test.go +++ b/coderd/x/chatd/chatprompt/title_test.go @@ -1,6 +1,7 @@ package chatprompt_test import ( + "bytes" "strings" "testing" @@ -133,6 +134,45 @@ func TestTitleText_TruncatesPasteContentRuneSafe(t *testing.T) { 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.TitlePasteBytePrefixForTest+4096) + require.Len(t, chatprompt.TitlePasteText(data), chatprompt.TitlePasteBytePrefixForTest) + }) + + 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.TitlePasteBytePrefixForTest/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() diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index fbe72363d38..416f7df7048 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -615,10 +615,10 @@ func titleInput( // titlePasteText resolves synthetic pasted-text attachment content for // visible user messages whose text and file-reference parts alone -// yield no title input. The result maps file IDs to raw file content -// for chatprompt.TitleText. It returns nil without touching the -// database when every user message already has text, so typical chats -// never incur a file fetch. +// yield no title input. The result maps file IDs to bounded content +// prefixes (see chatprompt.TitlePasteText) for chatprompt.TitleText. +// It returns nil without touching the database when every user message +// already has text, so typical chats never incur a file fetch. func titlePasteText( ctx context.Context, store database.Store, @@ -651,7 +651,7 @@ func titlePasteText( } pasteText := make(map[uuid.UUID]string, len(files)) for _, file := range files { - pasteText[file.ID] = string(file.Data) + pasteText[file.ID] = chatprompt.TitlePasteText(file.Data) } return pasteText, nil } From e1e289501b111086de3d4d65e7afcc8359d4094e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:35:10 +0000 Subject: [PATCH 6/7] fix(coderd): fetch only bounded paste prefixes for title derivation --- coderd/database/dbauthz/dbauthz.go | 28 +++++++++++++ coderd/database/dbauthz/dbauthz_test.go | 26 ++++++++++++ coderd/database/dbmetrics/querymetrics.go | 8 ++++ coderd/database/dbmock/dbmock.go | 15 +++++++ coderd/database/modelmethods.go | 4 ++ coderd/database/querier.go | 6 +++ coderd/database/querier_test.go | 51 +++++++++++++++++++++++ coderd/database/queries.sql.go | 51 +++++++++++++++++++++++ coderd/database/queries/chatfiles.sql | 10 +++++ coderd/x/chatd/chatprompt/export_test.go | 4 -- coderd/x/chatd/chatprompt/title.go | 16 +++---- coderd/x/chatd/chatprompt/title_test.go | 8 ++-- coderd/x/chatd/quickgen.go | 23 ++++++---- coderd/x/chatd/quickgen_internal_test.go | 12 ++++-- 14 files changed, 235 insertions(+), 27 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 285e01e61e5..9da30e7f513 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3239,6 +3239,34 @@ func (q *querier) GetChatFileByID(ctx context.Context, id uuid.UUID) (database.C return file, nil } +func (q *querier) GetChatFileDataPrefixesByIDs(ctx context.Context, arg database.GetChatFileDataPrefixesByIDsParams) ([]database.GetChatFileDataPrefixesByIDsRow, error) { + rows, err := q.db.GetChatFileDataPrefixesByIDs(ctx, arg) + if err != nil { + return nil, err + } + var prepared rbac.PreparedAuthorized + for _, row := range rows { + fileAuthErr := q.authorizeContext(ctx, policy.ActionRead, row) + if fileAuthErr == nil { + continue + } + if prepared == nil { + prepared, err = prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChat.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + } + chats, err := q.db.GetAuthorizedChatsByChatFileID(ctx, row.ID, prepared) + if err != nil { + return nil, err + } + if len(chats) == 0 { + return nil, fileAuthErr + } + } + return rows, nil +} + func (q *querier) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) { if _, err := q.GetChatByID(ctx, chatID); err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a98bf28ba6c..74d56d4fbf4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -206,6 +206,25 @@ func TestChatFilesAllowLinkedChatReads(t *testing.T) { require.NoError(t, err) require.Equal(t, []database.ChatFile{file}, got) }) + + t.Run("GetChatFileDataPrefixesByIDs", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + row := testutil.Fake(t, gofakeit.New(0), database.GetChatFileDataPrefixesByIDsRow{}) + arg := database.GetChatFileDataPrefixesByIDsParams{IDs: []uuid.UUID{row.ID}, PrefixBytes: 64} + + db.EXPECT().Wrappers().Return([]string{}).AnyTimes() + db.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), arg).Return([]database.GetChatFileDataPrefixesByIDsRow{row}, nil) + db.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), row.ID, gomock.Any()).Return([]database.Chat{{ID: uuid.New()}}, nil) + + q := dbauthz.New(db, authorizer, slogtest.Make(t, nil), coderdtest.AccessControlStorePointer()) + got, err := q.GetChatFileDataPrefixesByIDs(ctx, arg) + + require.NoError(t, err) + require.Equal(t, []database.GetChatFileDataPrefixesByIDsRow{row}, got) + }) } //nolint:tparallel,paralleltest // It toggles the global chat ACL flag. @@ -959,6 +978,13 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), file.ID, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes() check.Args([]uuid.UUID{file.ID}).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns([]database.ChatFile{file}) })) + s.Run("GetChatFileDataPrefixesByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + row := testutil.Fake(s.T(), faker, database.GetChatFileDataPrefixesByIDsRow{}) + arg := database.GetChatFileDataPrefixesByIDsParams{IDs: []uuid.UUID{row.ID}, PrefixBytes: 64} + dbm.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), arg).Return([]database.GetChatFileDataPrefixesByIDsRow{row}, nil).AnyTimes() + dbm.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), row.ID, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(row.OwnerID.String()).InOrg(row.OrganizationID).WithID(row.ID), policy.ActionRead).Returns([]database.GetChatFileDataPrefixesByIDsRow{row}) + })) s.Run("GetChatFileMetadataByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) file := testutil.Fake(s.T(), faker, database.ChatFile{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 1558c2edb98..a14f52f82cd 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1561,6 +1561,14 @@ func (m queryMetricsStore) GetChatFileByID(ctx context.Context, id uuid.UUID) (d return r0, r1 } +func (m queryMetricsStore) GetChatFileDataPrefixesByIDs(ctx context.Context, arg database.GetChatFileDataPrefixesByIDsParams) ([]database.GetChatFileDataPrefixesByIDsRow, error) { + start := time.Now() + r0, r1 := m.s.GetChatFileDataPrefixesByIDs(ctx, arg) + m.queryLatencies.WithLabelValues("GetChatFileDataPrefixesByIDs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatFileDataPrefixesByIDs").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) { start := time.Now() r0, r1 := m.s.GetChatFileMetadataByChatID(ctx, chatID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bc28a850931..352cfb1a953 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2878,6 +2878,21 @@ func (mr *MockStoreMockRecorder) GetChatFileByID(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFileByID", reflect.TypeOf((*MockStore)(nil).GetChatFileByID), ctx, id) } +// GetChatFileDataPrefixesByIDs mocks base method. +func (m *MockStore) GetChatFileDataPrefixesByIDs(ctx context.Context, arg database.GetChatFileDataPrefixesByIDsParams) ([]database.GetChatFileDataPrefixesByIDsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatFileDataPrefixesByIDs", ctx, arg) + ret0, _ := ret[0].([]database.GetChatFileDataPrefixesByIDsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatFileDataPrefixesByIDs indicates an expected call of GetChatFileDataPrefixesByIDs. +func (mr *MockStoreMockRecorder) GetChatFileDataPrefixesByIDs(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFileDataPrefixesByIDs", reflect.TypeOf((*MockStore)(nil).GetChatFileDataPrefixesByIDs), ctx, arg) +} + // GetChatFileMetadataByChatID mocks base method. func (m *MockStore) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index cec3e1d291b..8bc15192637 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -248,6 +248,10 @@ func (c GetChatFileMetadataByChatIDRow) RBACObject() rbac.Object { return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID) } +func (c GetChatFileDataPrefixesByIDsRow) RBACObject() rbac.Object { + return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID) +} + func (s APIKeyScope) ToRBAC() rbac.ScopeName { switch s { case ApiKeyScopeCoderAll: diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 85e4599b02e..52b830b0d9d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -417,6 +417,12 @@ type sqlcQuerier interface { // query does not walk up from a child. GetChatFamilyIDsByRootID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error) + // GetChatFileDataPrefixesByIDs returns a bounded prefix of each + // file's content. Title derivation needs only the beginning of each + // pasted-text attachment, so substr caps database I/O and server + // memory regardless of stored blob size. Owner and organization + // columns support row-level authorization. + GetChatFileDataPrefixesByIDs(ctx context.Context, arg GetChatFileDataPrefixesByIDsParams) ([]GetChatFileDataPrefixesByIDsRow, error) // GetChatFileMetadataByChatID returns lightweight file metadata for // all files linked to a chat. The data column is excluded to avoid // loading file content. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 3b5e2918ad6..00afbbd34f8 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1,6 +1,7 @@ package database_test import ( + "bytes" "context" "database/sql" "encoding/json" @@ -1918,6 +1919,56 @@ func TestGetAuthorizedChatsByChatFileIDACLSharing(t *testing.T) { require.Empty(t, rows[0].GroupACL) } +func TestGetChatFileDataPrefixesByIDs(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + ctx := testutil.Context(t, testutil.WaitMedium) + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + + owner := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + + longData := bytes.Repeat([]byte("a"), 100) + longFile, err := db.InsertChatFile(ctx, database.InsertChatFileParams{ + OwnerID: owner.ID, + OrganizationID: org.ID, + Name: "long.txt", + Mimetype: "text/plain", + Data: longData, + }) + require.NoError(t, err) + shortFile, err := db.InsertChatFile(ctx, database.InsertChatFileParams{ + OwnerID: owner.ID, + OrganizationID: org.ID, + Name: "short.txt", + Mimetype: "text/plain", + Data: []byte("tiny"), + }) + require.NoError(t, err) + + rows, err := db.GetChatFileDataPrefixesByIDs(ctx, database.GetChatFileDataPrefixesByIDsParams{ + IDs: []uuid.UUID{longFile.ID, shortFile.ID}, + PrefixBytes: 16, + }) + require.NoError(t, err) + require.Len(t, rows, 2) + + prefixes := make(map[uuid.UUID]database.GetChatFileDataPrefixesByIDsRow, len(rows)) + for _, row := range rows { + prefixes[row.ID] = row + } + require.Equal(t, longData[:16], prefixes[longFile.ID].DataPrefix) + require.Equal(t, []byte("tiny"), prefixes[shortFile.ID].DataPrefix) + require.Equal(t, owner.ID, prefixes[longFile.ID].OwnerID) + require.Equal(t, org.ID, prefixes[longFile.ID].OrganizationID) +} + func TestInsertWorkspaceAgentLogs(t *testing.T) { t.Parallel() if testing.Short() { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 83f4fe96a0a..d8945e16c51 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4989,6 +4989,57 @@ func (q *sqlQuerier) GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFil return i, err } +const getChatFileDataPrefixesByIDs = `-- name: GetChatFileDataPrefixesByIDs :many +SELECT id, owner_id, organization_id, substr(data, 1, $1::int) AS data_prefix +FROM chat_files +WHERE id = ANY($2::uuid[]) +` + +type GetChatFileDataPrefixesByIDsParams struct { + PrefixBytes int32 `db:"prefix_bytes" json:"prefix_bytes"` + IDs []uuid.UUID `db:"ids" json:"ids"` +} + +type GetChatFileDataPrefixesByIDsRow struct { + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + DataPrefix []byte `db:"data_prefix" json:"data_prefix"` +} + +// GetChatFileDataPrefixesByIDs returns a bounded prefix of each +// file's content. Title derivation needs only the beginning of each +// pasted-text attachment, so substr caps database I/O and server +// memory regardless of stored blob size. Owner and organization +// columns support row-level authorization. +func (q *sqlQuerier) GetChatFileDataPrefixesByIDs(ctx context.Context, arg GetChatFileDataPrefixesByIDsParams) ([]GetChatFileDataPrefixesByIDsRow, error) { + rows, err := q.db.QueryContext(ctx, getChatFileDataPrefixesByIDs, arg.PrefixBytes, pq.Array(arg.IDs)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetChatFileDataPrefixesByIDsRow + for rows.Next() { + var i GetChatFileDataPrefixesByIDsRow + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.OrganizationID, + &i.DataPrefix, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getChatFileMetadataByChatID = `-- name: GetChatFileMetadataByChatID :many SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at FROM chat_files cf diff --git a/coderd/database/queries/chatfiles.sql b/coderd/database/queries/chatfiles.sql index 7ebf8713fc8..5b8a52922b2 100644 --- a/coderd/database/queries/chatfiles.sql +++ b/coderd/database/queries/chatfiles.sql @@ -9,6 +9,16 @@ SELECT * FROM chat_files WHERE id = @id::uuid; -- name: GetChatFilesByIDs :many SELECT * FROM chat_files WHERE id = ANY(@ids::uuid[]); +-- name: GetChatFileDataPrefixesByIDs :many +-- GetChatFileDataPrefixesByIDs returns a bounded prefix of each +-- file's content. Title derivation needs only the beginning of each +-- pasted-text attachment, so substr caps database I/O and server +-- memory regardless of stored blob size. Owner and organization +-- columns support row-level authorization. +SELECT id, owner_id, organization_id, substr(data, 1, @prefix_bytes::int) AS data_prefix +FROM chat_files +WHERE id = ANY(@ids::uuid[]); + -- name: GetChatFileMetadataByChatID :many -- GetChatFileMetadataByChatID returns lightweight file metadata for -- all files linked to a chat. The data column is excluded to avoid diff --git a/coderd/x/chatd/chatprompt/export_test.go b/coderd/x/chatd/chatprompt/export_test.go index 9eb431eb6a6..16b3090c2b5 100644 --- a/coderd/x/chatd/chatprompt/export_test.go +++ b/coderd/x/chatd/chatprompt/export_test.go @@ -11,10 +11,6 @@ import ( // for external tests. const SyntheticPasteTitleBudgetForTest = syntheticPasteTitleBudget -// TitlePasteBytePrefixForTest exposes titlePasteBytePrefix for -// external tests. -const TitlePasteBytePrefixForTest = titlePasteBytePrefix - // ToolResultPartToMessagePartForTest exposes toolResultPartToMessagePart // for external tests. var ToolResultPartToMessagePartForTest = toolResultPartToMessagePart diff --git a/coderd/x/chatd/chatprompt/title.go b/coderd/x/chatd/chatprompt/title.go index 49533e3e71c..e1b93aebabc 100644 --- a/coderd/x/chatd/chatprompt/title.go +++ b/coderd/x/chatd/chatprompt/title.go @@ -15,19 +15,21 @@ import ( // the model prompt. const syntheticPasteTitleBudget = 16 * 1024 -// titlePasteBytePrefix caps, in bytes, how much of a pasted-text blob -// is copied to a string for title derivation. Four bytes per rune (the -// UTF-8 maximum) guarantees the prefix still spans at least +// TitlePasteBytePrefix caps, in bytes, how much of a pasted-text blob +// feeds title derivation. Four bytes per rune (the UTF-8 maximum) +// guarantees the prefix still spans at least // syntheticPasteTitleBudget complete runes, so TitleText's rune // truncation yields the same result it would on the full content. -const titlePasteBytePrefix = 4 * syntheticPasteTitleBudget +// Database callers pass it to GetChatFileDataPrefixesByIDs so blobs +// are bounded before they leave the database. +const TitlePasteBytePrefix = 4 * syntheticPasteTitleBudget -// TitlePasteText converts a pasted-text blob to TitleText input, -// copying at most titlePasteBytePrefix bytes instead of the whole +// TitlePasteText converts pasted-text content to TitleText input, +// copying at most TitlePasteBytePrefix bytes instead of the whole // blob. 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)]) + return string(data[:min(len(data), TitlePasteBytePrefix)]) } // TitleText derives title-generation input from message parts. Text diff --git a/coderd/x/chatd/chatprompt/title_test.go b/coderd/x/chatd/chatprompt/title_test.go index 7d0cc930817..cb2e023be96 100644 --- a/coderd/x/chatd/chatprompt/title_test.go +++ b/coderd/x/chatd/chatprompt/title_test.go @@ -146,8 +146,8 @@ func TestTitlePasteText(t *testing.T) { t.Run("LongDataBounded", func(t *testing.T) { t.Parallel() - data := bytes.Repeat([]byte("a"), chatprompt.TitlePasteBytePrefixForTest+4096) - require.Len(t, chatprompt.TitlePasteText(data), chatprompt.TitlePasteBytePrefixForTest) + data := bytes.Repeat([]byte("a"), chatprompt.TitlePasteBytePrefix+4096) + require.Len(t, chatprompt.TitlePasteText(data), chatprompt.TitlePasteBytePrefix) }) t.Run("MatchesFullContentDerivation", func(t *testing.T) { @@ -158,9 +158,9 @@ func TestTitlePasteText(t *testing.T) { 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 + // (TitlePasteBytePrefix % 3 != 0); TitleText's rune truncation // must still produce the same result as the full content. - content := strings.Repeat("€", chatprompt.TitlePasteBytePrefixForTest/3+16) + content := strings.Repeat("€", chatprompt.TitlePasteBytePrefix/3+16) bounded := chatprompt.TitleText(parts, map[uuid.UUID]string{ pasteFileID: chatprompt.TitlePasteText([]byte(content)), }) diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 416f7df7048..5e397218188 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -616,9 +616,10 @@ func titleInput( // titlePasteText resolves synthetic pasted-text attachment content for // visible user messages whose text and file-reference parts alone // yield no title input. The result maps file IDs to bounded content -// prefixes (see chatprompt.TitlePasteText) for chatprompt.TitleText. -// It returns nil without touching the database when every user message -// already has text, so typical chats never incur a file fetch. +// prefixes (see chatprompt.TitlePasteBytePrefix) for +// chatprompt.TitleText. It returns nil without touching the database +// when every user message already has text, so typical chats never +// incur a file fetch. func titlePasteText( ctx context.Context, store database.Store, @@ -645,13 +646,19 @@ func titlePasteText( return nil, nil //nolint:nilnil // Nil map cleanly signals no paste content to resolve. } - files, err := store.GetChatFilesByIDs(ctx, ids) + // The prefix fetch bounds blob transfer in SQL: full pasted-text + // attachments can reach the upload cap, and only a small prefix + // feeds title derivation. + rows, err := store.GetChatFileDataPrefixesByIDs(ctx, database.GetChatFileDataPrefixesByIDsParams{ + IDs: ids, + PrefixBytes: chatprompt.TitlePasteBytePrefix, + }) if err != nil { - return nil, xerrors.Errorf("get pasted-text chat files: %w", err) + return nil, xerrors.Errorf("get pasted-text chat file prefixes: %w", err) } - pasteText := make(map[uuid.UUID]string, len(files)) - for _, file := range files { - pasteText[file.ID] = chatprompt.TitlePasteText(file.Data) + pasteText := make(map[uuid.UUID]string, len(rows)) + for _, row := range rows { + pasteText[row.ID] = chatprompt.TitlePasteText(row.DataPrefix) } return pasteText, nil } diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index 8b1f954787f..70e72a15b82 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -466,7 +466,8 @@ func Test_titlePasteText(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - // No GetChatFilesByIDs expectation: a fetch would fail the test. + // No GetChatFileDataPrefixesByIDs expectation: a fetch would + // fail the test. db := dbmock.NewMockStore(ctrl) pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{ @@ -484,8 +485,11 @@ func Test_titlePasteText(t *testing.T) { ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - db.EXPECT().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{pasteFileID}).Return([]database.ChatFile{ - {ID: pasteFileID, Data: []byte("pasted content")}, + 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}) @@ -498,7 +502,7 @@ func Test_titlePasteText(t *testing.T) { ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - db.EXPECT().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{pasteFileID}).Return(nil, sql.ErrConnDone) + 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) From 9cdfb99acfde821aba1de3814f5e7f7466fe4759 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:18:52 +0000 Subject: [PATCH 7/7] refactor(coderd): tighten paste-title comments --- coderd/database/querier.go | 6 ++---- coderd/database/queries.sql.go | 6 ++---- coderd/database/queries/chatfiles.sql | 6 ++---- coderd/exp_chats.go | 22 ++++++++-------------- coderd/x/chatd/chatprompt/title.go | 18 +++++++----------- coderd/x/chatd/quickgen.go | 13 ++++--------- 6 files changed, 25 insertions(+), 46 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 52b830b0d9d..fb5bda9fc40 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -418,10 +418,8 @@ type sqlcQuerier interface { GetChatFamilyIDsByRootID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error) // GetChatFileDataPrefixesByIDs returns a bounded prefix of each - // file's content. Title derivation needs only the beginning of each - // pasted-text attachment, so substr caps database I/O and server - // memory regardless of stored blob size. Owner and organization - // columns support row-level authorization. + // file's content, keeping full blobs out of server memory. Owner and + // organization columns support row-level authorization. GetChatFileDataPrefixesByIDs(ctx context.Context, arg GetChatFileDataPrefixesByIDsParams) ([]GetChatFileDataPrefixesByIDsRow, error) // GetChatFileMetadataByChatID returns lightweight file metadata for // all files linked to a chat. The data column is excluded to avoid diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d8945e16c51..cc2350e91e4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5008,10 +5008,8 @@ type GetChatFileDataPrefixesByIDsRow struct { } // GetChatFileDataPrefixesByIDs returns a bounded prefix of each -// file's content. Title derivation needs only the beginning of each -// pasted-text attachment, so substr caps database I/O and server -// memory regardless of stored blob size. Owner and organization -// columns support row-level authorization. +// file's content, keeping full blobs out of server memory. Owner and +// organization columns support row-level authorization. func (q *sqlQuerier) GetChatFileDataPrefixesByIDs(ctx context.Context, arg GetChatFileDataPrefixesByIDsParams) ([]GetChatFileDataPrefixesByIDsRow, error) { rows, err := q.db.QueryContext(ctx, getChatFileDataPrefixesByIDs, arg.PrefixBytes, pq.Array(arg.IDs)) if err != nil { diff --git a/coderd/database/queries/chatfiles.sql b/coderd/database/queries/chatfiles.sql index 5b8a52922b2..e51c08fc214 100644 --- a/coderd/database/queries/chatfiles.sql +++ b/coderd/database/queries/chatfiles.sql @@ -11,10 +11,8 @@ SELECT * FROM chat_files WHERE id = ANY(@ids::uuid[]); -- name: GetChatFileDataPrefixesByIDs :many -- GetChatFileDataPrefixesByIDs returns a bounded prefix of each --- file's content. Title derivation needs only the beginning of each --- pasted-text attachment, so substr caps database I/O and server --- memory regardless of stored blob size. Owner and organization --- columns support row-level authorization. +-- file's content, keeping full blobs out of server memory. Owner and +-- organization columns support row-level authorization. SELECT id, owner_id, organization_id, substr(data, 1, @prefix_bytes::int) AS data_prefix FROM chat_files WHERE id = ANY(@ids::uuid[]); diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index bd70c38d8bc..1c35f334573 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6332,12 +6332,9 @@ func createChatInputFromRequest(ctx context.Context, db database.Store, req code if inputError != nil { return nil, "", nil, inputError } - // The shared derivation keeps this create-time titleSource - // identical to the extraction used by title generation, which - // gates auto-titling on that equality (see chatprompt.TitleText). - // Paste blobs are materialized as strings only when text and - // file-reference parts yield nothing, so mixed messages never - // copy attachment data they will not use. + // Derive titleSource through the same chatprompt.TitleText used at + // generation time; auto-titling gates on that equality. Paste blobs + // are copied only when text and file-reference parts yield nothing. titleSource := chatprompt.TitleText(content, nil) if titleSource == "" && len(pasteData) > 0 { pasteText := make(map[uuid.UUID]string, len(pasteData)) @@ -6350,10 +6347,9 @@ func createChatInputFromRequest(ctx context.Context, db database.Store, req code } // createChatInputFromParts validates input parts and converts them to -// message content. The returned map holds raw pasted-text blobs keyed -// by file ID; only the create path derives a title from it (see -// createChatInputFromRequest), message send and edit callers discard -// it without copying any blob data. +// message content. The returned map holds pasted-text blob references +// by file ID; the create path derives a title from it, message send +// and edit discard it without copying blob data. func createChatInputFromParts( ctx context.Context, db database.Store, @@ -6413,10 +6409,8 @@ func createChatInputFromParts( } content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype, chatFile.Name)) fileIDs = append(fileIDs, part.FileID) - // Pasted-text attachments feed create-time title derivation - // when the message has no other title text. Only the blob - // reference is retained here; blobs are never copied on the - // message send and edit paths, which discard this map. + // Retain blob references for create-time title derivation; + // send and edit paths discard the map. if chatprompt.IsSyntheticPaste(chatFile.Name, chatFile.Mimetype) { if pasteData == nil { pasteData = make(map[uuid.UUID][]byte) diff --git a/coderd/x/chatd/chatprompt/title.go b/coderd/x/chatd/chatprompt/title.go index e1b93aebabc..0a5702d18c3 100644 --- a/coderd/x/chatd/chatprompt/title.go +++ b/coderd/x/chatd/chatprompt/title.go @@ -16,18 +16,15 @@ import ( 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) -// guarantees the prefix still spans at least -// syntheticPasteTitleBudget complete runes, so TitleText's rune -// truncation yields the same result it would on the full content. -// Database callers pass it to GetChatFileDataPrefixesByIDs so blobs -// are bounded before they leave the database. +// 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 instead of the whole -// blob. Every caller that builds a pasteText map must use it so all -// derivation paths feed TitleText identical strings. +// 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)]) } @@ -36,8 +33,7 @@ func TitlePasteText(data []byte) string { // 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. Map values must come from -// TitlePasteText. +// syntheticPasteTitleBudget runes per file. // // The chat-creation fallback title and both title-generation paths // must derive their input through this function: auto-titling only diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 5e397218188..e80a282aca2 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -613,13 +613,11 @@ func titleInput( return firstUserText, true } -// titlePasteText resolves synthetic pasted-text attachment content for -// visible user messages whose text and file-reference parts alone -// yield no title input. The result maps file IDs to bounded content -// prefixes (see chatprompt.TitlePasteBytePrefix) for +// 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, so typical chats never -// incur a file fetch. +// when every user message already has text. func titlePasteText( ctx context.Context, store database.Store, @@ -646,9 +644,6 @@ func titlePasteText( return nil, nil //nolint:nilnil // Nil map cleanly signals no paste content to resolve. } - // The prefix fetch bounds blob transfer in SQL: full pasted-text - // attachments can reach the upload cap, and only a small prefix - // feeds title derivation. rows, err := store.GetChatFileDataPrefixesByIDs(ctx, database.GetChatFileDataPrefixesByIDsParams{ IDs: ids, PrefixBytes: chatprompt.TitlePasteBytePrefix,