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..fb5bda9fc40 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -417,6 +417,10 @@ 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, 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 // 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..cc2350e91e4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4989,6 +4989,55 @@ 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, 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 { + 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..e51c08fc214 100644 --- a/coderd/database/queries/chatfiles.sql +++ b/coderd/database/queries/chatfiles.sql @@ -9,6 +9,14 @@ 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, 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[]); + -- 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/exp_chats.go b/coderd/exp_chats.go index 83c95c15787..1c35f334573 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 { @@ -6327,17 +6328,36 @@ 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 + } + // 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)) + for id, data := range pasteData { + pasteText[id] = chatprompt.TitlePasteText(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 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, 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.", } @@ -6345,73 +6365,70 @@ func createChatInputFromParts( var fileIDs []uuid.UUID content := make([]codersdk.ChatMessagePart, 0, len(parts)) - textParts := make([]string, 0, len(parts)) + var pasteData map[uuid.UUID][]byte for i, part := range parts { switch strings.ToLower(strings.TrimSpace(string(part.Type))) { 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), } } content = append(content, codersdk.ChatMessageText(text)) - textParts = append(textParts, 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), } } // 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) { - 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) + // 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) + } + 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. 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)) - // 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{ + return nil, nil, nil, &codersdk.Response{ Message: "Invalid input part.", Detail: fmt.Sprintf( "%s[%d].type %q is not supported.", @@ -6423,48 +6440,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), } } - titleSource := strings.TrimSpace(strings.Join(textParts, " ")) - 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]) + return content, pasteData, fileIDs, nil } // linkFilesToChat inserts file-link rows into the chat_file_links 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..0a5702d18c3 --- /dev/null +++ b/coderd/x/chatd/chatprompt/title.go @@ -0,0 +1,115 @@ +package chatprompt + +import ( + "strings" + + "github.com/google/uuid" + + stringutil "github.com/coder/coder/v2/coderd/util/strings" + "github.com/coder/coder/v2/codersdk" +) + +// syntheticPasteTitleBudget caps, in runes, how much of a pasted-text +// attachment feeds title generation. It is far smaller than +// syntheticPasteInlineBudget because it only seeds a short title, not +// the model prompt. +const syntheticPasteTitleBudget = 16 * 1024 + +// TitlePasteBytePrefix caps, in bytes, how much of a pasted-text blob +// feeds title derivation: four bytes per rune (the UTF-8 maximum) +// covers syntheticPasteTitleBudget runes. Database callers pass it to +// GetChatFileDataPrefixesByIDs to bound the fetch itself. +const TitlePasteBytePrefix = 4 * syntheticPasteTitleBudget + +// TitlePasteText converts pasted-text content to TitleText input, +// copying at most TitlePasteBytePrefix bytes. Every caller that +// builds a pasteText map must use it so all derivation paths feed +// TitleText identical strings. +func TitlePasteText(data []byte) string { + return string(data[:min(len(data), TitlePasteBytePrefix)]) +} + +// TitleText derives title-generation input from message parts. Text +// and file-reference parts are joined in part order. When they yield +// nothing, the content of synthetic pasted-text attachments is used +// instead, looked up in pasteText by file ID and truncated to +// syntheticPasteTitleBudget runes per file. +// +// The chat-creation fallback title and both title-generation paths +// must derive their input through this function: auto-titling only +// proceeds when the current title equals FallbackTitle of this exact +// string, so a drift between derivations silently disables it. +func TitleText(parts []codersdk.ChatMessagePart, pasteText map[uuid.UUID]string) string { + texts := make([]string, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case codersdk.ChatMessagePartTypeText: + text := strings.TrimSpace(part.Text) + if text == "" { + continue + } + texts = append(texts, text) + case codersdk.ChatMessagePartTypeFileReference: + texts = append(texts, fileReferencePartToText(part)) + } + } + if joined := strings.TrimSpace(strings.Join(texts, " ")); joined != "" { + return joined + } + + pastes := make([]string, 0, len(pasteText)) + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeFile || !part.FileID.Valid { + continue + } + content := strings.TrimSpace(pasteText[part.FileID.UUID]) + if content == "" { + continue + } + pastes = append(pastes, stringutil.Truncate(content, syntheticPasteTitleBudget)) + } + return strings.TrimSpace(strings.Join(pastes, "\n\n")) +} + +// SyntheticPasteFileIDs returns the file IDs of file parts that are +// synthetic pasted-text attachments created by the chat UI. Callers +// resolve these to file content and pass the result to TitleText. +func SyntheticPasteFileIDs(parts []codersdk.ChatMessagePart) []uuid.UUID { + var ids []uuid.UUID + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeFile || !part.FileID.Valid { + continue + } + if !IsSyntheticPaste(part.Name, part.MediaType) { + continue + } + ids = append(ids, part.FileID.UUID) + } + return ids +} + +// FallbackTitle derives a deterministic chat title from title text: +// the first six words, ellipsized when truncated, capped at 80 runes. +// Empty input yields "New Chat". +func FallbackTitle(message string) string { + const maxWords = 6 + const maxRunes = 80 + + words := strings.Fields(message) + if len(words) == 0 { + return "New Chat" + } + + truncated := false + if len(words) > maxWords { + words = words[:maxWords] + truncated = true + } + + title := strings.Join(words, " ") + if truncated { + return stringutil.Truncate(title, maxRunes-1) + "…" + } + + return stringutil.Truncate(title, maxRunes) +} diff --git a/coderd/x/chatd/chatprompt/title_test.go b/coderd/x/chatd/chatprompt/title_test.go new file mode 100644 index 00000000000..cb2e023be96 --- /dev/null +++ b/coderd/x/chatd/chatprompt/title_test.go @@ -0,0 +1,278 @@ +package chatprompt_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/codersdk" +) + +func TestTitleText(t *testing.T) { + t.Parallel() + + pasteFileID := uuid.New() + otherPasteFileID := uuid.New() + syntheticPasteFile := func(id uuid.UUID) codersdk.ChatMessagePart { + return codersdk.ChatMessageFile(id, "text/plain", "pasted-text-2026-01-02-03-04-05.txt") + } + + tests := []struct { + name string + parts []codersdk.ChatMessagePart + pasteText map[uuid.UUID]string + want string + }{ + { + name: "joins trimmed text parts", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText(" fix the flaky test "), + codersdk.ChatMessageReasoning("skip me"), + codersdk.ChatMessageText(" in coderd "), + }, + want: "fix the flaky test in coderd", + }, + { + name: "formats file reference with line range and content fence", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageFileReference("main.go", 3, 7, "fmt.Println(\"hi\")\n"), + }, + want: "[file-reference] main.go:3-7\n```main.go\nfmt.Println(\"hi\")\n```", + }, + { + name: "formats single line file reference without content", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageFileReference("main.go", 3, 3, " "), + }, + want: "[file-reference] main.go:3", + }, + { + name: "joins text and file reference parts in order", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("explain this"), + codersdk.ChatMessageFileReference("app.ts", 1, 1, ""), + }, + want: "explain this [file-reference] app.ts:1", + }, + { + name: "falls back to paste content for file only messages", + parts: []codersdk.ChatMessagePart{ + syntheticPasteFile(pasteFileID), + }, + pasteText: map[uuid.UUID]string{pasteFileID: " pasted panic log\nsecond line "}, + want: "pasted panic log\nsecond line", + }, + { + name: "text wins over paste content", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("typed context"), + syntheticPasteFile(pasteFileID), + }, + pasteText: map[uuid.UUID]string{pasteFileID: "pasted content"}, + want: "typed context", + }, + { + name: "joins multiple pastes in part order", + parts: []codersdk.ChatMessagePart{ + syntheticPasteFile(pasteFileID), + syntheticPasteFile(otherPasteFileID), + }, + pasteText: map[uuid.UUID]string{ + pasteFileID: "first paste", + otherPasteFileID: "second paste", + }, + want: "first paste\n\nsecond paste", + }, + { + name: "ignores file parts without resolved paste content", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageFile(uuid.New(), "image/png", "photo.png"), + }, + pasteText: map[uuid.UUID]string{pasteFileID: "unrelated"}, + want: "", + }, + { + name: "ignores whitespace only paste content", + parts: []codersdk.ChatMessagePart{ + syntheticPasteFile(pasteFileID), + }, + pasteText: map[uuid.UUID]string{pasteFileID: " \n\t "}, + want: "", + }, + { + name: "empty parts yield empty text", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, chatprompt.TitleText(tt.parts, tt.pasteText)) + }) + } +} + +func TestTitleText_TruncatesPasteContentRuneSafe(t *testing.T) { + t.Parallel() + + pasteFileID := uuid.New() + parts := []codersdk.ChatMessagePart{ + codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"), + } + // Multi-byte runes ensure truncation cannot split a UTF-8 sequence. + content := strings.Repeat("é", chatprompt.SyntheticPasteTitleBudgetForTest+10) + + got := chatprompt.TitleText(parts, map[uuid.UUID]string{pasteFileID: content}) + + require.Len(t, []rune(got), chatprompt.SyntheticPasteTitleBudgetForTest) + require.True(t, strings.HasPrefix(content, got)) +} + +func TestTitlePasteText(t *testing.T) { + t.Parallel() + + t.Run("ShortDataCopiedWhole", func(t *testing.T) { + t.Parallel() + + require.Equal(t, "hello paste", chatprompt.TitlePasteText([]byte("hello paste"))) + }) + + t.Run("LongDataBounded", func(t *testing.T) { + t.Parallel() + + data := bytes.Repeat([]byte("a"), chatprompt.TitlePasteBytePrefix+4096) + require.Len(t, chatprompt.TitlePasteText(data), chatprompt.TitlePasteBytePrefix) + }) + + t.Run("MatchesFullContentDerivation", func(t *testing.T) { + t.Parallel() + + pasteFileID := uuid.New() + parts := []codersdk.ChatMessagePart{ + codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"), + } + // Three-byte runes make the byte-prefix cut land mid-rune + // (TitlePasteBytePrefix % 3 != 0); TitleText's rune truncation + // must still produce the same result as the full content. + content := strings.Repeat("€", chatprompt.TitlePasteBytePrefix/3+16) + bounded := chatprompt.TitleText(parts, map[uuid.UUID]string{ + pasteFileID: chatprompt.TitlePasteText([]byte(content)), + }) + full := chatprompt.TitleText(parts, map[uuid.UUID]string{ + pasteFileID: content, + }) + + require.Equal(t, full, bounded) + require.Len(t, []rune(bounded), chatprompt.SyntheticPasteTitleBudgetForTest) + }) +} + +func TestSyntheticPasteFileIDs(t *testing.T) { + t.Parallel() + + pasteFileID := uuid.New() + otherPasteFileID := uuid.New() + + noIDPart := codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeFile, + MediaType: "text/plain", + Name: "pasted-text-2026-01-02-03-04-05.txt", + } + + tests := []struct { + name string + parts []codersdk.ChatMessagePart + want []uuid.UUID + }{ + { + name: "collects synthetic paste file ids", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("hello"), + codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"), + codersdk.ChatMessageFile(otherPasteFileID, "text/plain; charset=utf-8", "pasted-text-2026-12-31-23-59-59.txt"), + }, + want: []uuid.UUID{pasteFileID, otherPasteFileID}, + }, + { + name: "skips files without the synthetic name pattern", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageFile(uuid.New(), "text/plain", "notes.txt"), + }, + want: nil, + }, + { + name: "skips files with non text media types", + parts: []codersdk.ChatMessagePart{ + codersdk.ChatMessageFile(uuid.New(), "image/png", "pasted-text-2026-01-02-03-04-05.txt"), + }, + want: nil, + }, + { + name: "skips file parts without a file id", + parts: []codersdk.ChatMessagePart{noIDPart}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, chatprompt.SyntheticPasteFileIDs(tt.parts)) + }) + } +} + +func TestFallbackTitle(t *testing.T) { + t.Parallel() + + longWord := strings.Repeat("x", 30) + + tests := []struct { + name string + message string + want string + }{ + { + name: "empty message yields default title", + message: " \n ", + want: "New Chat", + }, + { + name: "short message is kept verbatim", + message: "fix the flaky test", + want: "fix the flaky test", + }, + { + name: "collapses whitespace between words", + message: "fix\nthe\tflaky test", + want: "fix the flaky test", + }, + { + name: "truncates to six words with ellipsis", + message: "one two three four five six seven", + want: "one two three four five six…", + }, + { + name: "caps six long words at eighty runes keeping the ellipsis", + message: strings.Repeat(longWord+" ", 7), + want: strings.Repeat("x", 30) + " " + strings.Repeat("x", 30) + " " + strings.Repeat("x", 17) + "…", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := chatprompt.FallbackTitle(tt.message) + require.Equal(t, tt.want, got) + require.LessOrEqual(t, len([]rune(got)), 80) + }) + } +} diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 91f37a87463..e80a282aca2 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,64 @@ 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 yield +// no title input, fetching only bounded prefixes for +// chatprompt.TitleText. It returns nil without touching the database +// when every user message already has text. +func titlePasteText( + ctx context.Context, + store database.Store, + messages []database.ChatMessage, +) (map[uuid.UUID]string, error) { + var ids []uuid.UUID + for _, message := range messages { + if message.Visibility == database.ChatMessageVisibilityModel { + continue + } + if message.Role != database.ChatMessageRoleUser { + continue + } + parsed, err := chatprompt.ParseContent(message) + if err != nil { + continue + } + if chatprompt.TitleText(parsed, nil) != "" { + continue + } + ids = append(ids, chatprompt.SyntheticPasteFileIDs(parsed)...) } - 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 + rows, err := store.GetChatFileDataPrefixesByIDs(ctx, database.GetChatFileDataPrefixesByIDsParams{ + IDs: ids, + PrefixBytes: chatprompt.TitlePasteBytePrefix, + }) + if err != nil { + return nil, xerrors.Errorf("get pasted-text chat file prefixes: %w", err) } - - title := strings.Join(words, " ") - if truncated { - return truncateRunes(title, maxRunes-1) + "…" + pasteText := make(map[uuid.UUID]string, len(rows)) + for _, row := range rows { + pasteText[row.ID] = chatprompt.TitlePasteText(row.DataPrefix) } + 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 +703,14 @@ type manualTitleTurn struct { text string } -func extractManualTitleTurns(messages []database.ChatMessage) []manualTitleTurn { +// extractManualTitleTurns flattens visible user and assistant +// messages into title turns. pasteText carries resolved pasted-text +// attachment content (see titlePasteText) so paste-only user messages +// still produce turns. +func extractManualTitleTurns( + messages []database.ChatMessage, + pasteText map[uuid.UUID]string, +) []manualTitleTurn { turns := make([]manualTitleTurn, 0, len(messages)) for _, message := range messages { if message.Visibility == database.ChatMessageVisibilityModel { @@ -692,7 +732,7 @@ func extractManualTitleTurns(messages []database.ChatMessage) []manualTitleTurn continue } - text := strings.TrimSpace(contentBlocksToText(parts)) + text := chatprompt.TitleText(parts, pasteText) if text == "" { continue } @@ -802,9 +842,10 @@ func renderManualTitlePrompt( func generateManualTitle( ctx context.Context, messages []database.ChatMessage, + pasteText map[uuid.UUID]string, fallbackModel fantasy.LanguageModel, ) (string, fantasy.Usage, error) { - turns := extractManualTitleTurns(messages) + turns := extractManualTitleTurns(messages, pasteText) selected := selectManualTitleTurnIndexes(turns) firstUserIndex := slices.IndexFunc(turns, func(turn manualTitleTurn) bool { diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index f2cfef1a245..70e72a15b82 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,149 @@ func Test_renderManualTitlePrompt(t *testing.T) { } } +func Test_titleInput(t *testing.T) { + t.Parallel() + + pasteFileID := uuid.New() + pasteContent := "pasted stack trace with details" + pasteMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, + codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"), + ) + textMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, + codersdk.ChatMessageText("summarize build logs"), + ) + + tests := []struct { + name string + chat database.Chat + messages []database.ChatMessage + pasteText map[uuid.UUID]string + wantInput string + wantOK bool + }{ + { + name: "text message with fallback title is eligible", + chat: database.Chat{Title: chatprompt.FallbackTitle("summarize build logs")}, + messages: []database.ChatMessage{textMessage}, + wantInput: "summarize build logs", + wantOK: true, + }, + { + name: "paste only message with resolved paste text is eligible", + chat: database.Chat{Title: chatprompt.FallbackTitle(pasteContent)}, + messages: []database.ChatMessage{pasteMessage}, + pasteText: map[uuid.UUID]string{pasteFileID: pasteContent}, + wantInput: pasteContent, + wantOK: true, + }, + { + name: "paste only message without resolved paste text is skipped", + chat: database.Chat{Title: "New Chat"}, + messages: []database.ChatMessage{pasteMessage}, + wantOK: false, + }, + { + name: "paste only message with user renamed title is skipped", + chat: database.Chat{Title: "my custom name"}, + messages: []database.ChatMessage{pasteMessage}, + pasteText: map[uuid.UUID]string{pasteFileID: pasteContent}, + wantOK: false, + }, + { + name: "assistant reply disables generation", + chat: database.Chat{Title: chatprompt.FallbackTitle(pasteContent)}, + messages: []database.ChatMessage{ + pasteMessage, + mustChatMessage(t, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, + codersdk.ChatMessageText("done"), + ), + }, + pasteText: map[uuid.UUID]string{pasteFileID: pasteContent}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + input, ok := titleInput(tt.chat, tt.messages, tt.pasteText) + require.Equal(t, tt.wantOK, ok) + require.Equal(t, tt.wantInput, input) + }) + } +} + +func Test_titlePasteText(t *testing.T) { + t.Parallel() + + pasteFileID := uuid.New() + pasteMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, + codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"), + ) + + t.Run("skips fetch when user messages have text", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + // No GetChatFileDataPrefixesByIDs expectation: a fetch would + // fail the test. + db := dbmock.NewMockStore(ctrl) + + pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{ + mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, + codersdk.ChatMessageText("typed text"), + codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"), + ), + }) + require.NoError(t, err) + require.Nil(t, pasteText) + }) + + t.Run("resolves paste content for paste only user messages", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + db.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), database.GetChatFileDataPrefixesByIDsParams{ + IDs: []uuid.UUID{pasteFileID}, + PrefixBytes: chatprompt.TitlePasteBytePrefix, + }).Return([]database.GetChatFileDataPrefixesByIDsRow{ + {ID: pasteFileID, DataPrefix: []byte("pasted content")}, + }, nil) + + pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{pasteMessage}) + require.NoError(t, err) + require.Equal(t, map[uuid.UUID]string{pasteFileID: "pasted content"}, pasteText) + }) + + t.Run("propagates fetch errors", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + db.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), gomock.Any()).Return(nil, sql.ErrConnDone) + + _, err := titlePasteText(context.Background(), db, []database.ChatMessage{pasteMessage}) + require.ErrorIs(t, err, sql.ErrConnDone) + }) + + t.Run("ignores non synthetic file only messages", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{ + mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, + codersdk.ChatMessageFile(uuid.New(), "image/png", "photo.png"), + ), + }) + require.NoError(t, err) + require.Nil(t, pasteText) + }) +} + func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) { t.Parallel() @@ -390,7 +551,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) { OrganizationID: org.ID, OwnerID: owner.ID, LastModelConfigID: modelConfig.ID, - Title: fallbackChatTitle(userPrompt), + Title: chatprompt.FallbackTitle(userPrompt), Status: database.ChatStatusWaiting, ClientType: database.ChatClientTypeUi, }) @@ -422,6 +583,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) { ctx, chat, []database.ChatMessage{message}, + nil, "openai", "test-model", model, @@ -488,6 +650,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) { title, _, err := generateManualTitle( context.Background(), messages, + nil, model, ) require.NoError(t, err) @@ -524,6 +687,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) { _, _, err := generateManualTitle( context.Background(), messages, + nil, model, ) require.NoError(t, err) @@ -557,6 +721,7 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T) _, usage, err := generateManualTitle( context.Background(), messages, + nil, model, ) require.ErrorContains(t, err, "generated title was empty") 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,