diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
index c95228c963c..3047b9a79de 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
@@ -1,12 +1,13 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
-import { Chip } from '@sim/emcn'
+import { Chip, ChipLink } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
import { ActivityStatus } from '@/components/ui/activity-status'
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
import { useSession } from '@/lib/auth/client'
import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
+import { organizationRoutes } from '@/lib/navigation/paths'
import { SearchFilters } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters'
import {
groupResultsByDocument,
@@ -150,6 +151,25 @@ function LiveSearchResults({
Search found no results.
)}
+ {!isFetching && data?.live && accounts.length === 0 && (
+
+
+ {filters.source
+ ? 'You haven’t connected this app.'
+ : 'Connect your apps to search them.'}
+
+
+ Connect apps
+
+
+ )}
{accounts.some((account) =>
['unavailable', 'timeout', 'rate_limited', 'reconnect'].includes(account.status)
) && (
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 8ae1042bd73..ae675215799 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
@@ -246,6 +246,19 @@ describe('Assistant retrieval tools', () => {
)
expect(JSON.stringify(result)).not.toContain(secret)
})
+ it('tells the model a search no account served searched nothing, not to narrow and retry', async () => {
+ mocks.search.mockResolvedValueOnce({
+ query: 'roadmap',
+ retrieval: { status: 'partial', timedOutLegs: [] },
+ results: [],
+ live: { backend: 'live', accounts: [], guidance: 'Nothing was searched: connect one.' },
+ })
+ const result = await searchWorkspaceServerTool.execute({ query: 'roadmap' }, context)
+ expect(result).toMatchObject({
+ success: true,
+ message: expect.stringMatching(/^Nothing was searched: (?!.*more specific query)/),
+ })
+ })
it('rejects untrusted contexts, incompatible sources and out-of-scope document reads', async () => {
expect(
await searchWorkspaceServerTool.execute(
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 3b04bcb713c..4da4825ad9d 100644
--- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts
+++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts
@@ -85,9 +85,13 @@ export const searchWorkspaceServerTool: BaseServerTool = {
...liveInput,
workspaceId: scope.workspaceId,
})
+ /** Every searched account reports a status, so none means no account could serve it. */
+ const nothingSearched = data.live?.accounts.length === 0
return {
success: true,
- message: `${data.retrieval.status === 'partial' ? 'Search coverage is incomplete. Continue with a more specific query or source filter; these results cannot establish absence or completeness. ' : ''}Found ${data.results.length} live results. Read a documentId when its passage does not answer the question or more context is needed. ${CITATION_INSTRUCTION}`,
+ message: nothingSearched
+ ? 'Nothing was searched: no connected account can serve this search, so it says nothing about what exists. Follow the first line of live.guidance instead of retrying the same search.'
+ : `${data.retrieval.status === 'partial' ? 'Search coverage is incomplete. Continue with a more specific query or source filter; these results cannot establish absence or completeness. ' : ''}Found ${data.results.length} live results. Read a documentId when its passage does not answer the question or more context is needed. ${CITATION_INSTRUCTION}`,
data: {
...data,
results: data.results.map((item) => ({
diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts
index b69d53ff555..5bed7b39f05 100644
--- a/apps/sim/lib/sim-search/live/application.test.ts
+++ b/apps/sim/lib/sim-search/live/application.test.ts
@@ -666,6 +666,28 @@ describe('authorized live retrieval', () => {
expect(result.live?.accounts.map((status) => status.queryIndex)).toEqual([0, 1])
expect(result.live?.guidance).toBe('Live coverage: slack')
})
+ it('reports a search that reached no account as nothing searched, not as no results', async () => {
+ mocks.accounts.mockResolvedValue([])
+ const result = await searchLiveKnowledge.execute({ principal, input })
+ expect(mocks.search).not.toHaveBeenCalled()
+ expect(result.results).toEqual([])
+ expect(result.retrieval.status).toBe('partial')
+ expect(result.live?.accounts).toEqual([])
+ expect(result.live?.guidance).toMatch(
+ /^Nothing was searched: you have no connected sources here\. Connect your accounts at \S*\/workspace\/workspace\/integrations,/
+ )
+ })
+ it('names the filtered source when only other sources are connected', async () => {
+ const result = await searchLiveKnowledge.execute({
+ principal,
+ input: { ...input, filters: { source: 'slack' } },
+ })
+ expect(mocks.search).not.toHaveBeenCalled()
+ expect(result.retrieval.status).toBe('partial')
+ expect(result.live?.guidance).toMatch(
+ /^Nothing was searched: you have no connected slack account here\. .* or search without the source filter\./
+ )
+ })
describe('result previews', () => {
const excerpts = ['a', 'b', 'c', 'd'].map((id) => ({
...document,
diff --git a/apps/sim/lib/sim-search/live/application.ts b/apps/sim/lib/sim-search/live/application.ts
index 78e9817b53f..309c92ddb54 100644
--- a/apps/sim/lib/sim-search/live/application.ts
+++ b/apps/sim/lib/sim-search/live/application.ts
@@ -32,6 +32,7 @@ import {
mapWithConcurrency,
waitWithAbort,
} from '@/lib/core/utils/concurrency'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors'
import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability'
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
@@ -49,6 +50,7 @@ import {
import { RRF_K } from '@/lib/knowledge/search/recency'
import { matchPassage } from '@/lib/knowledge/search/snippet'
import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url'
+import { organizationRoutes } from '@/lib/navigation/paths'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
import {
type LiveAccountSession,
@@ -440,6 +442,24 @@ function compareAccounts(left: LiveAccount, right: LiveAccount): number {
)
}
+/**
+ * Why a plain search that reached no account returned nothing, and where the caller connects one;
+ * an empty result would otherwise read as a search that found nothing.
+ */
+function nothingSearchedGuidance(
+ owner: ResourceOwner,
+ source: string | undefined,
+ hasOtherAccounts: boolean
+): string {
+ const path = owner.organizationId
+ ? organizationRoutes(owner.organizationId).integrations
+ : `/workspace/${owner.workspaceId}/integrations`
+ const connect = new URL(path, getBaseUrl()).href
+ return source && hasOtherAccounts
+ ? `Nothing was searched: you have no connected ${source} account here. Connect one at ${connect}, or search without the source filter.`
+ : `Nothing was searched: you have no connected sources here. Connect your accounts at ${connect}, then search again.`
+}
+
const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
operation: knowledgeOperations.search,
resolveContext: ({ input }: { input: LiveSearchInput }) => resolveKnowledgeOwnerContext(input),
@@ -503,6 +523,7 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
nativesFor(account).length > 0
)
.sort(compareAccounts)
+ const nothingSearched = !queries && eligible.length === 0
const selected = eligible.slice(0, MAX_ACCOUNTS)
const openingAccount = createConcurrencyLimit(ACCOUNT_OPEN_CONCURRENCY)
annotateSearchDiagnostics({
@@ -909,9 +930,14 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
}
})
/** Live coverage guides the providers this call searched or tried to search. */
- const guidance = liveSearchGuidance(
- accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider)
- )
+ const guidance = [
+ ...(nothingSearched
+ ? [nothingSearchedGuidance(input, filters?.source, allAccounts.length > 0)]
+ : []),
+ liveSearchGuidance(
+ accounts.filter((account) => account.status !== 'reconnect').map(({ provider }) => provider)
+ ),
+ ].join('\n')
for (const account of eligible.slice(MAX_ACCOUNTS)) {
for (const { queryIndex } of nativesFor(account))
accounts.push({
@@ -934,9 +960,10 @@ const liveKnowledgeSearch = defineAuthorizedKnowledgeUseCase({
message: 'No connection with this provider is configured and approved in this scope.',
})
}
- const retrievalStatus = accounts.some((account) => account.status !== 'ok')
- ? 'partial'
- : 'complete'
+ const retrievalStatus =
+ nothingSearched || accounts.some((account) => account.status !== 'ok')
+ ? 'partial'
+ : 'complete'
annotateSearchDiagnostics({
resultCount: Math.min(ranked.length, input.topK),
retrievalStatus,
diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts
index 7a6fe15f8a3..df145b026e7 100644
--- a/apps/sim/lib/sim-search/live/atlassian.test.ts
+++ b/apps/sim/lib/sim-search/live/atlassian.test.ts
@@ -244,6 +244,13 @@ describe('Jira any-term search', () => {
const page = await searchAtlassian(api, 'confluence', { ...query, textMatch: 'any' })
expect(page.documents.map((document) => document.id)).toEqual(['5'])
})
+
+ it('searches nothing for a Confluence query with no word left, which would list every page', async () => {
+ const api = client({})
+ const page = await searchAtlassian(api, 'confluence', { ...query, query: String.raw` :\ ` })
+ expect(page.documents).toEqual([])
+ expect(api.json).not.toHaveBeenCalled()
+ })
})
describe('Confluence plain search ranking', () => {
diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts
index 4283b3eddf2..aae29c76799 100644
--- a/apps/sim/lib/sim-search/live/atlassian.ts
+++ b/apps/sim/lib/sim-search/live/atlassian.ts
@@ -132,6 +132,12 @@ export async function searchAtlassian(
provider: 'jira' | 'confluence',
input: NativeSearchInput
): Promise {
+ const text = nativeText(input)
+ const confluenceClause =
+ provider === 'confluence' && text && !input.native?.query
+ ? confluenceTextClause(text, input.textMatch)
+ : ''
+ if (confluenceClause === undefined) return { documents: [] }
const allSites = (await sites(client)).filter(
(site) =>
!input.policy?.sites.length ||
@@ -166,7 +172,6 @@ export async function searchAtlassian(
.join(' AND ') || undefined
const direction = dateSortDirection(input.filters)
const order = direction ? `${field} ${direction.toUpperCase()}` : undefined
- const text = nativeText(input)
// Jira's any-term JQL matches issues sharing two common words; like minimum-should-match,
// keep only issues holding at least half the terms.
const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined
@@ -234,13 +239,15 @@ export async function searchAtlassian(
}
}
const exact = await searchPages(
- text ? confluenceTextClause(text, input.textMatch) : '',
+ confluenceClause,
input.native?.cursor && single ? input.native.cursor : undefined
)
+ const relevance = confluenceTextClause(text, 'any')
// An all-terms match larger than a page comes back by raw term matches; rank it by relevance.
- if (!text || input.native || order || input.textMatch === 'any' || !exact.next) return exact
+ if (!relevance || input.native || order || input.textMatch === 'any' || !exact.next)
+ return exact
try {
- const ranked = await searchPages(confluenceTextClause(text, 'any'))
+ const ranked = await searchPages(relevance)
return { ...ranked, next: undefined, ranking: 'relevance' }
} catch {
// A cancelled search stops here; any other failure keeps the all-terms page.
diff --git a/apps/sim/lib/sim-search/live/service-session.ts b/apps/sim/lib/sim-search/live/service-session.ts
index df68caa9a5a..b30ce33124b 100644
--- a/apps/sim/lib/sim-search/live/service-session.ts
+++ b/apps/sim/lib/sim-search/live/service-session.ts
@@ -146,6 +146,7 @@ export async function createLiveServiceSession(input: {
const query =
search.native?.query ??
(search.query ? confluenceTextClause(search.query, search.textMatch) : '')
+ if (query === undefined) return null
return {
...search,
native: { provider, ...search.native, query: scopeAtlassianQuery(query, scope) },
diff --git a/apps/sim/lib/sim-search/live/text-match.test.ts b/apps/sim/lib/sim-search/live/text-match.test.ts
index bd05d59e34c..82ac1b41003 100644
--- a/apps/sim/lib/sim-search/live/text-match.test.ts
+++ b/apps/sim/lib/sim-search/live/text-match.test.ts
@@ -59,6 +59,16 @@ describe('text match clauses', () => {
)
})
+ it('sends a colon or backslash to Confluence as a word break, which it otherwise rejects', () => {
+ expect(confluenceTextClause('Security: Weekly Report', 'all')).toBe(
+ 'text ~ "Security Weekly Report"'
+ )
+ expect(confluenceTextClause(String.raw`C:\builds\out`, 'any')).toBe(
+ 'siteSearch ~ "C builds out"'
+ )
+ expect(confluenceTextClause(String.raw` :\ `, 'all')).toBeUndefined()
+ })
+
it('falls back to the all-terms clause when fewer than two terms are distinctive', () => {
expect(jiraTextClause('how does it work', 'any')).toBe('text ~ "how does it work"')
})
diff --git a/apps/sim/lib/sim-search/live/text-match.ts b/apps/sim/lib/sim-search/live/text-match.ts
index c5dd8958455..c8a40699012 100644
--- a/apps/sim/lib/sim-search/live/text-match.ts
+++ b/apps/sim/lib/sim-search/live/text-match.ts
@@ -150,9 +150,16 @@ export function jiraTextClause(text: string, match: TextMatch = 'all'): string {
* The text clause for a plain query in Confluence CQL. `any` uses `siteSearch`, Confluence's own
* relevance search, which ranks a page with the query's rarer words above the many pages sharing
* one common word, where a `text ~` OR query ranks them the other way round.
+ *
+ * Confluence answers HTTP 400 to the escaped colon or backslash this phrase would carry, and its
+ * analyzer splits words on both, so they go as spaces. `undefined` means nothing is left to match:
+ * Confluence would list every page for such a phrase, so callers search nothing instead.
*/
-export function confluenceTextClause(text: string, match: TextMatch = 'all'): string {
- return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(text)}`
+export function confluenceTextClause(text: string, match: TextMatch = 'all'): string | undefined {
+ const searchable = text.replace(/[:\\]/g, ' ').trim()
+ return searchable
+ ? `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
+ : undefined
}
/** How many distinct terms occur as whole words in the text. */
diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts
index a4a6b7bbe53..67f8350052e 100644
--- a/apps/sim/scripts/test-organization-search-e2e.ts
+++ b/apps/sim/scripts/test-organization-search-e2e.ts
@@ -445,7 +445,15 @@ try {
const body = toRecord(response.request().postDataJSON())
assert.equal(body.organizationId, organizationId)
assert.equal(body.query, 'Launch notes')
- await expect(page.getByText('Search found no results.', { exact: true })).toBeVisible()
+ // The seeded member has connected no account, so nothing was searched.
+ await expect(
+ page.getByText('Connect your apps to search them.', { exact: true })
+ ).toBeVisible()
+ await expect(page.getByText('Search found no results.', { exact: true })).toHaveCount(0)
+ await expect(page.getByRole('link', { name: 'Connect apps' })).toHaveAttribute(
+ 'href',
+ `${root}/integrations`
+ )
await expect(page).toHaveURL(
(url) => url.pathname === `${root}/search` && url.searchParams.get('q') === 'Launch notes'
)