From 331f83d58a1479df68ae91ff7a39605c8a8b39b3 Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Wed, 4 Nov 2026 17:55:43 -0800
Subject: [PATCH 1/3] fix(search): keep Confluence searchable for colon queries
and say when nothing was searched
Confluence rejects a CQL text query holding a colon or backslash with HTTP 400,
even escaped, which dropped it from those searches; both now go as word breaks.
A plain live search that reaches no connected account is reported as partial
with guidance naming where to connect one, and the results view offers the
connection instead of claiming no results.
---
.../knowledge-search-results.tsx | 22 ++++++++++-
.../lib/sim-search/live/application.test.ts | 22 +++++++++++
apps/sim/lib/sim-search/live/application.ts | 39 ++++++++++++++++---
.../lib/sim-search/live/text-match.test.ts | 10 +++++
apps/sim/lib/sim-search/live/text-match.ts | 7 +++-
5 files changed, 92 insertions(+), 8 deletions(-)
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/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/text-match.test.ts b/apps/sim/lib/sim-search/live/text-match.test.ts
index bd05d59e34c..aa4e3bef9dd 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')).toBe('')
+ })
+
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..75629e74681 100644
--- a/apps/sim/lib/sim-search/live/text-match.ts
+++ b/apps/sim/lib/sim-search/live/text-match.ts
@@ -150,9 +150,14 @@ 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 a text query holding a colon or backslash, even escaped, and its
+ * analyzer splits words on both, so they go as spaces. Nothing left to match means no clause.
*/
export function confluenceTextClause(text: string, match: TextMatch = 'all'): string {
- return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(text)}`
+ const searchable = text.replace(/[:\\]/g, ' ').trim()
+ if (!searchable) return ''
+ return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
}
/** How many distinct terms occur as whole words in the text. */
From 190dac4f58cf5b0929ed46647c943b738151615d Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Wed, 4 Nov 2026 18:07:05 -0800
Subject: [PATCH 2/3] fix(search): search nothing for a Confluence query with
no words and stop the Assistant retrying unsearchable searches
A query left with no words after the colon and backslash become spaces would
list every Confluence page, so it now searches nothing on both the member and
service-account paths. The Assistant search tool reports a search no account
served as nothing searched instead of advising a narrower retry, and the
organization search E2E asserts the connect prompt the unconnected member sees.
---
.../server/knowledge/workspace-search.test.ts | 13 +++++++++++++
.../tools/server/knowledge/workspace-search.ts | 6 +++++-
apps/sim/lib/sim-search/live/atlassian.test.ts | 7 +++++++
apps/sim/lib/sim-search/live/atlassian.ts | 15 +++++++++++----
apps/sim/lib/sim-search/live/service-session.ts | 1 +
apps/sim/lib/sim-search/live/text-match.test.ts | 2 +-
apps/sim/lib/sim-search/live/text-match.ts | 12 +++++++-----
apps/sim/scripts/test-organization-search-e2e.ts | 10 +++++++++-
8 files changed, 54 insertions(+), 12 deletions(-)
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..6af7171a817 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 (connect an account, or drop the source filter) 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/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 aa4e3bef9dd..82ac1b41003 100644
--- a/apps/sim/lib/sim-search/live/text-match.test.ts
+++ b/apps/sim/lib/sim-search/live/text-match.test.ts
@@ -66,7 +66,7 @@ describe('text match clauses', () => {
expect(confluenceTextClause(String.raw`C:\builds\out`, 'any')).toBe(
'siteSearch ~ "C builds out"'
)
- expect(confluenceTextClause(String.raw` :\ `, 'all')).toBe('')
+ expect(confluenceTextClause(String.raw` :\ `, 'all')).toBeUndefined()
})
it('falls back to the all-terms clause when fewer than two terms are distinctive', () => {
diff --git a/apps/sim/lib/sim-search/live/text-match.ts b/apps/sim/lib/sim-search/live/text-match.ts
index 75629e74681..c8a40699012 100644
--- a/apps/sim/lib/sim-search/live/text-match.ts
+++ b/apps/sim/lib/sim-search/live/text-match.ts
@@ -151,13 +151,15 @@ export function jiraTextClause(text: string, match: TextMatch = 'all'): string {
* 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 a text query holding a colon or backslash, even escaped, and its
- * analyzer splits words on both, so they go as spaces. Nothing left to match means no clause.
+ * 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 {
+export function confluenceTextClause(text: string, match: TextMatch = 'all'): string | undefined {
const searchable = text.replace(/[:\\]/g, ' ').trim()
- if (!searchable) return ''
- return `${match === 'any' ? 'siteSearch' : 'text'} ~ ${escapeSearchPhrase(searchable)}`
+ 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'
)
From 499d9850dbe564fec3e3c74845e049cdd7560245 Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Wed, 4 Nov 2026 18:13:27 -0800
Subject: [PATCH 3/3] fix(search): let the guidance alone say how to recover
from a search nothing served
---
.../lib/mothership/tools/server/knowledge/workspace-search.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 6af7171a817..4da4825ad9d 100644
--- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts
+++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts
@@ -90,7 +90,7 @@ export const searchWorkspaceServerTool: BaseServerTool = {
return {
success: true,
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 (connect an account, or drop the source filter) instead of retrying the same search.'
+ ? '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,