Skip to content

Commit 240a97c

Browse files
committed
fix(search): validate Lucid queries and isolate stale candidates
1 parent 27c3697 commit 240a97c

6 files changed

Lines changed: 98 additions & 31 deletions

File tree

‎apps/sim/lib/api/contracts/mothership-assistant-tools.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { LIVE_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog
44
export const liveSearchProviderSchema = z.enum(LIVE_SEARCH_PROVIDER_IDS)
55
export type LiveSearchProvider = z.output<typeof liveSearchProviderSchema>
66

7-
export const NOTION_SEARCH_TERMS_REQUIRED =
8-
'Notion requires search terms. Add keywords or a concise question.'
7+
export const SEARCH_TERMS_REQUIRED = {
8+
notion: 'Notion requires search terms. Add keywords or a concise question.',
9+
lucid: 'Lucid requires search terms. Add document-title keywords or a literal shape-text query.',
10+
} as const
911

1012
/**
1113
* Native queries one call may send to the same provider account. Alternatives run as separate
@@ -64,11 +66,11 @@ export const nativeSearchQuerySchema = z
6466
message: `${input.provider} kind must be one of: ${kinds.options.join(', ')}.`,
6567
})
6668
}
67-
if (input.provider === 'notion' && !input.query)
69+
if ((input.provider === 'notion' || input.provider === 'lucid') && !input.query)
6870
context.addIssue({
6971
code: 'custom',
7072
path: ['query'],
71-
message: NOTION_SEARCH_TERMS_REQUIRED,
73+
message: SEARCH_TERMS_REQUIRED[input.provider],
7274
})
7375
})
7476
export type NativeSearchQuery = z.output<typeof nativeSearchQuerySchema>

‎apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ export const liveSearchProviderSchema = z.enum([
2222
])
2323
export type LiveSearchProvider = z.output<typeof liveSearchProviderSchema>
2424

25-
export const NOTION_SEARCH_TERMS_REQUIRED =
26-
'Notion requires search terms. Add keywords or a concise question.'
25+
export const SEARCH_TERMS_REQUIRED = {
26+
notion: 'Notion requires search terms. Add keywords or a concise question.',
27+
lucid: 'Lucid requires search terms. Add document-title keywords or a literal shape-text query.',
28+
} as const
2729

2830
/**
2931
* Native queries one call may send to the same provider account. Alternatives run as separate
@@ -82,11 +84,11 @@ export const nativeSearchQuerySchema = z
8284
message: `${input.provider} kind must be one of: ${kinds.options.join(', ')}.`,
8385
})
8486
}
85-
if (input.provider === 'notion' && !input.query)
87+
if ((input.provider === 'notion' || input.provider === 'lucid') && !input.query)
8688
context.addIssue({
8789
code: 'custom',
8890
path: ['query'],
89-
message: NOTION_SEARCH_TERMS_REQUIRED,
91+
message: SEARCH_TERMS_REQUIRED[input.provider],
9092
})
9193
})
9294
export type NativeSearchQuery = z.output<typeof nativeSearchQuerySchema>

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -287,20 +287,34 @@ describe('authorized live retrieval', () => {
287287
}
288288
)
289289
})
290-
it.each([
291-
{ startDate: '2026-08-01T00:00:00Z' },
292-
{ source: ' notion ', startDate: '2026-08-01T00:00:00Z' },
293-
{ sortBy: 'newest' as const },
294-
{ sortBy: 'oldest' as const },
295-
])('rejects a Notion-only live listing with %j', async (bound) => {
296-
await expect(
297-
searchLiveKnowledge.execute({
298-
principal,
299-
input: { ...input, query: ' \t ', filters: { source: 'notion', ...bound } },
290+
describe.each(['notion', 'lucid'] as const)('%s requires terms before dispatch', (provider) => {
291+
it.each([
292+
{ startDate: '2026-08-01T00:00:00Z' },
293+
{ source: ` ${provider} `, startDate: '2026-08-01T00:00:00Z' },
294+
{ sortBy: 'newest' as const },
295+
{ sortBy: 'oldest' as const },
296+
])('rejects a provider-only listing with %j', async (bound) => {
297+
await expect(
298+
searchLiveKnowledge.execute({
299+
principal,
300+
input: { ...input, query: ' \t ', filters: { source: provider, ...bound } },
301+
})
302+
).rejects.toMatchObject({ code: 'validation' })
303+
})
304+
it('rejects an empty native query even with a date bound', async () => {
305+
await expect(
306+
searchLiveKnowledge.execute({
307+
principal,
308+
input: {
309+
...input,
310+
query: 'topology',
311+
filters: { startDate: '2026-08-01T00:00:00Z' },
312+
nativeQueries: [{ provider, query: ' \t ' }],
313+
},
314+
})
315+
).rejects.toMatchObject({
316+
issues: expect.arrayContaining([expect.objectContaining({ path: [0, 'query'] })]),
300317
})
301-
).rejects.toMatchObject({
302-
code: 'validation',
303-
message: 'Notion requires search terms. Add keywords or a concise question.',
304318
})
305319
})
306320
it.each([undefined, 'google_drive'])(

‎apps/sim/lib/sim-search/live/application.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {
1010
type LiveSearchAccountStatus,
1111
liveSearchProviderSchema,
1212
type NativeSearchQuery,
13-
NOTION_SEARCH_TERMS_REQUIRED,
1413
nativeSearchQueriesSchema,
14+
SEARCH_TERMS_REQUIRED,
1515
workspaceSearchFiltersSchema,
1616
} from '@/lib/api/contracts/mothership-assistant-tools'
1717
import { canonicalJson, fingerprint, instantScopePart } from '@/lib/api/cursor-binding'
@@ -344,8 +344,12 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({
344344
)
345345
throw new OrchestrationError('validation', 'Invalid live search query or result limit')
346346
const filters = input.filters
347-
if (!queries && filters?.source === 'notion' && !input.query.trim())
348-
throw new OrchestrationError('validation', NOTION_SEARCH_TERMS_REQUIRED)
347+
if (
348+
!queries &&
349+
(filters?.source === 'notion' || filters?.source === 'lucid') &&
350+
!input.query.trim()
351+
)
352+
throw new OrchestrationError('validation', SEARCH_TERMS_REQUIRED[filters.source])
349353
if (
350354
filters?.startDate &&
351355
filters.endDate &&

‎apps/sim/lib/sim-search/live/lucid-mcp.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ function resource(value: string): { id: string; kind?: string } | undefined {
4141
}
4242
}
4343

44-
async function metadata(client: ManagedSearchMcpClient, id: string): Promise<NativeDocument> {
44+
async function metadata(
45+
client: ManagedSearchMcpClient,
46+
id: string
47+
): Promise<NativeDocument | undefined> {
4548
if (!UUID.test(id)) invalid('Invalid Lucid document identity.')
4649
const row = object(await client.call('lucid_get_document_metadata', { document_id: id }))
4750
const url = resource(string(row.viewUrl))
@@ -58,7 +61,7 @@ async function metadata(client: ManagedSearchMcpClient, id: string): Promise<Nat
5861
typeof row.lastModified !== 'string' ||
5962
!Number.isFinite(Date.parse(row.lastModified))
6063
)
61-
invalid('Lucid document metadata is incomplete, changed, or no longer readable.')
64+
return undefined
6265
return {
6366
id,
6467
kind: url.kind,
@@ -99,6 +102,7 @@ export async function searchLucidMcp(
99102
'Lucid document search requires a document UUID or Lucid URL and a literal text query.'
100103
)
101104
const document = await metadata(client, scope.id)
105+
if (!document) invalid('Lucid document metadata is incomplete or no longer readable.')
102106
if (
103107
(scope.kind && document.kind !== scope.kind) ||
104108
!products.some((product) => product === document.kind)
@@ -176,13 +180,16 @@ export async function searchLucidMcp(
176180
const limit = Math.max(1, Math.min(MAX_CANDIDATES, input.limit))
177181
const documents = await mapWithConcurrency(candidates.slice(0, limit), 3, async (candidate) => {
178182
const document = await metadata(client, candidate.id)
179-
if (document.kind !== candidate.kind) invalid('Lucid document product changed during search.')
183+
if (!document || document.kind !== candidate.kind) {
184+
dropped = true
185+
return undefined
186+
}
180187
return document
181188
})
182189
const capped = candidates.length > limit || result.results.length >= 200
183190
const localDates = hasDateBounds(input.filters) || Boolean(dateSortDirection(input.filters))
184191
return {
185-
documents,
192+
documents: documents.filter((document) => document !== undefined),
186193
hasMore: capped,
187194
partial: dropped || capped || localDates,
188195
message:
@@ -191,7 +198,7 @@ export async function searchLucidMcp(
191198
? ' Dates use current modification timestamps; sorting and the end-date filter cover only the retrieved candidates, not the entire account.'
192199
: '') +
193200
(capped ? ' The candidate limit was reached; narrow the title query.' : '') +
194-
(dropped ? ' Unsupported search results were excluded.' : ''),
201+
(dropped ? ' Unsupported or no-longer-readable search results were excluded.' : ''),
195202
}
196203
}
197204

@@ -225,6 +232,7 @@ export async function readLucidMcp(
225232
reference: Pick<NativeDocument, 'id' | 'kind' | 'revision'>
226233
): Promise<NativeDocument> {
227234
const before = await metadata(client, reference.id)
235+
if (!before) invalid('Lucid document metadata is incomplete or no longer readable.')
228236
if (
229237
(reference.kind && reference.kind !== before.kind) ||
230238
(reference.revision && reference.revision !== before.revision)
@@ -304,6 +312,7 @@ export async function readLucidMcp(
304312
}
305313
const after = await metadata(client, before.id)
306314
if (
315+
!after ||
307316
after.revision !== before.revision ||
308317
after.kind !== before.kind ||
309318
after.modifiedAt !== before.modifiedAt ||

‎apps/sim/scripts/test-search-lucid-e2e.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,15 @@ protocol.setRequestHandler(CallToolRequestSchema, async (request) => {
9393
if (mode === 'tool-error') return failed()
9494
if (name === 'lucid_get_document_metadata') {
9595
metadataReads++
96+
if (args.document_id === ID && mode === 'candidate-error') return failed()
97+
if (args.document_id === ID && mode === 'candidate-rate')
98+
return { ...failed(), content: [{ type: 'text' as const, text: 'Rate limit reached' }] }
9699
if (mode === 'revoked' && metadataReads > 1) return failed()
97100
return result({
98-
documentId: mode === 'metadata-id' ? OTHER_ID : args.document_id,
101+
documentId:
102+
mode === 'metadata-id' || (mode === 'stale-candidate' && args.document_id === ID)
103+
? OTHER_ID
104+
: args.document_id,
99105
title: TITLE,
100106
product: mode === 'spark' ? 'lucidspark' : 'lucidchart',
101107
viewUrl:
@@ -119,7 +125,12 @@ protocol.setRequestHandler(CallToolRequestSchema, async (request) => {
119125
assert(
120126
Object.keys(args).every((key) => ['query', 'product', 'last_modified_after'].includes(key))
121127
)
122-
const rowCount = mode === 'search-cap' ? 200 : 1
128+
const rowCount =
129+
mode === 'search-cap'
130+
? 200
131+
: ['stale-candidate', 'candidate-error', 'candidate-rate'].includes(mode)
132+
? 2
133+
: 1
123134
return result({
124135
query: args.query,
125136
results:
@@ -387,6 +398,31 @@ try {
387398
padding += 'x'
388399
await assert.rejects(read, /512 KiB/)
389400
})
401+
await check(
402+
'A stale candidate cannot discard another independently readable document',
403+
async () => {
404+
mode = 'stale-candidate'
405+
const page = await search()
406+
assert.deepEqual(
407+
page.documents.map((document) => document.id),
408+
[OTHER_ID]
409+
)
410+
assert.equal(page.partial, true)
411+
assert.match(page.message ?? '', /excluded/i)
412+
}
413+
)
414+
for (const [failure, status] of [
415+
['candidate-error', 'unavailable'],
416+
['candidate-rate', 'rate_limited'],
417+
] as const) {
418+
await check(`Candidate provider failure ${status} remains terminal`, async () => {
419+
mode = failure
420+
await assert.rejects(
421+
() => search(),
422+
(error: unknown) => error instanceof NativeSearchError && error.status === status
423+
)
424+
})
425+
}
390426
await check('Capped title search discloses coverage without inventing a cursor', async () => {
391427
mode = 'search-cap'
392428
const page = await search()

0 commit comments

Comments
 (0)