Skip to content

Commit c734e46

Browse files
fix(search): preserve partial results and reuse document access checks (#7808)
1 parent 85a5938 commit c734e46

13 files changed

Lines changed: 241 additions & 52 deletions

File tree

apps/sim/app/api/knowledge/search/route.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ describe('workspace search route', () => {
1919
user: { id: 'user-1', email: 'reader@fixture.test', name: 'Reader' },
2020
session: { id: 'session-1' },
2121
})
22-
mocks.search.mockResolvedValue({ results: [], knowledgeBases: [] })
22+
mocks.search.mockResolvedValue({
23+
results: [],
24+
knowledgeBases: [],
25+
retrieval: { status: 'complete', timedOutLegs: [] },
26+
})
2327
})
2428

2529
it('passes the authenticated request cancellation signal through the existing operation', async () => {
@@ -41,11 +45,48 @@ describe('workspace search route', () => {
4145
expect(call.input).not.toHaveProperty('knowledgeBaseIds')
4246
expect(call.input.filters).toEqual({ source: 'slack', documentIds: ['doc-1'] })
4347
expect(call.input.signal).toBe(request.signal)
48+
expect(call.input.allowPartialResults).toBe(true)
4449
controller.abort()
4550
expect(call.input.signal.aborted).toBe(true)
4651
await expect(response.json()).resolves.toEqual({
4752
success: true,
48-
data: { query: 'Orion', results: [] },
53+
data: { query: 'Orion', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
54+
})
55+
})
56+
57+
it('preserves usable matches and incomplete coverage when one retrieval leg times out', async () => {
58+
mocks.search.mockResolvedValueOnce({
59+
knowledgeBases: [{ id: 'knowledge-1', name: 'Search index' }],
60+
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
61+
results: [
62+
{
63+
documentId: 'document-1',
64+
knowledgeBaseId: 'knowledge-1',
65+
documentName: 'Release plan',
66+
sourceUrl: null,
67+
connectorType: null,
68+
sourceModifiedAt: null,
69+
metadata: {},
70+
content: 'Orion release',
71+
chunkIndex: 0,
72+
similarity: 0.9,
73+
},
74+
],
75+
})
76+
const response = await POST(
77+
new NextRequest('http://localhost/api/knowledge/search', {
78+
method: 'POST',
79+
headers: { 'content-type': 'application/json' },
80+
body: JSON.stringify({ organizationId: 'organization-1', query: 'Orion' }),
81+
})
82+
)
83+
expect(response.status).toBe(200)
84+
await expect(response.json()).resolves.toMatchObject({
85+
success: true,
86+
data: {
87+
results: [{ documentId: 'document-1', content: 'Orion release' }],
88+
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
89+
},
4990
})
5091
})
5192

apps/sim/app/api/knowledge/search/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,18 @@ export const POST = defineInternalJsonRoute({
2424
filters: body.filters,
2525
query: body.query,
2626
topK: body.topK,
27+
allowPartialResults: true,
2728
surface: 'dashboard' as const,
2829
signal: request.signal,
2930
}),
3031
useCase: searchScopedKnowledge,
31-
present: ({ results, knowledgeBases }, { input }) => {
32+
present: ({ results, knowledgeBases, retrieval }, { input }) => {
3233
const knowledgeBaseNames = new Map(knowledgeBases.map((kb) => [kb.id, kb.name]))
3334
return {
3435
success: true as const,
3536
data: {
3637
query: input.query ?? '',
38+
retrieval,
3739
results: results.map((result) => ({
3840
documentId: result.documentId,
3941
knowledgeBaseId: result.knowledgeBaseId,

apps/sim/app/o/[organizationId]/search/search.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ beforeEach(() => {
9090
chunkIndex: 0,
9191
similarity: 1,
9292
}
93-
return { data: [result], isPending: false, isFetching: false, isError: false }
93+
return {
94+
data: { query, results: [result], retrieval: { status: 'complete', timedOutLegs: [] } },
95+
isPending: false,
96+
isFetching: false,
97+
isError: false,
98+
}
9499
})
95100
container = document.createElement('div')
96101
document.body.appendChild(container)

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,17 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
44
import { createRoot, type Root } from 'react-dom/client'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const mocks = vi.hoisted(() => ({ overview: vi.fn() }))
7+
const mocks = vi.hoisted(() => ({ overview: vi.fn(), search: vi.fn(), retry: vi.fn() }))
88
vi.mock('@/hooks/queries/kb/connectors', () => ({
99
useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }),
1010
useSearchSourceOverview: mocks.overview,
1111
}))
1212
vi.mock('@/hooks/queries/kb/knowledge', () => ({
13-
useWorkspaceKnowledgeSearch: () => ({
14-
data: [],
15-
isPending: false,
16-
isFetching: false,
17-
isError: false,
18-
}),
13+
useWorkspaceKnowledgeSearch: mocks.search,
1914
}))
2015
vi.mock(
2116
'@/app/workspace/[workspaceId]/home/components/message-content/components/source-card',
22-
() => ({ SourceCard: () => null })
17+
() => ({ SourceCard: ({ source }: { source: { title: string } }) => <span>{source.title}</span> })
2318
)
2419

2520
import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results'
@@ -28,6 +23,13 @@ let root: Root
2823
let container: HTMLDivElement
2924
beforeEach(() => {
3025
vi.clearAllMocks()
26+
mocks.search.mockReturnValue({
27+
data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
28+
isPending: false,
29+
isFetching: false,
30+
isError: false,
31+
refetch: mocks.retry,
32+
})
3133
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
3234
container = document.createElement('div')
3335
root = createRoot(container)
@@ -69,3 +71,52 @@ describe('source indexing context in search results', () => {
6971
expect(container.textContent).toContain('No documents you can read match')
7072
})
7173
})
74+
75+
describe('incomplete search coverage', () => {
76+
it.each([false, true])(
77+
'keeps matches and offers a retry without claiming absence (hasResults=%s)',
78+
async (hasResults) => {
79+
mocks.search.mockReturnValue({
80+
data: {
81+
query: 'launch',
82+
results: hasResults
83+
? [
84+
{
85+
documentId: 'document-1',
86+
knowledgeBaseId: 'index',
87+
knowledgeBaseName: 'Search index',
88+
documentName: 'Release plan',
89+
sourceUrl: 'https://fixture.test/release',
90+
connectorType: null,
91+
sourceModifiedAt: null,
92+
author: null,
93+
content: 'launch details',
94+
chunkIndex: 0,
95+
similarity: 0.9,
96+
},
97+
]
98+
: [],
99+
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
100+
},
101+
isPending: false,
102+
isFetching: false,
103+
isError: false,
104+
refetch: mocks.retry,
105+
})
106+
await render()
107+
expect(container.textContent).not.toContain('Search couldn’t run')
108+
expect(container.textContent).not.toContain('No documents')
109+
expect(container.textContent).not.toContain('0 documents')
110+
expect(container.textContent).toContain(
111+
hasResults ? 'Some results may be missing.' : 'Search is incomplete.'
112+
)
113+
if (hasResults) expect(container.textContent).toContain('Release plan')
114+
const retry = [...container.querySelectorAll('button')].find(
115+
(button) => button.textContent === 'Try again'
116+
)
117+
expect(retry).toBeDefined()
118+
await act(async () => retry!.click())
119+
expect(mocks.retry).toHaveBeenCalledOnce()
120+
}
121+
)
122+
})

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export function KnowledgeSearchResults({
133133
}
134134
}, [filters.source, filters.updated])
135135
const {
136-
data: results,
136+
data: search,
137137
isPending,
138138
isFetching,
139139
isError: searchFailed,
@@ -143,7 +143,8 @@ export function KnowledgeSearchResults({
143143
const indexing = (overview?.providers ?? [])
144144
.filter((provider) => provider.isSyncing)
145145
.map((provider) => connectorDisplayName(provider.connectorType))
146-
const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
146+
const documents = useMemo(() => groupResultsByDocument(search?.results ?? []), [search?.results])
147+
const incomplete = search?.retrieval.status === 'partial'
147148
const sourceTypes = [
148149
...new Set([
149150
...(filters.source ? [filters.source] : []),
@@ -155,8 +156,7 @@ export function KnowledgeSearchResults({
155156
const showFilters =
156157
filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1)
157158

158-
/* A failed search says so in one quiet line and offers to run again; the cause is
159-
the server's to log, never the reader's to parse. */
159+
/** A failed search offers a retry; server diagnostics carry the cause. */
160160
if (basesFailed || searchFailed) {
161161
const retrying = basesFetching || isFetching
162162
return (
@@ -188,7 +188,7 @@ export function KnowledgeSearchResults({
188188
</div>
189189
)
190190
}
191-
if (isPending || (isFetching && !results)) {
191+
if (isPending || (isFetching && !search)) {
192192
return (
193193
<div className='px-2 py-2'>
194194
<ActivityStatus label='Searching…' isActive />
@@ -205,12 +205,24 @@ export function KnowledgeSearchResults({
205205
<div className='flex flex-col'>
206206
<div className='flex items-center gap-2 px-2 py-2'>
207207
<span className='min-w-0 flex-1 text-[var(--text-muted)] text-caption'>
208-
<span className='tabular-nums'>
209-
{documents.length === 1 ? '1 document' : `${documents.length} documents`}
210-
</span>
211-
{' · searched as you'}
208+
{incomplete && documents.length === 0 ? (
209+
'Search is incomplete.'
210+
) : (
211+
<>
212+
<span className='tabular-nums'>
213+
{documents.length === 1 ? '1 document' : `${documents.length} documents`}
214+
</span>
215+
{' · searched as you'}
216+
{incomplete && <span className='block'>Some results may be missing.</span>}
217+
</>
218+
)}
212219
{indexingNote && <span className='block'>{indexingNote}</span>}
213220
</span>
221+
{incomplete && (
222+
<Chip variant='border' disabled={isFetching} onClick={() => void refetchSearch()}>
223+
{isFetching ? 'Retrying…' : 'Try again'}
224+
</Chip>
225+
)}
214226
</div>
215227
{showFilters && (
216228
<div className='flex flex-wrap items-center gap-1.5 px-2 pb-2'>
@@ -245,11 +257,13 @@ export function KnowledgeSearchResults({
245257
</div>
246258
)}
247259
{documents.length === 0 ? (
248-
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
249-
{filtersActive
250-
? 'No documents match these filters.'
251-
: `No documents you can read match “${query}”.`}
252-
</p>
260+
!incomplete && (
261+
<p className='px-2 py-2 text-[var(--text-muted)] text-caption'>
262+
{filtersActive
263+
? 'No documents match these filters.'
264+
: `No documents you can read match “${query}”.`}
265+
</p>
266+
)
253267
) : (
254268
<div className='flex flex-col' onKeyDown={handleResultsKeyDown}>
255269
{documents.map((result) => {

apps/sim/hooks/queries/kb/knowledge.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,17 @@ describe('knowledge query placeholder scope', () => {
132132
})
133133

134134
it('forwards search cancellation and leaves provider retries to the server', async () => {
135-
mocks.requestJson.mockResolvedValueOnce({ data: { results: [] } })
135+
const data = {
136+
query: 'query',
137+
results: [],
138+
retrieval: { status: 'partial', timedOutLegs: ['vector'] },
139+
}
140+
mocks.requestJson.mockResolvedValueOnce({ data })
136141
const query = captureQuery(() =>
137142
useWorkspaceKnowledgeSearch('workspace-1', ' query ', { source: 'slack' })
138143
)
139144
const controller = new AbortController()
140-
await query.queryFn({ signal: controller.signal })
145+
await expect(query.queryFn({ signal: controller.signal })).resolves.toEqual(data)
141146

142147
expect(query.retry).toBe(false)
143148
expect(mocks.requestJson).toHaveBeenLastCalledWith(

apps/sim/hooks/queries/kb/knowledge.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import {
5252
updateKnowledgeDocumentContract,
5353
updateKnowledgeDocumentTagsContract,
5454
type WorkspaceKnowledgeSearchBody,
55-
type WorkspaceKnowledgeSearchResult,
55+
type WorkspaceKnowledgeSearchData,
5656
} from '@/lib/api/contracts/knowledge'
5757
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/search'
5858
import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types'
@@ -1197,9 +1197,9 @@ export function useBulkDeleteKnowledgeBases(workspaceId: string) {
11971197
async function searchWorkspaceKnowledge(
11981198
body: WorkspaceKnowledgeSearchBody,
11991199
signal?: AbortSignal
1200-
): Promise<WorkspaceKnowledgeSearchResult[]> {
1200+
): Promise<WorkspaceKnowledgeSearchData> {
12011201
const data = await requestJson(searchWorkspaceKnowledgeContract, { body, signal })
1202-
return data.data.results
1202+
return data.data
12031203
}
12041204

12051205
/** Searches the canonical index under the signed-in person's ACLs. */

apps/sim/lib/api/contracts/knowledge/search.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,16 @@ export const workspaceKnowledgeSearchBodySchema = resourceOwnerSchema.safeExtend
186186
})
187187
export type WorkspaceKnowledgeSearchBody = z.input<typeof workspaceKnowledgeSearchBodySchema>
188188

189+
export const workspaceKnowledgeSearchDataSchema = z.object({
190+
query: z.string(),
191+
results: z.array(workspaceKnowledgeSearchResultSchema),
192+
retrieval: z.object({
193+
status: z.enum(['complete', 'partial']),
194+
timedOutLegs: z.array(z.enum(['vector', 'keyword', 'tags'])).max(3),
195+
}),
196+
})
197+
export type WorkspaceKnowledgeSearchData = z.output<typeof workspaceKnowledgeSearchDataSchema>
198+
189199
/**
190200
* The search a signed-in person runs from the composer: what their own
191201
* account may read across the workspace's knowledge bases, presented as
@@ -199,10 +209,7 @@ export const searchWorkspaceKnowledgeContract = defineRouteContract({
199209
mode: 'json',
200210
schema: z.object({
201211
success: z.literal(true),
202-
data: z.object({
203-
query: z.string(),
204-
results: z.array(workspaceKnowledgeSearchResultSchema),
205-
}),
212+
data: workspaceKnowledgeSearchDataSchema,
206213
}),
207214
},
208215
})

0 commit comments

Comments
 (0)