Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions coderd/database/modelqueries.go
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
arg.PrNumber,
arg.RepoQuery,
arg.PrTitleQuery,
arg.Search,
arg.OffsetOpt,
arg.LimitOpt,
)
Expand Down
232 changes: 232 additions & 0 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net"
"slices"
"sort"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -15240,6 +15241,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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-13] The message search positive path covers only one of four eligible (role, visibility) pairs.

The SQL accepts cm.role IN ('user', 'assistant') AND cm.visibility IN ('user', 'both'), giving four eligible combinations. Every positive message match on a root chat uses (user, both):

  • msgChat at line 15365: ChatMessageRoleUser, ChatMessageVisibilityBoth

The assistant role only appears on childChat (line 15372), where the assertion tests child exclusion, not role eligibility. The user visibility (distinct from both) also only appears there.

"If someone narrowed the SQL to cm.role = 'user' or cm.visibility = 'both', every test in this suite would still pass. The exclusion tests prove that tool role and model visibility are rejected, but they don't prove that assistant role or user visibility are accepted." (Bisky)

Fix: add one message with (assistant, both) and one with (user, user) on root chats, each with a distinct search term, and add table cases asserting they match.

(Bisky P3)

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(fixed): Added root-chat fixtures for the three missing eligible pairs: (assistant, both), (user, user), and (assistant, user), each with a distinct term and a positive table case. Narrowing role or visibility in the SQL now fails the suite. Fixed in 7b927dd.

🤖 Reply posted by Coder Agents on behalf of @johnstcn.


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/ReturnsAll", database.GetChatsParams{Search: " "}, allRootIDs},
{"TabOnlySearch/ReturnsAll", database.GetChatsParams{Search: "\t\t"}, allRootIDs},
{"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()

Expand Down
46 changes: 44 additions & 2 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions coderd/database/queries/chats.sql
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,46 @@ WHERE
)
ELSE true
END
-- websearch_to_tsquery accepts quoted phrases, OR, and -negation;
-- the 'simple' config folds case and skips stemming.
AND CASE
WHEN btrim(@search::text, E' \t\n\r') != '' THEN (
-- Served by idx_chats_title_fts.
to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search)
Comment thread
johnstcn marked this conversation as resolved.
-- Served by idx_chat_diff_statuses_pr_title_fts.
OR EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search)
)
-- The WHERE clause must repeat the partial predicate of
-- idx_chat_messages_search_tsv exactly so the planner can use it.
OR EXISTS (
SELECT 1
FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
AND cm.search_tsv IS NOT NULL
AND cm.deleted = false
AND cm.visibility IN ('user', 'both')
AND cm.role IN ('user', 'assistant')
AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)
)
-- CASE forces the digits guard before the ::bigint cast; AND
-- operand order is not guaranteed.
OR CASE
WHEN @search ~ '^[0-9]{1,18}$' THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND cds.pr_number IS NOT NULL
AND cds.pr_number::bigint = @search::bigint
)
ELSE false
END
)
ELSE true
END
-- Paginate over root chats only. Children are fetched
-- separately via GetChildChatsByParentIDs and embedded under
-- each parent. Other callers that need the full set should
Expand Down
Loading