mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add search and filter support to chats endpoint (#25391)
Fixes https://linear.app/codercom/issue/CODAGT-432 Adds structured search/filter capabilities to the `GET /api/experimental/chats/` endpoint via the `q` query parameter. All filters use explicit `key:value` syntax; bare terms are rejected to reserve them for potential future full-text search. > Generated by Coder Agents Co-authored-by: Danielle Maywood <danielle@themaywoods.com> Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
This commit is contained in:
co-authored by
Danielle Maywood
Jaayden Halko
parent
46e93e6325
commit
b7525a9b40
@@ -13948,6 +13948,242 @@ func TestDeleteChatDebugDataByChatIDStartedBeforeFiltersNewerRuns(t *testing.T)
|
||||
require.Equal(t, newRun.ID, remaining.ID)
|
||||
}
|
||||
|
||||
func TestGetChatsFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, _ := dbtestutil.NewDB(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})
|
||||
|
||||
_, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
APIKey: "test-key",
|
||||
Enabled: true,
|
||||
CentralApiKeyEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
Provider: "openai",
|
||||
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)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
linkPR := func(chatID uuid.UUID, url, state string, draft bool) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
_, err := store.UpsertChatDiffStatus(ctx, database.UpsertChatDiffStatusParams{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: url, Valid: true},
|
||||
PullRequestState: sql.NullString{String: state, Valid: true},
|
||||
PullRequestTitle: "PR " + state,
|
||||
PullRequestDraft: draft,
|
||||
Additions: 1,
|
||||
Deletions: 1,
|
||||
ChangedFiles: 1,
|
||||
RefreshedAt: now,
|
||||
StaleAt: now.Add(time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
makeUnread := func(chatID uuid.UUID) {
|
||||
t.Helper()
|
||||
_, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{
|
||||
ChatID: chatID,
|
||||
CreatedBy: []uuid.UUID{user.ID},
|
||||
ModelConfigID: []uuid.UUID{modelCfg.ID},
|
||||
Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant},
|
||||
Content: []string{`[{"type":"text","text":"hello"}]`},
|
||||
ContentVersion: []int16{0},
|
||||
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
|
||||
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},
|
||||
ProviderResponseID: []string{""},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
markRead := func(chatID uuid.UUID) {
|
||||
t.Helper()
|
||||
lastMsg, err := store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = store.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
|
||||
ID: chatID,
|
||||
LastReadMessageID: lastMsg.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- fixtures ---
|
||||
|
||||
// Title-only chats (no PR, no unread).
|
||||
alphaProject := createRoot("alpha project")
|
||||
betaProject := createRoot("beta project")
|
||||
gammaUnrelated := createRoot("gamma unrelated")
|
||||
percentComplete := createRoot("100% complete")
|
||||
thousandOne := createRoot("1001 things")
|
||||
underscoreConfig := createRoot("user_name config")
|
||||
hyphenConfig := createRoot("user-name config")
|
||||
|
||||
// PR-linked chats.
|
||||
draftPR := createRoot("draft pr chat")
|
||||
linkPR(draftPR.ID, "https://github.com/coder/coder/pull/1001", "open", true)
|
||||
makeUnread(draftPR.ID) // also unread
|
||||
|
||||
openPR := createRoot("open pr chat")
|
||||
linkPR(openPR.ID, "https://github.com/coder/coder/pull/1002", "open", false)
|
||||
|
||||
mergedPR := createRoot("merged pr chat")
|
||||
linkPR(mergedPR.ID, "https://github.com/coder/coder/pull/1003", "merged", false)
|
||||
|
||||
closedPR := createRoot("closed pr chat")
|
||||
linkPR(closedPR.ID, "https://github.com/coder/coder/pull/1004", "closed", false)
|
||||
|
||||
// Unread chat without PR.
|
||||
unreadNoPR := createRoot("unread no pr")
|
||||
makeUnread(unreadNoPR.ID)
|
||||
|
||||
// Read chat (message exists but marked read).
|
||||
readChat := createRoot("read chat")
|
||||
makeUnread(readChat.ID)
|
||||
markRead(readChat.ID)
|
||||
|
||||
// Child with draft PR (must not surface its parent).
|
||||
childParent := createRoot("child parent")
|
||||
makeUnread(childParent.ID)
|
||||
markRead(childParent.ID)
|
||||
childWithDraftPR := createChild(childParent, "child draft pr")
|
||||
linkPR(childWithDraftPR.ID, "https://github.com/coder/coder/pull/1005", "open", true)
|
||||
makeUnread(childWithDraftPR.ID)
|
||||
|
||||
// All root chat IDs (for "returns everything" baseline).
|
||||
allRootIDs := []uuid.UUID{
|
||||
alphaProject.ID, betaProject.ID, gammaUnrelated.ID,
|
||||
percentComplete.ID, thousandOne.ID, underscoreConfig.ID, hyphenConfig.ID,
|
||||
draftPR.ID, openPR.ID, mergedPR.ID, closedPR.ID,
|
||||
unreadNoPR.ID, readChat.ID, childParent.ID,
|
||||
}
|
||||
|
||||
// --- test cases ---
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params database.GetChatsParams
|
||||
want []uuid.UUID
|
||||
}{
|
||||
// Title filter.
|
||||
{"Title/SubstringMatch", database.GetChatsParams{TitleQuery: "project"}, []uuid.UUID{alphaProject.ID, betaProject.ID}},
|
||||
{"Title/SingleResult", database.GetChatsParams{TitleQuery: "gamma"}, []uuid.UUID{gammaUnrelated.ID}},
|
||||
{"Title/CaseInsensitive", database.GetChatsParams{TitleQuery: "ALPHA"}, []uuid.UUID{alphaProject.ID}},
|
||||
{"Title/MultiWord", database.GetChatsParams{TitleQuery: "alpha project"}, []uuid.UUID{alphaProject.ID}},
|
||||
{"Title/NoMatch", database.GetChatsParams{TitleQuery: "nonexistent"}, nil},
|
||||
{"Title/EmptyReturnsAll", database.GetChatsParams{TitleQuery: ""}, allRootIDs},
|
||||
// % acts as wildcard since we don't escape ILIKE metacharacters.
|
||||
{"Title/PercentWildcard", database.GetChatsParams{TitleQuery: "100%"}, []uuid.UUID{percentComplete.ID, thousandOne.ID}},
|
||||
// _ acts as single-char wildcard.
|
||||
{"Title/UnderscoreWildcard", database.GetChatsParams{TitleQuery: "user_name"}, []uuid.UUID{underscoreConfig.ID, hyphenConfig.ID}},
|
||||
|
||||
// PR status filter.
|
||||
{"PRStatus/Draft", database.GetChatsParams{PullRequestStatuses: []string{"draft"}}, []uuid.UUID{draftPR.ID}},
|
||||
{"PRStatus/Open", database.GetChatsParams{PullRequestStatuses: []string{"open"}}, []uuid.UUID{openPR.ID}},
|
||||
{"PRStatus/Merged", database.GetChatsParams{PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedPR.ID}},
|
||||
{"PRStatus/Closed", database.GetChatsParams{PullRequestStatuses: []string{"closed"}}, []uuid.UUID{closedPR.ID}},
|
||||
{"PRStatus/MultiStatus", database.GetChatsParams{PullRequestStatuses: []string{"draft", "closed"}}, []uuid.UUID{draftPR.ID, closedPR.ID}},
|
||||
|
||||
// Unread filter.
|
||||
{"Unread/MatchesUnread", database.GetChatsParams{HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID, unreadNoPR.ID}},
|
||||
// HasUnread=false returns chats without unread messages.
|
||||
{"Unread/ExcludesRead", database.GetChatsParams{HasUnread: sql.NullBool{Bool: false, Valid: true}}, []uuid.UUID{alphaProject.ID, betaProject.ID, gammaUnrelated.ID, percentComplete.ID, thousandOne.ID, underscoreConfig.ID, hyphenConfig.ID, openPR.ID, mergedPR.ID, closedPR.ID, readChat.ID, childParent.ID}},
|
||||
|
||||
// Composed filters.
|
||||
{"Composed/TitleAndPRStatus", database.GetChatsParams{TitleQuery: "draft", PullRequestStatuses: []string{"draft"}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/TitleAndUnread", database.GetChatsParams{TitleQuery: "draft pr", HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/PRStatusAndUnread", database.GetChatsParams{PullRequestStatuses: []string{"draft"}, HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/AllFilters", database.GetChatsParams{TitleQuery: "draft", PullRequestStatuses: []string{"draft"}, HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/TitleNarrowsUnread", database.GetChatsParams{TitleQuery: "no pr", HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{unreadNoPR.ID}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Always scope to this user.
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user