diff --git a/apps/sim/lib/sim-search/live/lucid-mcp.test.ts b/apps/sim/lib/sim-search/live/lucid-mcp.test.ts new file mode 100644 index 00000000000..7b71a627677 --- /dev/null +++ b/apps/sim/lib/sim-search/live/lucid-mcp.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { readLucidMcp } from '@/lib/sim-search/live/lucid-mcp' +import type { ManagedSearchMcpClient } from '@/lib/sim-search/live/managed-mcp' + +const ID = '0b6a3f0e-5a52-4c55-9d8c-6d2f2a1b9c11' +const COUNTS = [3, 0, 2, 1] +const TITLE = 'Fixture diagram' +const metadataRow = { + documentId: ID, + viewUrl: `https://lucid.app/lucidchart/${ID}/view`, + product: 'lucidchart', + title: TITLE, + version: 7, + pageCount: COUNTS.length, + lastModified: '2026-10-01T12:00:00Z', +} + +function manifest(extra: Record = {}) { + return { + document_id: ID, + edit_url: `https://lucid.app/lucidchart/${ID}/edit`, + title: TITLE, + metadata: { page_count: COUNTS.length, page_region_counts: COUNTS, ...extra }, + } +} + +function regionResponse(pageIndex: number, region: number) { + const pageId = `page-${pageIndex}` + return { + ...manifest({ page_index: pageIndex + 1 }), + page_index: pageIndex + 1, + page_id: pageId, + text: JSON.stringify({ + pages: [ + { + pageIndex, + pageId, + pageTitle: `Page ${pageIndex + 1}`, + totalChunks: COUNTS[pageIndex], + requestedChunks: COUNTS[pageIndex] + ? [{ chunkIndex: region, data: { label: `p${pageIndex}r${region}` } }] + : [], + }, + ], + }), + } +} + +/** Region fetches settle in reverse request order, after a number of ticks, to expose ordering. */ +function lucidClient(options: { corruptFirstRegion?: boolean } = {}) { + let inFlight = 0 + let maxInFlight = 0 + let regionCalls = 0 + const client: ManagedSearchMcpClient = { + async call(name, args) { + if (name === 'lucid_get_document_metadata') return metadataRow + if (args.metadata_only) return manifest() + const pageIndex = Number(args.page_index) - 1 + const region = Array.isArray(args.region_index) ? Number(args.region_index[0]) - 1 : 0 + const call = regionCalls++ + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + for (let tick = 0; tick < 10 - call; tick++) await Promise.resolve() + inFlight-- + const response = regionResponse(pageIndex, region) + return options.corruptFirstRegion && call === 0 ? { ...response, page_id: 'other' } : response + }, + } + return { client, maxInFlight: () => maxInFlight, regionCalls: () => regionCalls } +} + +const REFERENCE = { id: ID, kind: 'lucidchart', revision: '7' } + +describe('readLucidMcp', () => { + it('fetches at most four regions at once and assembles them in page and region order', async () => { + const { client, maxInFlight, regionCalls } = lucidClient() + const document = await readLucidMcp(client, REFERENCE) + expect(regionCalls()).toBe(7) + expect(maxInFlight()).toBe(4) + const pages = JSON.parse(document.content ?? '').pages as { + pageId: string + requestedChunks: { data: { label: string } }[] + }[] + expect(pages.map((page) => page.pageId)).toEqual(['page-0', 'page-1', 'page-2', 'page-3']) + expect(pages.map((page) => page.requestedChunks.map((chunk) => chunk.data.label))).toEqual([ + ['p0r0', 'p0r1', 'p0r2'], + [], + ['p2r0', 'p2r1'], + ['p3r0'], + ]) + }) + + it('stops scheduling region fetches once a region fails validation', async () => { + const { client, regionCalls } = lucidClient({ corruptFirstRegion: true }) + await expect(readLucidMcp(client, REFERENCE)).rejects.toThrow(/mismatched page regions/) + expect(regionCalls()).toBe(4) + }) +}) diff --git a/apps/sim/lib/sim-search/live/lucid-mcp.ts b/apps/sim/lib/sim-search/live/lucid-mcp.ts index 34ed17a7324..89dfaff71cb 100644 --- a/apps/sim/lib/sim-search/live/lucid-mcp.ts +++ b/apps/sim/lib/sim-search/live/lucid-mcp.ts @@ -322,23 +322,30 @@ function manifest(value: unknown, id: string, kind: string | undefined) { return { row, counts: counts as number[] } } +/** + * Region fetches are independent provider reads; running them together keeps a full read inside + * the live read deadline. Validation walks them in page and region order, and a fetch is only + * scheduled once the region this many places before it has been validated, so a rejected or + * oversized document stops further reads and holds at most this many responses. + */ +const REGION_FETCH_CONCURRENCY = 4 + /** Complete, bounded provider pages preserve graph data; no returned URL is fetched. */ export async function readLucidMcp( client: ManagedSearchMcpClient, reference: Pick ): Promise { - const before = await metadata(client, reference.id) + const [before, initialRaw] = await Promise.all([ + metadata(client, reference.id), + client.call('fetch', { id: reference.id, metadata_only: true }), + ]) if (!before) invalid('Lucid document metadata is incomplete or no longer readable.') if ( (reference.kind && reference.kind !== before.kind) || (reference.revision && reference.revision !== before.revision) ) invalid('Lucid document changed since this result was issued. Search again before reading.') - const initial = manifest( - await client.call('fetch', { id: before.id, metadata_only: true }), - before.id, - before.kind - ) + const initial = manifest(initialRaw, before.id, before.kind) if ( initial.counts.length !== before.accessMetadata?.pageCount || initial.row.title !== before.title @@ -346,20 +353,28 @@ export async function readLucidMcp( invalid('Lucid document coverage changed before reading. Search again.') const pages: Record[] = [] const output = () => JSON.stringify({ document_id: before.id, title: before.title, pages }) - for (let pageIndex = 0; pageIndex < initial.counts.length; pageIndex++) { - const count = initial.counts[pageIndex]! + const requests = initial.counts.flatMap((count, pageIndex) => + Array.from({ length: Math.max(1, count) }, (_, region) => ({ + id: before.id, + page_index: pageIndex + 1, + ...(count ? { region_index: [region + 1] } : {}), + })) + ) + const responses: Promise[] = [] + const schedule = (index: number) => { + if (index >= requests.length) return + const response = client.call('fetch', requests[index]) + // An invalid earlier region abandons in-flight fetches; their rejections must not go unhandled. + response.catch(() => {}) + responses[index] = response + } + for (let index = 0; index < REGION_FETCH_CONCURRENCY; index++) schedule(index) + let next = 0 + for (const [pageIndex, count] of initial.counts.entries()) { let assembled: Record | undefined const chunks: Record[] = [] for (let region = 0; region < Math.max(1, count); region++) { - const fetched = manifest( - await client.call('fetch', { - id: before.id, - page_index: pageIndex + 1, - ...(count ? { region_index: [region + 1] } : {}), - }), - before.id, - before.kind - ) + const fetched = manifest(await responses[next], before.id, before.kind) if ( fetched.counts.some((value, index) => value !== initial.counts[index]) || fetched.counts.length !== initial.counts.length || @@ -404,6 +419,8 @@ export async function readLucidMcp( chunks.push(...returnedChunks) if (Buffer.byteLength(output(), 'utf8') > MAX_CONTENT_BYTES) invalid('Lucid document exceeds the 512 KiB complete-read limit. Open the source document.') + schedule(next + REGION_FETCH_CONCURRENCY) + next++ } } const after = await metadata(client, before.id) diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index 426291d0d2f..5934cde0a94 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1650,7 +1650,7 @@ "apps/sim/lib/sim-search/live/google-meet.ts": 1, "apps/sim/lib/sim-search/live/granola-mcp.ts": 2, "apps/sim/lib/sim-search/live/hubspot-mcp.test.ts": 1, - "apps/sim/lib/sim-search/live/lucid-mcp.ts": 2, + "apps/sim/lib/sim-search/live/lucid-mcp.ts": 1, "apps/sim/lib/sim-search/live/pages.ts": 1, "apps/sim/lib/sim-search/live/policy-schema.ts": 1, "apps/sim/lib/sim-search/live/policy.ts": 2,