Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/sim/lib/sim-search/live/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/lib/sim-search/live/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/lib/sim-search/live/discussion-reads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/sim-search/live/discussion.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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,
Expand All @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/sim-search/live/linear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/sim-search/live/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/sim-search/live/meeting-content.ts
Original file line number Diff line number Diff line change
@@ -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, '')
}
10 changes: 10 additions & 0 deletions apps/sim/lib/sim-search/live/notion-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/sim-search/live/notion-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading