From 410b4f3c47e0c438ca600f91c857a708c9a0c211 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 27 Sep 2026 18:05:54 -0700 Subject: [PATCH] fix(search): validate Notion search terms before dispatch --- .../contracts/mothership-assistant-tools.ts | 15 +++++++++-- .../sim-assistant-tools.generated.ts | 15 +++++++++-- .../mothership/generated/tool-catalog-v1.ts | 4 +-- .../mothership/generated/tool-schemas-v1.ts | 4 +-- .../server/knowledge/workspace-search.test.ts | 19 +++++++++++++ .../server/knowledge/workspace-search.ts | 3 ++- .../lib/sim-search/live/application.test.ts | 27 +++++++++++++++++++ apps/sim/lib/sim-search/live/application.ts | 3 +++ apps/sim/lib/sim-search/live/providers.ts | 2 +- 9 files changed, 82 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts index 3d6c3f18ed7..3441e477f52 100644 --- a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts +++ b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts @@ -4,6 +4,9 @@ import { LIVE_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog export const liveSearchProviderSchema = z.enum(LIVE_SEARCH_PROVIDER_IDS) export type LiveSearchProvider = z.output +export const NOTION_SEARCH_TERMS_REQUIRED = + 'Notion requires search terms. Add keywords or a concise question.' + /** * Native queries one call may send to the same provider account. Alternatives run as separate * provider searches and fuse into one ranking, so the bound keeps a call within the provider's @@ -42,6 +45,14 @@ export const nativeSearchQuerySchema = z keywordOnly: z.boolean().optional(), }) .strict() + .superRefine((input, context) => { + if (input.provider === 'notion' && !input.query) + context.addIssue({ + code: 'custom', + path: ['query'], + message: NOTION_SEARCH_TERMS_REQUIRED, + }) + }) export type NativeSearchQuery = z.output export const nativeSearchQueriesSchema = z @@ -161,7 +172,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema nativeQueries: nativeSearchQueriesSchema .optional() .describe( - `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` + `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` ), query: z .string() @@ -169,7 +180,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema .max(2000) .default('') .describe( - 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest.' + 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest where supported; Notion requires search terms.' ), topK: z .number() diff --git a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts index 056b1a9c44a..c807fbc0035 100644 --- a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts +++ b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts @@ -20,6 +20,9 @@ export const liveSearchProviderSchema = z.enum([ ]) export type LiveSearchProvider = z.output +export const NOTION_SEARCH_TERMS_REQUIRED = + 'Notion requires search terms. Add keywords or a concise question.' + /** * Native queries one call may send to the same provider account. Alternatives run as separate * provider searches and fuse into one ranking, so the bound keeps a call within the provider's @@ -58,6 +61,14 @@ export const nativeSearchQuerySchema = z keywordOnly: z.boolean().optional(), }) .strict() + .superRefine((input, context) => { + if (input.provider === 'notion' && !input.query) + context.addIssue({ + code: 'custom', + path: ['query'], + message: NOTION_SEARCH_TERMS_REQUIRED, + }) + }) export type NativeSearchQuery = z.output export const nativeSearchQueriesSchema = z @@ -177,7 +188,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema nativeQueries: nativeSearchQueriesSchema .optional() .describe( - `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` + `Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to ${MAX_NATIVE_QUERIES_PER_ACCOUNT} per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.` ), query: z .string() @@ -185,7 +196,7 @@ export const searchWorkspaceInputSchema = workspaceSearchFiltersSchema .max(2000) .default('') .describe( - 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest.' + 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest where supported; Notion requires search terms.' ), topK: z .number() diff --git a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts index 541632ef94c..ad38b8801be 100644 --- a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts @@ -6096,7 +6096,7 @@ export const SearchWorkspace: ToolCatalogEntry = { }, nativeQueries: { description: - "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", + "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", minItems: 1, maxItems: 9, type: 'array', @@ -6140,7 +6140,7 @@ export const SearchWorkspace: ToolCatalogEntry = { query: { default: '', description: - 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest.', + 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest where supported; Notion requires search terms.', type: 'string', maxLength: 2000, }, diff --git a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts index b66465ceecd..ea4eb8e9c12 100644 --- a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts @@ -6044,7 +6044,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, nativeQueries: { description: - "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", + "Live search only: queries in a provider's own language (Drive q, Gmail operators, JQL, CQL, GitHub qualifiers, Slack RTS, plain Linear/Fireflies terms, Granola natural-language questions, Notion keywords or AI questions when available). Notion requires nonempty search terms even with dates or sorting. Up to 4 per account run separately and merge; GitHub and GitLab take one per kind. Write them from the returned live guidance and account IDs; each account status names the queryIndex its cursor belongs to. Omit for simple cross-provider terms.", minItems: 1, maxItems: 9, type: 'array', @@ -6114,7 +6114,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { query: { default: '', description: - 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest.', + 'Search terms, without dates already supplied as filters. May be empty for a live listing with a date bound or sortBy newest or oldest where supported; Notion requires search terms.', type: 'string', maxLength: 2000, }, diff --git a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts index aea763d0c6c..2a44b3e3608 100644 --- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts @@ -90,6 +90,25 @@ describe('Assistant retrieval tools', () => { next: null, }) }) + it.each([{ startDate: '2026-09-01T00:00:00Z' }, { sortBy: 'newest' }, { sortBy: 'oldest' }])( + 'returns actionable validation for empty Notion native queries with %j', + async (bound) => { + setEnvFlags({ isLiveEnterpriseSearchEnabled: true }) + const result = await searchWorkspaceServerTool.execute( + { + ...bound, + query: 'fallback terms', + nativeQueries: [{ provider: 'notion', query: ' \t ' }], + }, + { ...context, assistantSearch: undefined } + ) + expect(result).toMatchObject({ + success: false, + message: 'Notion requires search terms. Add keywords or a concise question.', + }) + expect(result).not.toHaveProperty('data') + } + ) it('returns a safe permanent configuration failure instead of empty results or opaque error', async () => { mocks.search.mockRejectedValue(new EmbeddingConfigurationError()) const result = await searchWorkspaceServerTool.execute({ query: 'policy' }, context) diff --git a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts index f1e205bc93a..e50144f149a 100644 --- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts @@ -4,6 +4,7 @@ import { readDocumentInputSchema, searchWorkspaceInputSchema, } from '@/lib/api/contracts/mothership-assistant-tools' +import { getValidationErrorMessage } from '@/lib/api/server/validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { EmbeddingConfigurationError } from '@/lib/embeddings/configuration-error' import { sourceAuthor } from '@/lib/knowledge/search/author' @@ -211,7 +212,7 @@ export const searchWorkspaceServerTool: BaseServerTool = { error instanceof SearchDeadlineError ? error.message : error instanceof z.ZodError - ? 'Invalid search arguments' + ? getValidationErrorMessage(error, 'Invalid search arguments') : messageForCopilotKnowledgeError(error), } } diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts index 0108c2fa6ac..f6eeefac31c 100644 --- a/apps/sim/lib/sim-search/live/application.test.ts +++ b/apps/sim/lib/sim-search/live/application.test.ts @@ -124,6 +124,33 @@ describe('authorized live retrieval', () => { }) mocks.adminVerify.mockResolvedValue(true) }) + it.each([ + { startDate: '2026-08-01T00:00:00Z' }, + { source: ' notion ', startDate: '2026-08-01T00:00:00Z' }, + { sortBy: 'newest' as const }, + { sortBy: 'oldest' as const }, + ])('rejects a Notion-only live listing with %j', async (bound) => { + await expect( + searchLiveKnowledge.execute({ + principal, + input: { ...input, query: ' \t ', filters: { source: 'notion', ...bound } }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Notion requires search terms. Add keywords or a concise question.', + }) + }) + it.each([undefined, 'google_drive'])( + 'preserves date-only live results with source %s', + async (source) => { + const result = await searchLiveKnowledge.execute({ + principal, + input: { ...input, query: '', filters: { source, startDate: '2026-08-01T00:00:00Z' } }, + }) + expect(result.results.map((row) => decodeLiveReference(row.documentId).id)).toEqual(['doc']) + expect(result.live?.accounts[0]?.status).toBe('ok') + } + ) it('filters admin-token GitLab results through the reader ACL before projection', async () => { const gitlab = { ...account, diff --git a/apps/sim/lib/sim-search/live/application.ts b/apps/sim/lib/sim-search/live/application.ts index f4bd5da9229..968ac7e2253 100644 --- a/apps/sim/lib/sim-search/live/application.ts +++ b/apps/sim/lib/sim-search/live/application.ts @@ -10,6 +10,7 @@ import { type LiveSearchAccountStatus, liveSearchProviderSchema, type NativeSearchQuery, + NOTION_SEARCH_TERMS_REQUIRED, nativeSearchQueriesSchema, workspaceSearchFiltersSchema, } from '@/lib/api/contracts/mothership-assistant-tools' @@ -312,6 +313,8 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({ if (input.filters) input = { ...input, filters: workspaceSearchFiltersSchema.parse(input.filters) } const filters = input.filters + if (!queries && filters?.source === 'notion' && !input.query.trim()) + throw new OrchestrationError('validation', NOTION_SEARCH_TERMS_REQUIRED) if ( filters?.startDate && filters.endDate && diff --git a/apps/sim/lib/sim-search/live/providers.ts b/apps/sim/lib/sim-search/live/providers.ts index 3c4f8463a42..6c9a4f0db3d 100644 --- a/apps/sim/lib/sim-search/live/providers.ts +++ b/apps/sim/lib/sim-search/live/providers.ts @@ -216,7 +216,7 @@ export const LIVE_SEARCH_PROVIDERS = { transport: 'managed_mcp', guide: { syntax: - 'Natural-language or plain keyword content search through Notion MCP. Availability depends on the connected account and plan; results are restricted to Notion pages, excluding connected apps.', + 'Natural-language or plain keyword content search through Notion MCP. Search terms are required even with dates or sorting. Availability depends on the connected account and plan; results are restricted to Notion pages, excluding connected apps.', scope: 'project optionally takes a known Notion page URL when the advertised tool supports page scoping. Dates use explicit last-edited timestamps; results without those timestamps cannot satisfy date filters. Read a result for page content.', example: 'deployment rollback checklist',