Skip to content

Commit e1124ff

Browse files
authored
fix(search): let cancelled live reads stop instead of reporting partial coverage (#8359)
* fix(search): let cancelled discussion reads stop instead of reporting partial coverage * fix(search): judge live-read cancellation by the signal, not the error shape * fix(search): stop a live read cancelled during current-scope verification
1 parent bf2594c commit e1124ff

9 files changed

Lines changed: 105 additions & 7 deletions

File tree

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
readLiveDocument,
6969
searchLiveKnowledge,
7070
} from '@/lib/sim-search/live/application'
71+
import { readDrive } from '@/lib/sim-search/live/google'
7172
import { NativeSearchError } from '@/lib/sim-search/live/http'
7273
import { createPolicyVerifier } from '@/lib/sim-search/live/policy'
7374
import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
@@ -432,6 +433,62 @@ describe('authorized live retrieval', () => {
432433
).rejects.toThrow('Revoked')
433434
expect(mocks.read).not.toHaveBeenCalled()
434435
})
436+
it.each([
437+
['a stop reason', 'user_stop:test'],
438+
['an AbortError', new DOMException('The operation was aborted.', 'AbortError')],
439+
['a TimeoutError', new DOMException('The operation timed out.', 'TimeoutError')],
440+
])(
441+
'rejects a read cancelled with %s instead of returning partial discussion coverage',
442+
async (_, reason) => {
443+
const search = await searchLiveKnowledge.execute({ principal, input })
444+
const controller = new AbortController()
445+
mocks.read.mockImplementation((_provider, client) => readDrive(client, 'doc'))
446+
mocks.json.mockImplementation(async (path: string) => {
447+
if (!path.endsWith('/comments'))
448+
return {
449+
id: 'doc',
450+
name: 'Launch',
451+
mimeType: 'application/pdf',
452+
webViewLink: document.url,
453+
}
454+
controller.abort(reason)
455+
throw controller.signal.reason
456+
})
457+
await expect(
458+
readLiveDocument.execute({
459+
principal,
460+
input: {
461+
workspaceId: 'workspace',
462+
documentId: search.results[0]!.documentId,
463+
limit: 1,
464+
resultSecretRegistry: new ResolvedSecretTraceRegistry([]),
465+
signal: controller.signal,
466+
},
467+
})
468+
).rejects.toBe(reason)
469+
}
470+
)
471+
it('rejects a read cancelled while its current scope was being verified', async () => {
472+
const search = await searchLiveKnowledge.execute({ principal, input })
473+
const controller = new AbortController()
474+
mocks.service.mockResolvedValueOnce(undefined)
475+
mocks.service.mockImplementationOnce(async () => {
476+
controller.abort('user_stop:test')
477+
return { policy: defaultLiveSearchPolicy(), verify: async () => true, partial: false }
478+
})
479+
await expect(
480+
readLiveDocument.execute({
481+
principal,
482+
input: {
483+
workspaceId: 'workspace',
484+
documentId: search.results[0]!.documentId,
485+
limit: 1,
486+
resultSecretRegistry: new ResolvedSecretTraceRegistry([]),
487+
signal: controller.signal,
488+
},
489+
})
490+
).rejects.toBe('user_stop:test')
491+
})
435492
it('rejects cross-user document references before token resolution', async () => {
436493
const search = await searchLiveKnowledge.execute({ principal, input })
437494
const reference = decodeLiveReference(search.results[0].documentId)

‎apps/sim/lib/sim-search/live/application.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,12 @@ export const readLiveDocument = defineAuthorizedKnowledgeUseCase({
664664
'Document is outside your organization’s search scope'
665665
)
666666
document = await measureSearchStage('live.read', () => session.read(reference, input.filters))
667-
if (!(await session.verifyCurrent(document)))
667+
/** Readers degrade section failures to warnings, so the signal decides cancellation. */
668+
signal.throwIfAborted()
669+
const current = await session.verifyCurrent(document)
670+
/** A verifier may report a check cut short by cancellation as a normal result. */
671+
signal.throwIfAborted()
672+
if (!current)
668673
throw new OrchestrationError(
669674
'not_found',
670675
'Document is outside your organization’s search scope'

‎apps/sim/lib/sim-search/live/discussion-reads.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,20 @@ describe('Drive discussion reads', () => {
258258
expect(content).toMatch(/rate limit/i)
259259
})
260260

261+
it('degrades a failed comment request to partial coverage', async () => {
262+
const api: NativeClient = {
263+
text,
264+
async json(path) {
265+
if (path.endsWith('/comments'))
266+
throw Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })
267+
return DRIVE_FILE
268+
},
269+
}
270+
const { content } = await readDrive(api, 'doc')
271+
expect(content).toContain('Original document text')
272+
expect(content).toMatch(/incomplete[\s\S]*could not be fully retrieved/i)
273+
})
274+
261275
it('detects repeated comment cursors without silently claiming all comments were read', async () => {
262276
let pages = 0
263277
const api: NativeClient = {

‎apps/sim/lib/sim-search/live/discussion.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { truncate } from '@sim/utils/string'
1+
import { truncateAtCodePoint } from '@sim/utils/string'
22
import { NativeSearchError } from '@/lib/sim-search/live/http'
33

44
/** Leaves room for file content and authorization within one live read's request budget. */
@@ -13,6 +13,7 @@ interface DiscussionPage {
1313
/**
1414
* Collects a bounded discussion without representing provider failures or omitted pages as an
1515
* empty, complete history. The caller puts the warning before the document's first read window.
16+
* Cancellation is judged by the caller's signal after the read, not by the error's shape.
1617
*/
1718
export async function readDiscussionSection(
1819
label: string,
@@ -29,7 +30,7 @@ export async function readDiscussionSection(
2930
for (const entry of result.entries) {
3031
if (!entry) continue
3132
const remaining = Math.max(0, DISCUSSION_MAX_CHARACTERS - characters - 2)
32-
entries.push(truncate(entry, remaining))
33+
entries.push(truncateAtCodePoint(entry, remaining, ''))
3334
characters += Math.min(entry.length, remaining) + 2
3435
if (entry.length > remaining) {
3536
omitted = 'exceeded the discussion text limit'

‎apps/sim/lib/sim-search/live/linear.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ describe('Linear live search boundary', () => {
134134
it('rejects malformed search responses and project references', async () => {
135135
const api = client(async () => ({ data: { searchIssues: {} } }))
136136
await expect(searchLinear(api, input)).rejects.toThrow('unsupported')
137+
const ascending = client(async () => ({
138+
data: { searchIssues: { nodes: [issue], pageInfo: { hasNextPage: false } } },
139+
}))
140+
await expect(
141+
searchLinear(ascending, { ...input, filters: { sortBy: 'oldest' } })
142+
).rejects.toThrow('unsupported')
137143
await expect(
138144
searchLinear(api, {
139145
...input,

‎apps/sim/lib/sim-search/live/linear.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ export async function searchLinear(
138138
variables
139139
)
140140
const result = object(data[field])
141-
if (!Array.isArray(result.nodes) || typeof object(result.pageInfo).hasNextPage !== 'boolean')
141+
if (
142+
!Array.isArray(result.nodes) ||
143+
typeof object(result.pageInfo)[backwards ? 'hasPreviousPage' : 'hasNextPage'] !== 'boolean'
144+
)
142145
throw new NativeSearchError(
143146
'unavailable',
144147
'Linear search returned an unsupported result format.'
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { truncate } from '@sim/utils/string'
1+
import { truncateAtCodePoint } from '@sim/utils/string'
22

33
/** Coverage notices precede the text so the first paginated read cannot hide a provider cap. */
44
export function boundedMeetingContent(content: string, limit = 200_000): string {
55
if (content.length <= limit) return content
66
const notice = '[Meeting content truncated. Open the original meeting for the remainder.]\n\n'
7-
return notice + truncate(content, limit - notice.length, '')
7+
return notice + truncateAtCodePoint(content, limit - notice.length, '')
88
}

‎apps/sim/lib/sim-search/live/notion-mcp.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ describe('Notion live MCP boundary', () => {
153153
expect(page.partial).toBe(true)
154154
})
155155

156+
it('keeps AI search coverage partial when the result omits its search type', async () => {
157+
const client: ManagedSearchMcpClient = {
158+
call: async (name) =>
159+
name === 'notion-get-tool-access'
160+
? { current_tool_access: { ai_search: { status: 'available' } } }
161+
: { results: [result] },
162+
}
163+
expect(await searchNotionMcp(client, input)).toMatchObject({ partial: true })
164+
})
165+
156166
it('uses keyword search when AI access needs an upgrade without inventing unknown timestamps', async () => {
157167
const client: ManagedSearchMcpClient = {
158168
call: async (name) => {

‎apps/sim/lib/sim-search/live/notion-mcp.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ export async function searchNotionMcp(
155155
const next = string(result.next_cursor ?? result.nextCursor)
156156
const nextCursor = cursorKey && next && !clipped ? next : undefined
157157
const notices = array(result.notices).length > 0
158-
const aiSearch = result.type === 'ai_search'
158+
const aiSearch =
159+
result.type === 'ai_search' ||
160+
(tool === 'notion-ai-search' && result.type !== 'workspace_search')
159161
const hasMore = clipped || result.has_more === true || result.hasMore === true || Boolean(next)
160162
const cappedWithoutCoverage =
161163
rows.length >= limit && !next && result.has_more !== false && result.hasMore !== false

0 commit comments

Comments
 (0)