diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts index 0108c2fa6ac..de55321919e 100644 --- a/apps/sim/lib/sim-search/live/application.test.ts +++ b/apps/sim/lib/sim-search/live/application.test.ts @@ -68,6 +68,7 @@ import { readLiveDocument, searchLiveKnowledge, } from '@/lib/sim-search/live/application' +import { readDrive } from '@/lib/sim-search/live/google' import { NativeSearchError } from '@/lib/sim-search/live/http' import { createPolicyVerifier } from '@/lib/sim-search/live/policy' import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema' @@ -405,6 +406,62 @@ describe('authorized live retrieval', () => { ).rejects.toThrow('Revoked') expect(mocks.read).not.toHaveBeenCalled() }) + it.each([ + ['a stop reason', 'user_stop:test'], + ['an AbortError', new DOMException('The operation was aborted.', 'AbortError')], + ['a TimeoutError', new DOMException('The operation timed out.', 'TimeoutError')], + ])( + 'rejects a read cancelled with %s instead of returning partial discussion coverage', + async (_, reason) => { + const search = await searchLiveKnowledge.execute({ principal, input }) + const controller = new AbortController() + mocks.read.mockImplementation((_provider, client) => readDrive(client, 'doc')) + mocks.json.mockImplementation(async (path: string) => { + if (!path.endsWith('/comments')) + return { + id: 'doc', + name: 'Launch', + mimeType: 'application/pdf', + webViewLink: document.url, + } + controller.abort(reason) + throw controller.signal.reason + }) + await expect( + readLiveDocument.execute({ + principal, + input: { + workspaceId: 'workspace', + documentId: search.results[0]!.documentId, + limit: 1, + resultSecretRegistry: new ResolvedSecretTraceRegistry([]), + signal: controller.signal, + }, + }) + ).rejects.toBe(reason) + } + ) + it('rejects a read cancelled while its current scope was being verified', async () => { + const search = await searchLiveKnowledge.execute({ principal, input }) + const controller = new AbortController() + mocks.service.mockResolvedValueOnce(undefined) + mocks.service.mockImplementationOnce(async () => { + controller.abort('user_stop:test') + return { policy: defaultLiveSearchPolicy(), verify: async () => true, partial: false } + }) + await expect( + readLiveDocument.execute({ + principal, + input: { + workspaceId: 'workspace', + documentId: search.results[0]!.documentId, + limit: 1, + resultSecretRegistry: new ResolvedSecretTraceRegistry([]), + signal: controller.signal, + }, + }) + ).rejects.toBe('user_stop:test') + }) it('rejects cross-user document references before token resolution', async () => { const search = await searchLiveKnowledge.execute({ principal, input }) const reference = decodeLiveReference(search.results[0].documentId) diff --git a/apps/sim/lib/sim-search/live/application.ts b/apps/sim/lib/sim-search/live/application.ts index f4bd5da9229..ea6972bfce7 100644 --- a/apps/sim/lib/sim-search/live/application.ts +++ b/apps/sim/lib/sim-search/live/application.ts @@ -661,7 +661,12 @@ export const readLiveDocument = defineAuthorizedKnowledgeUseCase({ 'Document is outside your organization’s search scope' ) document = await measureSearchStage('live.read', () => session.read(reference, input.filters)) - if (!(await session.verifyCurrent(document))) + /** Readers degrade section failures to warnings, so the signal decides cancellation. */ + signal.throwIfAborted() + const current = await session.verifyCurrent(document) + /** A verifier may report a check cut short by cancellation as a normal result. */ + signal.throwIfAborted() + if (!current) throw new OrchestrationError( 'not_found', 'Document is outside your organization’s search scope' diff --git a/apps/sim/lib/sim-search/live/discussion-reads.test.ts b/apps/sim/lib/sim-search/live/discussion-reads.test.ts index e588c65f3dc..af9e72468a8 100644 --- a/apps/sim/lib/sim-search/live/discussion-reads.test.ts +++ b/apps/sim/lib/sim-search/live/discussion-reads.test.ts @@ -258,6 +258,20 @@ describe('Drive discussion reads', () => { expect(content).toMatch(/rate limit/i) }) + it('degrades a failed comment request to partial coverage', async () => { + const api: NativeClient = { + text, + async json(path) { + if (path.endsWith('/comments')) + throw Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }) + return DRIVE_FILE + }, + } + const { content } = await readDrive(api, 'doc') + expect(content).toContain('Original document text') + expect(content).toMatch(/incomplete[\s\S]*could not be fully retrieved/i) + }) + it('detects repeated comment cursors without silently claiming all comments were read', async () => { let pages = 0 const api: NativeClient = { diff --git a/apps/sim/lib/sim-search/live/discussion.ts b/apps/sim/lib/sim-search/live/discussion.ts index a1702047c74..9cedde99b7f 100644 --- a/apps/sim/lib/sim-search/live/discussion.ts +++ b/apps/sim/lib/sim-search/live/discussion.ts @@ -1,4 +1,4 @@ -import { truncate } from '@sim/utils/string' +import { truncateAtCodePoint } from '@sim/utils/string' import { NativeSearchError } from '@/lib/sim-search/live/http' /** Leaves room for file content and authorization within one live read's request budget. */ @@ -13,6 +13,7 @@ interface DiscussionPage { /** * Collects a bounded discussion without representing provider failures or omitted pages as an * empty, complete history. The caller puts the warning before the document's first read window. + * Cancellation is judged by the caller's signal after the read, not by the error's shape. */ export async function readDiscussionSection( label: string, @@ -29,7 +30,7 @@ export async function readDiscussionSection( for (const entry of result.entries) { if (!entry) continue const remaining = Math.max(0, DISCUSSION_MAX_CHARACTERS - characters - 2) - entries.push(truncate(entry, remaining)) + entries.push(truncateAtCodePoint(entry, remaining, '')) characters += Math.min(entry.length, remaining) + 2 if (entry.length > remaining) { omitted = 'exceeded the discussion text limit' diff --git a/apps/sim/lib/sim-search/live/linear.test.ts b/apps/sim/lib/sim-search/live/linear.test.ts index 5c472da4430..89ec76476e2 100644 --- a/apps/sim/lib/sim-search/live/linear.test.ts +++ b/apps/sim/lib/sim-search/live/linear.test.ts @@ -134,6 +134,12 @@ describe('Linear live search boundary', () => { it('rejects malformed search responses and project references', async () => { const api = client(async () => ({ data: { searchIssues: {} } })) await expect(searchLinear(api, input)).rejects.toThrow('unsupported') + const ascending = client(async () => ({ + data: { searchIssues: { nodes: [issue], pageInfo: { hasNextPage: false } } }, + })) + await expect( + searchLinear(ascending, { ...input, filters: { sortBy: 'oldest' } }) + ).rejects.toThrow('unsupported') await expect( searchLinear(api, { ...input, diff --git a/apps/sim/lib/sim-search/live/linear.ts b/apps/sim/lib/sim-search/live/linear.ts index 4ea5234cffa..e6aaac0e512 100644 --- a/apps/sim/lib/sim-search/live/linear.ts +++ b/apps/sim/lib/sim-search/live/linear.ts @@ -138,7 +138,10 @@ export async function searchLinear( variables ) const result = object(data[field]) - if (!Array.isArray(result.nodes) || typeof object(result.pageInfo).hasNextPage !== 'boolean') + if ( + !Array.isArray(result.nodes) || + typeof object(result.pageInfo)[backwards ? 'hasPreviousPage' : 'hasNextPage'] !== 'boolean' + ) throw new NativeSearchError( 'unavailable', 'Linear search returned an unsupported result format.' diff --git a/apps/sim/lib/sim-search/live/meeting-content.ts b/apps/sim/lib/sim-search/live/meeting-content.ts index 95f158d0a99..8025f3b4643 100644 --- a/apps/sim/lib/sim-search/live/meeting-content.ts +++ b/apps/sim/lib/sim-search/live/meeting-content.ts @@ -1,8 +1,8 @@ -import { truncate } from '@sim/utils/string' +import { truncateAtCodePoint } from '@sim/utils/string' /** Coverage notices precede the text so the first paginated read cannot hide a provider cap. */ export function boundedMeetingContent(content: string, limit = 200_000): string { if (content.length <= limit) return content const notice = '[Meeting content truncated. Open the original meeting for the remainder.]\n\n' - return notice + truncate(content, limit - notice.length, '') + return notice + truncateAtCodePoint(content, limit - notice.length, '') } diff --git a/apps/sim/lib/sim-search/live/notion-mcp.test.ts b/apps/sim/lib/sim-search/live/notion-mcp.test.ts index be855805f81..94d53e2c6f8 100644 --- a/apps/sim/lib/sim-search/live/notion-mcp.test.ts +++ b/apps/sim/lib/sim-search/live/notion-mcp.test.ts @@ -153,6 +153,16 @@ describe('Notion live MCP boundary', () => { expect(page.partial).toBe(true) }) + it('keeps AI search coverage partial when the result omits its search type', async () => { + const client: ManagedSearchMcpClient = { + call: async (name) => + name === 'notion-get-tool-access' + ? { current_tool_access: { ai_search: { status: 'available' } } } + : { results: [result] }, + } + expect(await searchNotionMcp(client, input)).toMatchObject({ partial: true }) + }) + it('uses keyword search when AI access needs an upgrade without inventing unknown timestamps', async () => { const client: ManagedSearchMcpClient = { call: async (name) => { diff --git a/apps/sim/lib/sim-search/live/notion-mcp.ts b/apps/sim/lib/sim-search/live/notion-mcp.ts index f23269d0ec1..c9ab482b082 100644 --- a/apps/sim/lib/sim-search/live/notion-mcp.ts +++ b/apps/sim/lib/sim-search/live/notion-mcp.ts @@ -155,7 +155,9 @@ export async function searchNotionMcp( const next = string(result.next_cursor ?? result.nextCursor) const nextCursor = cursorKey && next && !clipped ? next : undefined const notices = array(result.notices).length > 0 - const aiSearch = result.type === 'ai_search' + const aiSearch = + result.type === 'ai_search' || + (tool === 'notion-ai-search' && result.type !== 'workspace_search') const hasMore = clipped || result.has_more === true || result.hasMore === true || Boolean(next) const cappedWithoutCoverage = rows.length >= limit && !next && result.has_more !== false && result.hasMore !== false