From 4a02a4ffb6484a646e94633fd5f4b847f3b23a00 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 13 Oct 2026 16:25:49 -0700 Subject: [PATCH 1/7] improvement(search): rank an overflowing Confluence all-terms match with relevance search A plain Confluence query runs an all-terms text ~ search. When more pages match every word than fit on one page, Confluence orders them by raw term matches, so on a large site the right page is often not on the page returned. That page is now fetched from siteSearch, the relevance search behind Confluence's own search box, which weights titles and rarer words. An all-terms match that fits on one page, date-sorted listings, and native CQL are unchanged; the underfilled case keeps its existing relevance fallback. A relevance-ranked page reports hasMore without a cursor, since a cursor would continue a different query. --- .../sim/lib/sim-search/live/atlassian.test.ts | 53 +++++++++++++++ apps/sim/lib/sim-search/live/atlassian.ts | 68 ++++++++++++------- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index 4168a50fcfb..85a8935526c 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -245,3 +245,56 @@ describe('Jira any-term search', () => { expect(page.documents.map((document) => document.id)).toEqual(['5']) }) }) + +describe('Confluence plain search ranking', () => { + const search = '/ex/confluence/cloud/wiki/rest/api/search' + /** Answers the all-terms query with `exact` and the relevance query with `ranked`. */ + function confluence(exact: { ids: string[]; more: boolean }, ranked: string[]) { + const results = (ids: string[]) => + ids.map((id) => ({ content: { id, type: 'page', title: id } })) + const json = vi.fn(async (path: string, options?: { query?: Record }) => { + if (path === '/oauth/token/accessible-resources') return [SITE] + if (path !== search) throw new Error(`Unexpected request: ${path}`) + if (options?.query?.cql?.includes('siteSearch ~')) + return { results: results(ranked), _links: {} } + return { + results: results(exact.ids), + _links: exact.more ? { next: `${search}?cursor=page-2` } : {}, + } + }) + return { json, text: vi.fn(), bytes: vi.fn() } satisfies NativeClient + } + const query = { query: 'merge train service', limit: 2, scopes: [] } + + it('ranks an all-terms match too large for one page with relevance search', async () => { + const api = confluence({ ids: ['old-1', 'old-2'], more: true }, ['best', 'next']) + const page = await searchAtlassian(api, 'confluence', query) + expect(page.documents.map((document) => document.id)).toEqual(['best', 'next']) + expect(page.hasMore).toBe(true) + expect(page.nextCursor).toBeUndefined() + }) + + it('keeps an all-terms match that fits on one page', async () => { + const api = confluence({ ids: ['exact'], more: false }, ['loose']) + const page = await searchAtlassian(api, 'confluence', query) + expect(page.documents.map((document) => document.id)).toEqual(['exact']) + expect(api.json).toHaveBeenCalledTimes(2) + }) + + it('keeps date order and native queries on the all-terms page', async () => { + const dated = confluence({ ids: ['newest', 'older'], more: true }, ['best']) + const byDate = await searchAtlassian(dated, 'confluence', { + ...query, + filters: { sortBy: 'newest' }, + }) + expect(byDate.documents.map((document) => document.id)).toEqual(['newest', 'older']) + expect(byDate.nextCursor).toBe('page-2') + const native = confluence({ ids: ['a', 'b'], more: true }, ['best']) + const own = await searchAtlassian(native, 'confluence', { + ...query, + native: { provider: 'confluence', query: 'text ~ "merge train"' }, + }) + expect(own.documents.map((document) => document.id)).toEqual(['a', 'b']) + expect(own.nextCursor).toBe('page-2') + }) +}) diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index 86721de66c2..49e1b5befd2 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -193,33 +193,46 @@ export async function searchAtlassian( next: string(data.nextPageToken) || undefined, } } - const data = object( - await client.json(`/ex/confluence/${segment(cloudId)}/wiki/rest/api/search`, { - query: { - cql: scopeAtlassianQuery( - input.native?.query || - `type IN (page, blogpost)${text ? ` AND ${confluenceTextClause(text, input.textMatch)}` : ''}`, - scope, - order - ), - limit: String(input.limit), - expand: 'content.version,content.space', - ...(input.native?.cursor && single ? { cursor: input.native.cursor } : {}), - }, - signal: input.signal, - }) + const searchPages = async (clause: string, cursor?: string) => { + const data = object( + await client.json(`/ex/confluence/${segment(cloudId)}/wiki/rest/api/search`, { + query: { + cql: scopeAtlassianQuery( + input.native?.query || `type IN (page, blogpost)${clause ? ` AND ${clause}` : ''}`, + scope, + order + ), + limit: String(input.limit), + expand: 'content.version,content.space', + ...(cursor ? { cursor } : {}), + }, + signal: input.signal, + }) + ) + const next = string(object(data._links).next) + const rows = array(data.results) + const documents = rows + .map((row) => page(row, cloudId, origin)) + .filter((document) => document !== undefined) + return { + documents, + excluded: documents.length < rows.length, + next: next + ? (new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined) + : undefined, + relevanceRanked: false, + } + } + const exact = await searchPages( + text ? confluenceTextClause(text, input.textMatch) : '', + input.native?.cursor && single ? input.native.cursor : undefined ) - const next = string(object(data._links).next) - const rows = array(data.results) - const documents = rows - .map((row) => page(row, cloudId, origin)) - .filter((document) => document !== undefined) + // 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 return { - documents, - excluded: documents.length < rows.length, - next: next - ? (new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined) - : undefined, + ...(await searchPages(confluenceTextClause(text, 'any'))), + next: undefined, + relevanceRanked: true, } }) ) @@ -228,12 +241,15 @@ export async function searchAtlassian( return { documents, partial: excluded || (!input.native?.project && allSites.length > selected.length), - hasMore: pages.some((result) => Boolean(result.next)), + hasMore: pages.some((result) => Boolean(result.next) || result.relevanceRanked), nextCursor: single ? pages[0]?.next : undefined, message: 'Searches up to four accessible Atlassian sites. For a specific site and pagination, set project to its cloud ID.' + (excluded ? ' Matches other than pages, blog posts, and spaces (attachments, comments, whiteboards, folders, databases) were excluded because they cannot be read.' + : '') + + (pages.some((result) => result.relevanceRanked) + ? ' More pages contained every word than fit on one page, so this page is ordered by Confluence relevance search; refine the query, or page with a native CQL query.' : ''), } } From 7e02baebd95a3f2f51df08ed98e505593c7609d2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 13 Oct 2026 17:22:01 -0700 Subject: [PATCH 2/7] fix(search): give Jira and Confluence site pages the same shape --- apps/sim/lib/sim-search/live/atlassian.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index 49e1b5befd2..0678ca697ce 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -191,6 +191,7 @@ export async function searchAtlassian( .map((row) => issue(row, cloudId, origin)), excluded: false, next: string(data.nextPageToken) || undefined, + relevanceRanked: false, } } const searchPages = async (clause: string, cursor?: string) => { From a2032c5bcc22ea2e6c491ebb0f19a420ab128a53 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 13 Oct 2026 17:27:07 -0700 Subject: [PATCH 3/7] fix(search): keep the all-terms Confluence page when relevance ranking fails A provider error on the optional relevance request (rate limit, refusal) now keeps the all-terms page and its cursor instead of failing the site, and with it the account's other sites. Cancellation and transport errors still propagate. Relevance-ranked and ranking-unavailable pages are reported as partial. --- .../sim/lib/sim-search/live/atlassian.test.ts | 32 ++++++++++++++++ apps/sim/lib/sim-search/live/atlassian.ts | 37 ++++++++++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index 85a8935526c..2cb5355a7c0 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -271,9 +271,41 @@ describe('Confluence plain search ranking', () => { const page = await searchAtlassian(api, 'confluence', query) expect(page.documents.map((document) => document.id)).toEqual(['best', 'next']) expect(page.hasMore).toBe(true) + expect(page.partial).toBe(true) expect(page.nextCursor).toBeUndefined() }) + it('keeps the all-terms page and its cursor when relevance ranking fails', async () => { + const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) + const exact = api.json.getMockImplementation() + api.json.mockImplementation( + async (path: string, options?: { query?: Record }) => { + if (options?.query?.cql?.includes('siteSearch ~')) + throw new NativeSearchError('rate_limited', 'Rate limited.', 30) + return exact?.(path, options) + } + ) + const page = await searchAtlassian(api, 'confluence', query) + expect(page.documents.map((document) => document.id)).toEqual(['old-1', 'old-2']) + expect(page.nextCursor).toBe('page-2') + expect(page.partial).toBe(true) + }) + + it('propagates cancellation during relevance ranking', async () => { + const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) + const exact = api.json.getMockImplementation() + api.json.mockImplementation( + async (path: string, options?: { query?: Record }) => { + if (options?.query?.cql?.includes('siteSearch ~')) + throw new DOMException('Search cancelled', 'AbortError') + return exact?.(path, options) + } + ) + await expect(searchAtlassian(api, 'confluence', query)).rejects.toMatchObject({ + name: 'AbortError', + }) + }) + it('keeps an all-terms match that fits on one page', async () => { const api = confluence({ ids: ['exact'], more: false }, ['loose']) const page = await searchAtlassian(api, 'confluence', query) diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index 0678ca697ce..8c7c463c83c 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -116,6 +116,17 @@ async function sites(client: NativeClient) { return array(await client.json('/oauth/token/accessible-resources', { memo: true })) } +/** + * One Atlassian site's page; `ranking` marks a page whose order or completeness differs from the + * all-terms search. + */ +interface SitePage { + documents: NativeDocument[] + excluded: boolean + next?: string + ranking?: 'relevance' | 'unavailable' +} + export async function searchAtlassian( client: NativeClient, provider: 'jira' | 'confluence', @@ -160,7 +171,7 @@ export async function searchAtlassian( // keep only issues holding at least half the terms. const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined const pages = await Promise.all( - selected.map(async (site) => { + selected.map(async (site): Promise => { const cloudId = string(site.id) const origin = string(site.url).replace(/\/$/, '') if (provider === 'jira') { @@ -191,7 +202,6 @@ export async function searchAtlassian( .map((row) => issue(row, cloudId, origin)), excluded: false, next: string(data.nextPageToken) || undefined, - relevanceRanked: false, } } const searchPages = async (clause: string, cursor?: string) => { @@ -221,7 +231,6 @@ export async function searchAtlassian( next: next ? (new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined) : undefined, - relevanceRanked: false, } } const exact = await searchPages( @@ -230,10 +239,12 @@ export async function searchAtlassian( ) // 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 - return { - ...(await searchPages(confluenceTextClause(text, 'any'))), - next: undefined, - relevanceRanked: true, + try { + const ranked = await searchPages(confluenceTextClause(text, 'any')) + return { ...ranked, next: undefined, ranking: 'relevance' } + } catch (error) { + if (!(error instanceof NativeSearchError)) throw error + return { ...exact, ranking: 'unavailable' } } }) ) @@ -241,16 +252,22 @@ export async function searchAtlassian( const excluded = pages.some((result) => result.excluded) return { documents, - partial: excluded || (!input.native?.project && allSites.length > selected.length), - hasMore: pages.some((result) => Boolean(result.next) || result.relevanceRanked), + partial: + excluded || + pages.some((result) => result.ranking) || + (!input.native?.project && allSites.length > selected.length), + hasMore: pages.some((result) => Boolean(result.next) || result.ranking === 'relevance'), nextCursor: single ? pages[0]?.next : undefined, message: 'Searches up to four accessible Atlassian sites. For a specific site and pagination, set project to its cloud ID.' + (excluded ? ' Matches other than pages, blog posts, and spaces (attachments, comments, whiteboards, folders, databases) were excluded because they cannot be read.' : '') + - (pages.some((result) => result.relevanceRanked) + (pages.some((result) => result.ranking === 'relevance') ? ' More pages contained every word than fit on one page, so this page is ordered by Confluence relevance search; refine the query, or page with a native CQL query.' + : '') + + (pages.some((result) => result.ranking === 'unavailable') + ? ' Relevance ranking was unavailable, so this page keeps the order of the all-terms search.' : ''), } } From 7e74b71d5c8897fca29b6ac7d53f67151c568688 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 13 Oct 2026 17:30:45 -0700 Subject: [PATCH 4/7] fix(search): keep the all-terms Confluence page on any non-cancellation ranking failure The client's own request timeout and transport failures surface as plain errors, so keeping the page only for provider errors still dropped it on a slow relevance request. Any failure other than cancellation (an AbortSignal reason) now keeps the all-terms page. --- apps/sim/lib/sim-search/live/atlassian.test.ts | 15 +++++++++++++++ apps/sim/lib/sim-search/live/atlassian.ts | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index 2cb5355a7c0..e5d38e32a70 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -291,6 +291,21 @@ describe('Confluence plain search ranking', () => { expect(page.partial).toBe(true) }) + it('keeps the all-terms page when the relevance request times out', async () => { + const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) + const exact = api.json.getMockImplementation() + api.json.mockImplementation( + async (path: string, options?: { query?: Record }) => { + if (options?.query?.cql?.includes('siteSearch ~')) + throw new Error('Request timed out after 10000ms') + return exact?.(path, options) + } + ) + const page = await searchAtlassian(api, 'confluence', query) + expect(page.documents.map((document) => document.id)).toEqual(['old-1', 'old-2']) + expect(page.partial).toBe(true) + }) + it('propagates cancellation during relevance ranking', async () => { const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) const exact = api.json.getMockImplementation() diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index 8c7c463c83c..d1fe9c60523 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -243,7 +243,8 @@ export async function searchAtlassian( const ranked = await searchPages(confluenceTextClause(text, 'any')) return { ...ranked, next: undefined, ranking: 'relevance' } } catch (error) { - if (!(error instanceof NativeSearchError)) throw error + // Cancellation arrives as an AbortSignal reason; any other failure keeps the all-terms page. + if (error instanceof DOMException) throw error return { ...exact, ranking: 'unavailable' } } }) From 8b1e1757f2d57c4735bbfddcd37b214a3662580e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 13 Oct 2026 17:34:16 -0700 Subject: [PATCH 5/7] fix(search): honor a caller cancellation of any reason during Confluence relevance ranking --- apps/sim/lib/sim-search/live/atlassian.test.ts | 16 ++++++++++++++++ apps/sim/lib/sim-search/live/atlassian.ts | 1 + 2 files changed, 17 insertions(+) diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index e5d38e32a70..017fb802918 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -306,6 +306,22 @@ describe('Confluence plain search ranking', () => { expect(page.partial).toBe(true) }) + it('propagates a caller cancellation whatever its reason', async () => { + const caller = new AbortController() + const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) + const exact = api.json.getMockImplementation() + api.json.mockImplementation( + async (path: string, options?: { query?: Record }) => { + if (!options?.query?.cql?.includes('siteSearch ~')) return exact?.(path, options) + caller.abort(new Error('Caller stopped')) + throw new Error('Request aborted') + } + ) + await expect( + searchAtlassian(api, 'confluence', { ...query, signal: caller.signal }) + ).rejects.toThrow('Caller stopped') + }) + it('propagates cancellation during relevance ranking', async () => { const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) const exact = api.json.getMockImplementation() diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index d1fe9c60523..d5bad208838 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -244,6 +244,7 @@ export async function searchAtlassian( return { ...ranked, next: undefined, ranking: 'relevance' } } catch (error) { // Cancellation arrives as an AbortSignal reason; any other failure keeps the all-terms page. + input.signal?.throwIfAborted() if (error instanceof DOMException) throw error return { ...exact, ranking: 'unavailable' } } From 0fbe2faa66fb0c6db7af08c4277df70f6605a57c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 13 Oct 2026 17:36:21 -0700 Subject: [PATCH 6/7] test(search): drive Confluence ranking failures through the test client The failure cases re-mocked the client with an optional call that could return undefined, which the test type check rejects. The helper now takes the relevance result or a throwing function, and the provider-refusal and timeout cases share one table test. --- .../sim/lib/sim-search/live/atlassian.test.ts | 79 +++++++------------ 1 file changed, 27 insertions(+), 52 deletions(-) diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index 017fb802918..25c0c5520ec 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -248,15 +248,15 @@ describe('Jira any-term search', () => { describe('Confluence plain search ranking', () => { const search = '/ex/confluence/cloud/wiki/rest/api/search' - /** Answers the all-terms query with `exact` and the relevance query with `ranked`. */ - function confluence(exact: { ids: string[]; more: boolean }, ranked: string[]) { + /** Answers the all-terms query with `exact` and the relevance query with `ranked`, or its failure. */ + function confluence(exact: { ids: string[]; more: boolean }, ranked: string[] | (() => never)) { const results = (ids: string[]) => ids.map((id) => ({ content: { id, type: 'page', title: id } })) const json = vi.fn(async (path: string, options?: { query?: Record }) => { if (path === '/oauth/token/accessible-resources') return [SITE] if (path !== search) throw new Error(`Unexpected request: ${path}`) if (options?.query?.cql?.includes('siteSearch ~')) - return { results: results(ranked), _links: {} } + return { results: results(typeof ranked === 'function' ? ranked() : ranked), _links: {} } return { results: results(exact.ids), _links: exact.more ? { next: `${search}?cursor=page-2` } : {}, @@ -265,9 +265,10 @@ describe('Confluence plain search ranking', () => { return { json, text: vi.fn(), bytes: vi.fn() } satisfies NativeClient } const query = { query: 'merge train service', limit: 2, scopes: [] } + const overflowing = { ids: ['old-1', 'old-2'], more: true } it('ranks an all-terms match too large for one page with relevance search', async () => { - const api = confluence({ ids: ['old-1', 'old-2'], more: true }, ['best', 'next']) + const api = confluence(overflowing, ['best', 'next']) const page = await searchAtlassian(api, 'confluence', query) expect(page.documents.map((document) => document.id)).toEqual(['best', 'next']) expect(page.hasMore).toBe(true) @@ -275,63 +276,37 @@ describe('Confluence plain search ranking', () => { expect(page.nextCursor).toBeUndefined() }) - it('keeps the all-terms page and its cursor when relevance ranking fails', async () => { - const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) - const exact = api.json.getMockImplementation() - api.json.mockImplementation( - async (path: string, options?: { query?: Record }) => { - if (options?.query?.cql?.includes('siteSearch ~')) - throw new NativeSearchError('rate_limited', 'Rate limited.', 30) - return exact?.(path, options) - } - ) - const page = await searchAtlassian(api, 'confluence', query) - expect(page.documents.map((document) => document.id)).toEqual(['old-1', 'old-2']) - expect(page.nextCursor).toBe('page-2') - expect(page.partial).toBe(true) - }) - - it('keeps the all-terms page when the relevance request times out', async () => { - const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) - const exact = api.json.getMockImplementation() - api.json.mockImplementation( - async (path: string, options?: { query?: Record }) => { - if (options?.query?.cql?.includes('siteSearch ~')) - throw new Error('Request timed out after 10000ms') - return exact?.(path, options) - } - ) - const page = await searchAtlassian(api, 'confluence', query) - expect(page.documents.map((document) => document.id)).toEqual(['old-1', 'old-2']) - expect(page.partial).toBe(true) - }) + it.each([ + ['a provider refusal', new NativeSearchError('rate_limited', 'Rate limited.', 30)], + ['a request timeout', new Error('Request timed out after 10000ms')], + ])( + 'keeps the all-terms page and its cursor when relevance ranking fails on %s', + async (_, failure) => { + const api = confluence(overflowing, () => { + throw failure + }) + const page = await searchAtlassian(api, 'confluence', query) + expect(page.documents.map((document) => document.id)).toEqual(['old-1', 'old-2']) + expect(page.nextCursor).toBe('page-2') + expect(page.partial).toBe(true) + } + ) it('propagates a caller cancellation whatever its reason', async () => { const caller = new AbortController() - const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) - const exact = api.json.getMockImplementation() - api.json.mockImplementation( - async (path: string, options?: { query?: Record }) => { - if (!options?.query?.cql?.includes('siteSearch ~')) return exact?.(path, options) - caller.abort(new Error('Caller stopped')) - throw new Error('Request aborted') - } - ) + const api = confluence(overflowing, () => { + caller.abort(new Error('Caller stopped')) + throw new Error('Request aborted') + }) await expect( searchAtlassian(api, 'confluence', { ...query, signal: caller.signal }) ).rejects.toThrow('Caller stopped') }) it('propagates cancellation during relevance ranking', async () => { - const api = confluence({ ids: ['old-1', 'old-2'], more: true }, []) - const exact = api.json.getMockImplementation() - api.json.mockImplementation( - async (path: string, options?: { query?: Record }) => { - if (options?.query?.cql?.includes('siteSearch ~')) - throw new DOMException('Search cancelled', 'AbortError') - return exact?.(path, options) - } - ) + const api = confluence(overflowing, () => { + throw new DOMException('Search cancelled', 'AbortError') + }) await expect(searchAtlassian(api, 'confluence', query)).rejects.toMatchObject({ name: 'AbortError', }) From a1c83f428f99d3a55268000374a0a70d7c2b617e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 13 Oct 2026 17:48:39 -0700 Subject: [PATCH 7/7] fix(search): give provider searches the session cancellation, whatever its reason A search stopped with a non-error abort reason (a string) surfaced from the HTTP client unchanged, so the relevance-ranking fallback treated it as a ranking failure and returned results after the stop. Provider searches now carry the account session's signal, and the fallback checks it instead of the error's type. --- .../lib/sim-search/live/account-session.ts | 6 ++++-- .../lib/sim-search/live/application.test.ts | 11 ++++++++++ .../sim/lib/sim-search/live/atlassian.test.ts | 21 +++++++------------ apps/sim/lib/sim-search/live/atlassian.ts | 5 ++--- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/sim-search/live/account-session.ts b/apps/sim/lib/sim-search/live/account-session.ts index c343e894fd1..b8fede59474 100644 --- a/apps/sim/lib/sim-search/live/account-session.ts +++ b/apps/sim/lib/sim-search/live/account-session.ts @@ -205,9 +205,11 @@ export async function openLiveAccountSession( client ? searchNativeProvider(provider, client, native) : searchMcp(native) ) } + /** Providers see the session's cancellation, whatever its reason, on every request. */ + const request = search.signal ? search : { ...search, signal } return client && requiresEveryTerm(provider) - ? searchWithAnyTermFallback(searchOnce, search, signal) - : searchOnce(search) + ? searchWithAnyTermFallback(searchOnce, request, signal) + : searchOnce(request) }, async verify(document) { if (!(await boundary.verify(document))) return false diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts index 7591c26a748..a4e8336c5e9 100644 --- a/apps/sim/lib/sim-search/live/application.test.ts +++ b/apps/sim/lib/sim-search/live/application.test.ts @@ -697,6 +697,17 @@ describe('authorized live retrieval', () => { }) }) + it('gives provider searches the account session cancellation signal', async () => { + const signals: (AbortSignal | undefined)[] = [] + mocks.search.mockImplementation(async (_provider, _client, search) => { + signals.push(search.signal) + return { documents: [document] } + }) + await searchLiveKnowledge.execute({ principal, input: { ...input, query: 'launch checklist' } }) + expect(signals.length).toBeGreaterThan(0) + for (const signal of signals) expect(signal).toBeInstanceOf(AbortSignal) + }) + it('retries an underfilled search with any term for providers that require every term', async () => { mocks.requiresEveryTerm.mockReturnValue(true) const loose = { ...document, id: 'loose', url: 'https://docs.google.com/document/d/loose/edit' } diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index 25c0c5520ec..7a6fe15f8a3 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -292,24 +292,19 @@ describe('Confluence plain search ranking', () => { } ) - it('propagates a caller cancellation whatever its reason', async () => { + it.each([ + ['an Error', new Error('Caller stopped')], + ['a string', 'AbortReason.UserStop'], + ['a DOMException', new DOMException('Search cancelled', 'AbortError')], + ])('propagates a cancellation whose reason is %s', async (_, reason) => { const caller = new AbortController() const api = confluence(overflowing, () => { - caller.abort(new Error('Caller stopped')) - throw new Error('Request aborted') + caller.abort(reason) + throw reason }) await expect( searchAtlassian(api, 'confluence', { ...query, signal: caller.signal }) - ).rejects.toThrow('Caller stopped') - }) - - it('propagates cancellation during relevance ranking', async () => { - const api = confluence(overflowing, () => { - throw new DOMException('Search cancelled', 'AbortError') - }) - await expect(searchAtlassian(api, 'confluence', query)).rejects.toMatchObject({ - name: 'AbortError', - }) + ).rejects.toBe(reason) }) it('keeps an all-terms match that fits on one page', async () => { diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index d5bad208838..4283b3eddf2 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -242,10 +242,9 @@ export async function searchAtlassian( try { const ranked = await searchPages(confluenceTextClause(text, 'any')) return { ...ranked, next: undefined, ranking: 'relevance' } - } catch (error) { - // Cancellation arrives as an AbortSignal reason; any other failure keeps the all-terms page. + } catch { + // A cancelled search stops here; any other failure keeps the all-terms page. input.signal?.throwIfAborted() - if (error instanceof DOMException) throw error return { ...exact, ranking: 'unavailable' } } })