mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd): use pasted-text attachments as chat title input (#27067)
Closes https://linear.app/codercom/issue/CODAGT-268 ## Problem The chat UI collapses large pastes (>=10 lines or >=1000 chars) into a synthetic `pasted-text-*.txt` attachment. A chat created with only such an attachment had no title input anywhere: the create path derived `titleSource` only from text and file-reference parts (so the chat was named "New Chat"), async auto-titling extracted text the same way and silently skipped generation, and the manual propose/regenerate paths returned an empty title for the same reason. The regular prompt path already inlines these files for the model; only the title paths were blind. ## Fix Add a single title-input derivation in `chatprompt` and use it everywhere: - `chatprompt.TitleText` joins text and file-reference parts (unchanged formatting), and falls back to synthetic pasted-text attachment content (truncated to a 16 KiB title budget) when they yield nothing. - `chatprompt.SyntheticPasteFileIDs` identifies paste attachments; `chatprompt.FallbackTitle` consolidates the previously duplicated `chatTitleFromMessage` / `fallbackChatTitle`. - Chat creation captures paste blob references while validating file parts (the file row was already loaded there) and derives `titleSource` via `TitleText`. Only the create path derives titles; message send and edit reuse the same validation without copying any blob data. - `GenerateChatTitleAsync` and the manual propose/regenerate paths resolve paste content via `titlePasteText`, which only queries when a visible user message has no other title text, so chats with typed text never incur a file fetch. - Title-path paste fetches are bounded: a new `GetChatFileDataPrefixesByIDs` query returns only a `substr` prefix (`chatprompt.TitlePasteBytePrefix`, 64 KiB = 4 bytes x the 16 Ki-rune title budget) so full blobs (up to 10 MiB each) never leave the database for titling, and `chatprompt.TitlePasteText` applies the same bound to the create path which already holds the loaded row. Deliberate side effect: because generation-time extraction now matches create-time `titleSource` exactly, file-reference-only chats also become eligible for AI titles. They were previously skipped by the same derivation mismatch. Non-goals: no frontend changes (attachment chip UX stays as is), and non-synthetic user-uploaded `.txt` files still yield "New Chat". ## Testing - Unit tests for `TitleText`, `TitlePasteText`, `SyntheticPasteFileIDs`, `FallbackTitle`, `titleInput`, `titlePasteText`, and paste-aware `extractManualTitleTurns`. - Real-database test for `GetChatFileDataPrefixesByIDs` (prefix shorter and longer than stored data) plus dbauthz coverage for the new query. - Integration tests: paste-only create gets a fallback title from the paste content, async title generation fires with the paste content as input, and `RegenerateChatTitle` works on a paste-only chat. > This PR was written by [Mux](https://mux.coder.com) on Mike's behalf.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
+8
@@ -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)
|
||||
|
||||
Generated
+15
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+4
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Generated
+49
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+45
-63
@@ -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
|
||||
|
||||
+160
-1
@@ -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"}.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package chatprompt_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestTitleText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pasteFileID := uuid.New()
|
||||
otherPasteFileID := uuid.New()
|
||||
syntheticPasteFile := func(id uuid.UUID) codersdk.ChatMessagePart {
|
||||
return codersdk.ChatMessageFile(id, "text/plain", "pasted-text-2026-01-02-03-04-05.txt")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
parts []codersdk.ChatMessagePart
|
||||
pasteText map[uuid.UUID]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "joins trimmed text parts",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText(" fix the flaky test "),
|
||||
codersdk.ChatMessageReasoning("skip me"),
|
||||
codersdk.ChatMessageText(" in coderd "),
|
||||
},
|
||||
want: "fix the flaky test in coderd",
|
||||
},
|
||||
{
|
||||
name: "formats file reference with line range and content fence",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageFileReference("main.go", 3, 7, "fmt.Println(\"hi\")\n"),
|
||||
},
|
||||
want: "[file-reference] main.go:3-7\n```main.go\nfmt.Println(\"hi\")\n```",
|
||||
},
|
||||
{
|
||||
name: "formats single line file reference without content",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageFileReference("main.go", 3, 3, " "),
|
||||
},
|
||||
want: "[file-reference] main.go:3",
|
||||
},
|
||||
{
|
||||
name: "joins text and file reference parts in order",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("explain this"),
|
||||
codersdk.ChatMessageFileReference("app.ts", 1, 1, ""),
|
||||
},
|
||||
want: "explain this [file-reference] app.ts:1",
|
||||
},
|
||||
{
|
||||
name: "falls back to paste content for file only messages",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
syntheticPasteFile(pasteFileID),
|
||||
},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: " pasted panic log\nsecond line "},
|
||||
want: "pasted panic log\nsecond line",
|
||||
},
|
||||
{
|
||||
name: "text wins over paste content",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("typed context"),
|
||||
syntheticPasteFile(pasteFileID),
|
||||
},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: "pasted content"},
|
||||
want: "typed context",
|
||||
},
|
||||
{
|
||||
name: "joins multiple pastes in part order",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
syntheticPasteFile(pasteFileID),
|
||||
syntheticPasteFile(otherPasteFileID),
|
||||
},
|
||||
pasteText: map[uuid.UUID]string{
|
||||
pasteFileID: "first paste",
|
||||
otherPasteFileID: "second paste",
|
||||
},
|
||||
want: "first paste\n\nsecond paste",
|
||||
},
|
||||
{
|
||||
name: "ignores file parts without resolved paste content",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageFile(uuid.New(), "image/png", "photo.png"),
|
||||
},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: "unrelated"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "ignores whitespace only paste content",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
syntheticPasteFile(pasteFileID),
|
||||
},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: " \n\t "},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty parts yield empty text",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tt.want, chatprompt.TitleText(tt.parts, tt.pasteText))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleText_TruncatesPasteContentRuneSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pasteFileID := uuid.New()
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
}
|
||||
// Multi-byte runes ensure truncation cannot split a UTF-8 sequence.
|
||||
content := strings.Repeat("é", chatprompt.SyntheticPasteTitleBudgetForTest+10)
|
||||
|
||||
got := chatprompt.TitleText(parts, map[uuid.UUID]string{pasteFileID: content})
|
||||
|
||||
require.Len(t, []rune(got), chatprompt.SyntheticPasteTitleBudgetForTest)
|
||||
require.True(t, strings.HasPrefix(content, got))
|
||||
}
|
||||
|
||||
func TestTitlePasteText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ShortDataCopiedWhole", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, "hello paste", chatprompt.TitlePasteText([]byte("hello paste")))
|
||||
})
|
||||
|
||||
t.Run("LongDataBounded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := bytes.Repeat([]byte("a"), chatprompt.TitlePasteBytePrefix+4096)
|
||||
require.Len(t, chatprompt.TitlePasteText(data), chatprompt.TitlePasteBytePrefix)
|
||||
})
|
||||
|
||||
t.Run("MatchesFullContentDerivation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pasteFileID := uuid.New()
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
}
|
||||
// Three-byte runes make the byte-prefix cut land mid-rune
|
||||
// (TitlePasteBytePrefix % 3 != 0); TitleText's rune truncation
|
||||
// must still produce the same result as the full content.
|
||||
content := strings.Repeat("€", chatprompt.TitlePasteBytePrefix/3+16)
|
||||
bounded := chatprompt.TitleText(parts, map[uuid.UUID]string{
|
||||
pasteFileID: chatprompt.TitlePasteText([]byte(content)),
|
||||
})
|
||||
full := chatprompt.TitleText(parts, map[uuid.UUID]string{
|
||||
pasteFileID: content,
|
||||
})
|
||||
|
||||
require.Equal(t, full, bounded)
|
||||
require.Len(t, []rune(bounded), chatprompt.SyntheticPasteTitleBudgetForTest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyntheticPasteFileIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pasteFileID := uuid.New()
|
||||
otherPasteFileID := uuid.New()
|
||||
|
||||
noIDPart := codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeFile,
|
||||
MediaType: "text/plain",
|
||||
Name: "pasted-text-2026-01-02-03-04-05.txt",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
parts []codersdk.ChatMessagePart
|
||||
want []uuid.UUID
|
||||
}{
|
||||
{
|
||||
name: "collects synthetic paste file ids",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("hello"),
|
||||
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
codersdk.ChatMessageFile(otherPasteFileID, "text/plain; charset=utf-8", "pasted-text-2026-12-31-23-59-59.txt"),
|
||||
},
|
||||
want: []uuid.UUID{pasteFileID, otherPasteFileID},
|
||||
},
|
||||
{
|
||||
name: "skips files without the synthetic name pattern",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageFile(uuid.New(), "text/plain", "notes.txt"),
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "skips files with non text media types",
|
||||
parts: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageFile(uuid.New(), "image/png", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "skips file parts without a file id",
|
||||
parts: []codersdk.ChatMessagePart{noIDPart},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tt.want, chatprompt.SyntheticPasteFileIDs(tt.parts))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
longWord := strings.Repeat("x", 30)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty message yields default title",
|
||||
message: " \n ",
|
||||
want: "New Chat",
|
||||
},
|
||||
{
|
||||
name: "short message is kept verbatim",
|
||||
message: "fix the flaky test",
|
||||
want: "fix the flaky test",
|
||||
},
|
||||
{
|
||||
name: "collapses whitespace between words",
|
||||
message: "fix\nthe\tflaky test",
|
||||
want: "fix the flaky test",
|
||||
},
|
||||
{
|
||||
name: "truncates to six words with ellipsis",
|
||||
message: "one two three four five six seven",
|
||||
want: "one two three four five six…",
|
||||
},
|
||||
{
|
||||
name: "caps six long words at eighty runes keeping the ellipsis",
|
||||
message: strings.Repeat(longWord+" ", 7),
|
||||
want: strings.Repeat("x", 30) + " " + strings.Repeat("x", 30) + " " + strings.Repeat("x", 17) + "…",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := chatprompt.FallbackTitle(tt.message)
|
||||
require.Equal(t, tt.want, got)
|
||||
require.LessOrEqual(t, len([]rune(got)), 80)
|
||||
})
|
||||
}
|
||||
}
|
||||
+77
-36
@@ -17,6 +17,7 @@ import (
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
|
||||
fantasyvercel "charm.land/fantasy/providers/vercel"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
@@ -155,7 +156,14 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat)
|
||||
)
|
||||
return
|
||||
}
|
||||
if _, ok := titleInput(chat, messages); !ok {
|
||||
pasteText, err := titlePasteText(ctx, p.db, messages)
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "failed to load pasted-text attachments for automatic title generation",
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if _, ok := titleInput(chat, messages, pasteText); !ok {
|
||||
return
|
||||
}
|
||||
// Detach from request; bind to server so Close cancels it.
|
||||
@@ -175,6 +183,7 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat)
|
||||
turnCtx,
|
||||
chat,
|
||||
messages,
|
||||
pasteText,
|
||||
string(route.Provider.Type),
|
||||
modelConfig.Model,
|
||||
model,
|
||||
@@ -205,6 +214,7 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
messages []database.ChatMessage,
|
||||
pasteText map[uuid.UUID]string,
|
||||
fallbackProvider string,
|
||||
fallbackModelName string,
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
@@ -214,7 +224,7 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
logger slog.Logger,
|
||||
debugSvc *chatdebug.Service,
|
||||
) {
|
||||
input, ok := titleInput(chat, messages)
|
||||
input, ok := titleInput(chat, messages, pasteText)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -553,13 +563,16 @@ func validateGeneratedTitle(title string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// titleInput returns the first user message text and whether title
|
||||
// generation should proceed. It returns false when the chat already
|
||||
// has assistant/tool replies, has more than one visible user message,
|
||||
// or the current title doesn't look like a candidate for replacement.
|
||||
// titleInput returns the first user message title text and whether
|
||||
// title generation should proceed. It returns false when the chat
|
||||
// already has assistant/tool replies, has more than one visible user
|
||||
// message, or the current title doesn't look like a candidate for
|
||||
// replacement. pasteText carries resolved pasted-text attachment
|
||||
// content (see titlePasteText) so paste-only messages stay eligible.
|
||||
func titleInput(
|
||||
chat database.Chat,
|
||||
messages []database.ChatMessage,
|
||||
pasteText map[uuid.UUID]string,
|
||||
) (string, bool) {
|
||||
userCount := 0
|
||||
firstUserText := ""
|
||||
@@ -579,9 +592,7 @@ func titleInput(
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
firstUserText = strings.TrimSpace(
|
||||
contentBlocksToText(parsed),
|
||||
)
|
||||
firstUserText = chatprompt.TitleText(parsed, pasteText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -595,13 +606,58 @@ func titleInput(
|
||||
return firstUserText, true
|
||||
}
|
||||
|
||||
if currentTitle != fallbackChatTitle(firstUserText) {
|
||||
if currentTitle != chatprompt.FallbackTitle(firstUserText) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return firstUserText, true
|
||||
}
|
||||
|
||||
// titlePasteText resolves synthetic pasted-text attachment content
|
||||
// for visible user messages whose text and file-reference parts yield
|
||||
// no title input, fetching only bounded prefixes for
|
||||
// chatprompt.TitleText. It returns nil without touching the database
|
||||
// when every user message already has text.
|
||||
func titlePasteText(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
messages []database.ChatMessage,
|
||||
) (map[uuid.UUID]string, error) {
|
||||
var ids []uuid.UUID
|
||||
for _, message := range messages {
|
||||
if message.Visibility == database.ChatMessageVisibilityModel {
|
||||
continue
|
||||
}
|
||||
if message.Role != database.ChatMessageRoleUser {
|
||||
continue
|
||||
}
|
||||
parsed, err := chatprompt.ParseContent(message)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if chatprompt.TitleText(parsed, nil) != "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, chatprompt.SyntheticPasteFileIDs(parsed)...)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil //nolint:nilnil // Nil map cleanly signals no paste content to resolve.
|
||||
}
|
||||
|
||||
rows, err := store.GetChatFileDataPrefixesByIDs(ctx, database.GetChatFileDataPrefixesByIDsParams{
|
||||
IDs: ids,
|
||||
PrefixBytes: chatprompt.TitlePasteBytePrefix,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get pasted-text chat file prefixes: %w", err)
|
||||
}
|
||||
pasteText := make(map[uuid.UUID]string, len(rows))
|
||||
for _, row := range rows {
|
||||
pasteText[row.ID] = chatprompt.TitlePasteText(row.DataPrefix)
|
||||
}
|
||||
return pasteText, nil
|
||||
}
|
||||
|
||||
func normalizeTitleOutput(title string) string {
|
||||
title = normalizeShortTextOutput(title)
|
||||
if title == "" {
|
||||
@@ -610,29 +666,6 @@ func normalizeTitleOutput(title string) string {
|
||||
return truncateRunes(title, 80)
|
||||
}
|
||||
|
||||
func fallbackChatTitle(message string) string {
|
||||
const maxWords = 6
|
||||
const maxRunes = 80
|
||||
|
||||
words := strings.Fields(message)
|
||||
if len(words) == 0 {
|
||||
return "New Chat"
|
||||
}
|
||||
|
||||
truncated := false
|
||||
if len(words) > maxWords {
|
||||
words = words[:maxWords]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
title := strings.Join(words, " ")
|
||||
if truncated {
|
||||
return truncateRunes(title, maxRunes-1) + "…"
|
||||
}
|
||||
|
||||
return truncateRunes(title, maxRunes)
|
||||
}
|
||||
|
||||
// contentBlocksToText concatenates the text parts of SDK chat
|
||||
// message parts into a single space-separated string.
|
||||
func contentBlocksToText(parts []codersdk.ChatMessagePart) string {
|
||||
@@ -670,7 +703,14 @@ type manualTitleTurn struct {
|
||||
text string
|
||||
}
|
||||
|
||||
func extractManualTitleTurns(messages []database.ChatMessage) []manualTitleTurn {
|
||||
// extractManualTitleTurns flattens visible user and assistant
|
||||
// messages into title turns. pasteText carries resolved pasted-text
|
||||
// attachment content (see titlePasteText) so paste-only user messages
|
||||
// still produce turns.
|
||||
func extractManualTitleTurns(
|
||||
messages []database.ChatMessage,
|
||||
pasteText map[uuid.UUID]string,
|
||||
) []manualTitleTurn {
|
||||
turns := make([]manualTitleTurn, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
if message.Visibility == database.ChatMessageVisibilityModel {
|
||||
@@ -692,7 +732,7 @@ func extractManualTitleTurns(messages []database.ChatMessage) []manualTitleTurn
|
||||
continue
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(contentBlocksToText(parts))
|
||||
text := chatprompt.TitleText(parts, pasteText)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
@@ -802,9 +842,10 @@ func renderManualTitlePrompt(
|
||||
func generateManualTitle(
|
||||
ctx context.Context,
|
||||
messages []database.ChatMessage,
|
||||
pasteText map[uuid.UUID]string,
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
) (string, fantasy.Usage, error) {
|
||||
turns := extractManualTitleTurns(messages)
|
||||
turns := extractManualTitleTurns(messages, pasteText)
|
||||
selected := selectManualTitleTurnIndexes(turns)
|
||||
|
||||
firstUserIndex := slices.IndexFunc(turns, func(turn manualTitleTurn) bool {
|
||||
|
||||
@@ -2,6 +2,7 @@ package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -11,13 +12,17 @@ import (
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -27,11 +32,24 @@ import (
|
||||
func Test_extractManualTitleTurns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pasteFileID := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []database.ChatMessage
|
||||
want []manualTitleTurn
|
||||
name string
|
||||
messages []database.ChatMessage
|
||||
pasteText map[uuid.UUID]string
|
||||
want []manualTitleTurn
|
||||
}{
|
||||
{
|
||||
name: "paste only user message resolves via paste text",
|
||||
messages: []database.ChatMessage{
|
||||
mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
),
|
||||
},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: "pasted panic output"},
|
||||
want: []manualTitleTurn{{role: "user", text: "pasted panic output"}},
|
||||
},
|
||||
{
|
||||
name: "filters to visible user and assistant text turns",
|
||||
messages: []database.ChatMessage{
|
||||
@@ -82,7 +100,7 @@ func Test_extractManualTitleTurns(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := extractManualTitleTurns(tt.messages)
|
||||
got := extractManualTitleTurns(tt.messages, tt.pasteText)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
@@ -363,6 +381,149 @@ func Test_renderManualTitlePrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_titleInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pasteFileID := uuid.New()
|
||||
pasteContent := "pasted stack trace with details"
|
||||
pasteMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
)
|
||||
textMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageText("summarize build logs"),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chat database.Chat
|
||||
messages []database.ChatMessage
|
||||
pasteText map[uuid.UUID]string
|
||||
wantInput string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "text message with fallback title is eligible",
|
||||
chat: database.Chat{Title: chatprompt.FallbackTitle("summarize build logs")},
|
||||
messages: []database.ChatMessage{textMessage},
|
||||
wantInput: "summarize build logs",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "paste only message with resolved paste text is eligible",
|
||||
chat: database.Chat{Title: chatprompt.FallbackTitle(pasteContent)},
|
||||
messages: []database.ChatMessage{pasteMessage},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: pasteContent},
|
||||
wantInput: pasteContent,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "paste only message without resolved paste text is skipped",
|
||||
chat: database.Chat{Title: "New Chat"},
|
||||
messages: []database.ChatMessage{pasteMessage},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "paste only message with user renamed title is skipped",
|
||||
chat: database.Chat{Title: "my custom name"},
|
||||
messages: []database.ChatMessage{pasteMessage},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: pasteContent},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "assistant reply disables generation",
|
||||
chat: database.Chat{Title: chatprompt.FallbackTitle(pasteContent)},
|
||||
messages: []database.ChatMessage{
|
||||
pasteMessage,
|
||||
mustChatMessage(t, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageText("done"),
|
||||
),
|
||||
},
|
||||
pasteText: map[uuid.UUID]string{pasteFileID: pasteContent},
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input, ok := titleInput(tt.chat, tt.messages, tt.pasteText)
|
||||
require.Equal(t, tt.wantOK, ok)
|
||||
require.Equal(t, tt.wantInput, input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_titlePasteText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pasteFileID := uuid.New()
|
||||
pasteMessage := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
)
|
||||
|
||||
t.Run("skips fetch when user messages have text", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
// No GetChatFileDataPrefixesByIDs expectation: a fetch would
|
||||
// fail the test.
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{
|
||||
mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageText("typed text"),
|
||||
codersdk.ChatMessageFile(pasteFileID, "text/plain", "pasted-text-2026-01-02-03-04-05.txt"),
|
||||
),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, pasteText)
|
||||
})
|
||||
|
||||
t.Run("resolves paste content for paste only user messages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
db.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), database.GetChatFileDataPrefixesByIDsParams{
|
||||
IDs: []uuid.UUID{pasteFileID},
|
||||
PrefixBytes: chatprompt.TitlePasteBytePrefix,
|
||||
}).Return([]database.GetChatFileDataPrefixesByIDsRow{
|
||||
{ID: pasteFileID, DataPrefix: []byte("pasted content")},
|
||||
}, nil)
|
||||
|
||||
pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{pasteMessage})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[uuid.UUID]string{pasteFileID: "pasted content"}, pasteText)
|
||||
})
|
||||
|
||||
t.Run("propagates fetch errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
db.EXPECT().GetChatFileDataPrefixesByIDs(gomock.Any(), gomock.Any()).Return(nil, sql.ErrConnDone)
|
||||
|
||||
_, err := titlePasteText(context.Background(), db, []database.ChatMessage{pasteMessage})
|
||||
require.ErrorIs(t, err, sql.ErrConnDone)
|
||||
})
|
||||
|
||||
t.Run("ignores non synthetic file only messages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
pasteText, err := titlePasteText(context.Background(), db, []database.ChatMessage{
|
||||
mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth,
|
||||
codersdk.ChatMessageFile(uuid.New(), "image/png", "photo.png"),
|
||||
),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, pasteText)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -390,7 +551,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: fallbackChatTitle(userPrompt),
|
||||
Title: chatprompt.FallbackTitle(userPrompt),
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
})
|
||||
@@ -422,6 +583,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
|
||||
ctx,
|
||||
chat,
|
||||
[]database.ChatMessage{message},
|
||||
nil,
|
||||
"openai",
|
||||
"test-model",
|
||||
model,
|
||||
@@ -488,6 +650,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
|
||||
title, _, err := generateManualTitle(
|
||||
context.Background(),
|
||||
messages,
|
||||
nil,
|
||||
model,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -524,6 +687,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
|
||||
_, _, err := generateManualTitle(
|
||||
context.Background(),
|
||||
messages,
|
||||
nil,
|
||||
model,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -557,6 +721,7 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T)
|
||||
_, usage, err := generateManualTitle(
|
||||
context.Background(),
|
||||
messages,
|
||||
nil,
|
||||
model,
|
||||
)
|
||||
require.ErrorContains(t, err, "generated title was empty")
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/aibridge"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -62,6 +63,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) {
|
||||
ctx,
|
||||
chat,
|
||||
messages,
|
||||
nil,
|
||||
"openai",
|
||||
"fallback-chat-model",
|
||||
fallbackModel,
|
||||
@@ -111,6 +113,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideReadDBError(t *testing.T)
|
||||
ctx,
|
||||
chat,
|
||||
messages,
|
||||
nil,
|
||||
"openai",
|
||||
"fallback-chat-model",
|
||||
fallbackModel,
|
||||
@@ -159,6 +162,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideMalformedFallsThrough(t *
|
||||
ctx,
|
||||
chat,
|
||||
messages,
|
||||
nil,
|
||||
"openai",
|
||||
"fallback-chat-model",
|
||||
fallbackModel,
|
||||
@@ -232,6 +236,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) {
|
||||
ctx,
|
||||
chat,
|
||||
messages,
|
||||
nil,
|
||||
"openai",
|
||||
"fallback-chat-model",
|
||||
fallbackModel,
|
||||
@@ -273,6 +278,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi
|
||||
ctx,
|
||||
chat,
|
||||
messages,
|
||||
nil,
|
||||
"openai",
|
||||
"fallback-chat-model",
|
||||
fallbackModel,
|
||||
@@ -326,6 +332,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(
|
||||
ctx,
|
||||
chat,
|
||||
messages,
|
||||
nil,
|
||||
"openai",
|
||||
"fallback-chat-model",
|
||||
fallbackModel,
|
||||
@@ -667,7 +674,7 @@ func titleOverrideTestChatAndMessages(t *testing.T) (database.Chat, []database.C
|
||||
chat := database.Chat{
|
||||
ID: uuid.New(),
|
||||
OwnerID: uuid.New(),
|
||||
Title: fallbackChatTitle(userPrompt),
|
||||
Title: chatprompt.FallbackTitle(userPrompt),
|
||||
}
|
||||
message := mustChatMessage(
|
||||
t,
|
||||
|
||||
Reference in New Issue
Block a user