feat: Add full text search over chat messages (#27126)

Closes CODAGT-721
Closes CODAGT-722
Closes CODAGT-723
Closes CODAGT-724
Closes CODAGT-725

This PR adds the database and API pieces necessary to support full-text
chat message search.

- Adds required chat schema for full-text search
- Adds dbpurge job to populate search_tsv in the background
- Adds `search` parameter to GetChats query
- Adds `search` filter to `searchquery.Chats`
- Wires chat search filter into chats API

> Implemented by Coder Agents, reviewed and tested by a human.
This commit is contained in:
Cian Johnston
2026-07-16 15:21:57 +01:00
committed by GitHub
parent 9862f10484
commit f7481c5d08
26 changed files with 1756 additions and 35 deletions
+232
View File
@@ -10,6 +10,7 @@ import (
"net"
"slices"
"sort"
"strconv"
"strings"
"testing"
"time"
@@ -15344,6 +15345,237 @@ func TestGetChatsFilter(t *testing.T) {
}
}
func TestGetChatsSearch(t *testing.T) {
t.Parallel()
store, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
ctx := context.Background()
org := dbgen.Organization(t, store, database.Organization{})
user := dbgen.User(t, store, database.User{})
dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
provider := dbgen.AIProviderWithOptionalKey(t, store, database.AIProvider{
Type: database.AIProviderTypeOpenai,
}, "test-key")
modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
Model: "test-model-" + uuid.NewString(),
DisplayName: "Test Model",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)
createRoot := func(title string) database.Chat {
t.Helper()
chat, err := store.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: title,
})
require.NoError(t, err)
return chat
}
createChild := func(root database.Chat, title string) database.Chat {
t.Helper()
chat, err := store.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: title,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
require.NoError(t, err)
return chat
}
insertMsg := func(chatID uuid.UUID, role database.ChatMessageRole, visibility database.ChatMessageVisibility, text string) database.ChatMessage {
t.Helper()
msgs, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{
ChatID: chatID,
CreatedBy: []uuid.UUID{user.ID},
ModelConfigID: []uuid.UUID{modelCfg.ID},
Role: []database.ChatMessageRole{role},
Content: []string{`[{"type":"text","text":` + strconv.Quote(text) + `}]`},
ContentVersion: []int16{1},
Visibility: []database.ChatMessageVisibility{visibility},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
require.Len(t, msgs, 1)
return msgs[0]
}
linkPR := func(chatID uuid.UUID, url, state, prTitle string, prNumber int32, gitRemoteOrigin string) {
t.Helper()
now := time.Now()
_, err := store.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{
ChatID: chatID,
Url: sql.NullString{String: url, Valid: true},
GitBranch: "main",
GitRemoteOrigin: gitRemoteOrigin,
StaleAt: now.Add(time.Hour),
})
require.NoError(t, err)
_, err = store.UpsertChatDiffStatus(ctx, database.UpsertChatDiffStatusParams{
ChatID: chatID,
Url: sql.NullString{String: url, Valid: true},
PullRequestState: sql.NullString{String: state, Valid: true},
PullRequestTitle: prTitle,
PrNumber: sql.NullInt32{Int32: prNumber, Valid: prNumber > 0},
Additions: 1,
Deletions: 1,
ChangedFiles: 1,
RefreshedAt: now,
StaleAt: now.Add(time.Hour),
})
require.NoError(t, err)
}
titleChat := createRoot("deploy pipeline alpha")
archivedChat := createRoot("deploy pipeline beta")
prTitleChat := createRoot("widget work")
linkPR(prTitleChat.ID, "https://github.com/acme/widget/pull/42", "open", "Fix authentication bug", 42, "https://github.com/acme/widget.git")
mergedChat := createRoot("other work")
linkPR(mergedChat.ID, "https://github.com/acme/other-repo/pull/7", "merged", "Fix authentication flow", 7, "https://github.com/acme/other-repo.git")
msgChat := createRoot("plain one")
insertMsg(msgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "kubernetes cluster restart")
assistantMsgChat := createRoot("plain assistant")
insertMsg(assistantMsgChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, "grafana dashboard tuning")
userVisMsgChat := createRoot("plain uservis")
insertMsg(userVisMsgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityUser, "vault token rotation")
assistantUserVisMsgChat := createRoot("plain assistant uservis")
insertMsg(assistantUserVisMsgChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityUser, "redis eviction policy")
deletedMsgChat := createRoot("plain two")
deletedMsg := insertMsg(deletedMsgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "terraform apply failure")
childParent := createRoot("plain parent")
childChat := createChild(childParent, "plain child")
insertMsg(childChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityUser, "orchestrator saga")
ineligibleChat := createRoot("plain three")
toolMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "forbidden secret token")
modelOnlyMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "forbidden secret token")
// Ineligible rows keep search_tsv NULL after backfill.
_, err = store.BackfillChatMessagesSearchTsv(ctx, 1000)
require.NoError(t, err)
// Soft-deleted rows stay excluded even though search_tsv remains
// populated.
err = store.SoftDeleteChatMessageByID(ctx, deletedMsg.ID)
require.NoError(t, err)
// Inserted after backfill: search_tsv IS NULL, must match nothing.
pendingChat := createRoot("plain four")
insertMsg(pendingChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "elasticsearch indexing")
// Prove role/visibility predicates exclude rows even when search_tsv
// is set.
_, err = sqlDB.ExecContext(ctx,
`UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'forbidden secret token') WHERE id = ANY($1)`,
pq.Array([]int64{toolMsg.ID, modelOnlyMsg.ID}))
require.NoError(t, err)
_, err = store.ArchiveChatByID(ctx, archivedChat.ID)
require.NoError(t, err)
allRootIDs := []uuid.UUID{
titleChat.ID, archivedChat.ID, prTitleChat.ID, mergedChat.ID,
msgChat.ID, assistantMsgChat.ID, userVisMsgChat.ID,
assistantUserVisMsgChat.ID, deletedMsgChat.ID, childParent.ID,
ineligibleChat.ID, pendingChat.ID,
}
tests := []struct {
name string
params database.GetChatsParams
want []uuid.UUID
}{
{"Title/Match", database.GetChatsParams{Search: "pipeline alpha"}, []uuid.UUID{titleChat.ID}},
{"Title/CaseInsensitiveMultiWord", database.GetChatsParams{Search: "ALPHA DEPLOY"}, []uuid.UUID{titleChat.ID}},
{"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil},
{"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}},
{"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}},
{"Message/AssistantRoleMatch", database.GetChatsParams{Search: "grafana tuning"}, []uuid.UUID{assistantMsgChat.ID}},
{"Message/UserVisibilityMatch", database.GetChatsParams{Search: "vault rotation"}, []uuid.UUID{userVisMsgChat.ID}},
{"Message/AssistantUserVisibilityMatch", database.GetChatsParams{Search: "redis eviction"}, []uuid.UUID{assistantUserVisMsgChat.ID}},
{"PRNumber/Match", database.GetChatsParams{Search: "42"}, []uuid.UUID{prTitleChat.ID}},
{"PRNumber/NonNumericNoMatch", database.GetChatsParams{Search: "42abc"}, nil},
{"PRNumber/OversizedDigitsNoError", database.GetChatsParams{Search: "1111111111111111111111111"}, nil},
{"NoMatch", database.GetChatsParams{Search: "zzzqqq"}, nil},
{"Message/PendingBackfillNoMatch", database.GetChatsParams{Search: "elasticsearch"}, nil},
{"Message/DeletedNoMatch", database.GetChatsParams{Search: "terraform"}, nil},
// Parent also excluded: EXISTS is per-chat, not per-tree.
{"Message/ChildNotSurfaced", database.GetChatsParams{Search: "orchestrator saga"}, nil},
{"Message/IneligibleMessagesNoMatch", database.GetChatsParams{Search: "forbidden secret"}, nil},
{"Composed/ArchivedDefaultIncludesAll", database.GetChatsParams{Search: "deploy pipeline"}, []uuid.UUID{titleChat.ID, archivedChat.ID}},
{"Composed/ArchivedFalseExcludes", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: false, Valid: true}}, []uuid.UUID{titleChat.ID}},
{"Composed/ArchivedTrueOnly", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{archivedChat.ID}},
{"Composed/SearchAndRepo", database.GetChatsParams{Search: "authentication", RepoQuery: "acme/widget"}, []uuid.UUID{prTitleChat.ID}},
{"Composed/SearchAndPRStatus", database.GetChatsParams{Search: "authentication", PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedChat.ID}},
{"EmptySearch/ReturnsAll", database.GetChatsParams{Search: ""}, allRootIDs},
{"WhitespaceSearch/ReturnsNothing", database.GetChatsParams{Search: " "}, nil},
{"TabOnlySearch/ReturnsNothing", database.GetChatsParams{Search: "\t\t"}, nil},
{"EmptySearch/TitleQueryStillWorks", database.GetChatsParams{Search: "", TitleQuery: "pipeline alpha"}, []uuid.UUID{titleChat.ID}},
{"EmptySearch/PRTitleQueryStillWorks", database.GetChatsParams{Search: "", PrTitleQuery: "authentication bug"}, []uuid.UUID{prTitleChat.ID}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params := tt.params
params.OwnedOnly = true
params.ViewerID = user.ID
rows, err := store.GetChats(ctx, params)
require.NoError(t, err)
got := make([]uuid.UUID, 0, len(rows))
for _, row := range rows {
got = append(got, row.Chat.ID)
}
if tt.want == nil {
require.Empty(t, got)
} else {
require.ElementsMatch(t, tt.want, got)
}
})
}
}
func TestChatHasUnread(t *testing.T) {
t.Parallel()