@@ -196,6 +196,14 @@ export type { PdfCommand };
196196// reject first and return a real error instead of the client cancelling us.
197197const GET_PAGES_TIMEOUT_MS = 45_000 ;
198198
199+ /**
200+ * Grace period for the viewer's first poll. If interact() arrives before the
201+ * iframe has ever polled, we wait this long for it to show up (iframe mount +
202+ * PDF load + startPolling). If no poll comes, the viewer almost certainly
203+ * never rendered — failing fast beats a silent 45s hang.
204+ */
205+ const VIEWER_FIRST_POLL_GRACE_MS = 8_000 ;
206+
199207interface PageDataEntry {
200208 page : number ;
201209 text ?: string ;
@@ -232,6 +240,33 @@ function waitForPageData(
232240 } ) ;
233241}
234242
243+ /**
244+ * Wait for the viewer's first poll_pdf_commands call.
245+ *
246+ * Called before waitForPageData() / waitForSaveData() so a viewer that never
247+ * mounted fails in ~8s with a specific message instead of a generic 45s
248+ * "Timeout waiting for ..." that gives no hint why.
249+ *
250+ * Intentionally does NOT touch pollWaiters: piggybacking on that single-slot
251+ * Map races with poll_pdf_commands' batch-wait branch (which never cancels the
252+ * prior waiter) and with concurrent interact calls (which would overwrite each
253+ * other). A plain check loop on viewsPolled is stateless — multiple callers
254+ * can wait independently and all observe the same add() when it happens.
255+ */
256+ async function ensureViewerIsPolling ( uuid : string ) : Promise < void > {
257+ const deadline = Date . now ( ) + VIEWER_FIRST_POLL_GRACE_MS ;
258+ while ( ! viewsPolled . has ( uuid ) ) {
259+ if ( Date . now ( ) >= deadline ) {
260+ throw new Error (
261+ `Viewer never connected for viewUUID ${ uuid } (no poll within ${ VIEWER_FIRST_POLL_GRACE_MS / 1000 } s). ` +
262+ `The iframe likely failed to mount — this happens when the conversation ` +
263+ `goes idle before the viewer finishes loading. Call display_pdf again to get a fresh viewUUID.` ,
264+ ) ;
265+ }
266+ await new Promise ( ( r ) => setTimeout ( r , 100 ) ) ;
267+ }
268+ }
269+
235270// =============================================================================
236271// Pending save_as Requests (request-response bridge via client)
237272// =============================================================================
@@ -278,6 +313,15 @@ const commandQueues = new Map<string, QueueEntry>();
278313/** Waiters for long-poll: resolve callback wakes up a blocked poll_pdf_commands */
279314const pollWaiters = new Map < string , ( ) => void > ( ) ;
280315
316+ /**
317+ * viewUUIDs that have been polled at least once. A view missing from this set
318+ * means the iframe never reached startPolling() — usually because it wasn't
319+ * mounted yet, or ontoolresult threw before the poll loop started. Used to
320+ * fail fast in get_screenshot/get_text instead of waiting the full 45s for
321+ * a viewer that was never there.
322+ */
323+ const viewsPolled = new Set < string > ( ) ;
324+
281325/** Valid form field names per viewer UUID (populated during display_pdf) */
282326const viewFieldNames = new Map < string , Set < string > > ( ) ;
283327
@@ -323,6 +367,7 @@ function pruneStaleQueues(): void {
323367 commandQueues . delete ( uuid ) ;
324368 viewFieldNames . delete ( uuid ) ;
325369 viewFieldInfo . delete ( uuid ) ;
370+ viewsPolled . delete ( uuid ) ;
326371 stopFileWatch ( uuid ) ;
327372 }
328373 }
@@ -847,6 +892,10 @@ interface FormFieldInfo {
847892 y : number ;
848893 width : number ;
849894 height : number ;
895+ /** Radio button export value (buttonValue) — distinguishes widgets that share a field name. */
896+ exportValue ?: string ;
897+ /** Dropdown/listbox option values, as seen in the widget's `options` array. */
898+ options ?: string [ ] ;
850899}
851900
852901/**
@@ -899,6 +948,16 @@ async function extractFormFieldInfo(
899948 // Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height
900949 const modelY = pageHeight - y2 ;
901950
951+ // Choice widgets (combo/listbox) carry `options` as
952+ // [{exportValue, displayValue}]. Expose export values — that's
953+ // what fill_form needs.
954+ let options : string [ ] | undefined ;
955+ if ( Array . isArray ( ann . options ) && ann . options . length > 0 ) {
956+ options = ann . options
957+ . map ( ( o : { exportValue ?: string } ) => o ?. exportValue )
958+ . filter ( ( v : unknown ) : v is string => typeof v === "string" ) ;
959+ }
960+
902961 fields . push ( {
903962 name : fieldName ,
904963 type : fieldType ,
@@ -908,6 +967,12 @@ async function extractFormFieldInfo(
908967 width : Math . round ( width ) ,
909968 height : Math . round ( height ) ,
910969 ...( ann . alternativeText ? { label : ann . alternativeText } : undefined ) ,
970+ // Radio: buttonValue is the per-widget export value — the only
971+ // thing distinguishing three `size [Btn]` lines from each other.
972+ ...( ann . radioButton && ann . buttonValue != null
973+ ? { exportValue : String ( ann . buttonValue ) }
974+ : undefined ) ,
975+ ...( options ?. length ? { options } : undefined ) ,
911976 } ) ;
912977 }
913978 }
@@ -1317,6 +1382,14 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before
13171382 y : z . number ( ) ,
13181383 width : z . number ( ) ,
13191384 height : z . number ( ) ,
1385+ exportValue : z
1386+ . string ( )
1387+ . optional ( )
1388+ . describe ( "Radio button value — pass this to fill_form" ) ,
1389+ options : z
1390+ . array ( z . string ( ) )
1391+ . optional ( )
1392+ . describe ( "Dropdown/listbox option values" ) ,
13201393 } ) ,
13211394 )
13221395 . optional ( )
@@ -1488,8 +1561,14 @@ URL: ${normalized}`,
14881561 for ( const f of fields ) {
14891562 const label = f . label ? ` "${ f . label } "` : "" ;
14901563 const nameStr = f . name || "(unnamed)" ;
1564+ // Radio: =<exportValue> tells the model what value to pass.
1565+ // Dropdown: options:[...] lists valid choices.
1566+ const exportSuffix = f . exportValue ? `=${ f . exportValue } ` : "" ;
1567+ const optsSuffix = f . options
1568+ ? ` options:[${ f . options . join ( ", " ) } ]`
1569+ : "" ;
14911570 lines . push (
1492- ` ${ nameStr } ${ label } [${ f . type } ] at (${ f . x } ,${ f . y } ) ${ f . width } ×${ f . height } ` ,
1571+ ` ${ nameStr } ${ exportSuffix } ${ label } [${ f . type } ] at (${ f . x } ,${ f . y } ) ${ f . width } ×${ f . height } ${ optsSuffix } ` ,
14931572 ) ;
14941573 }
14951574 }
@@ -1973,6 +2052,7 @@ URL: ${normalized}`,
19732052
19742053 let pageData : PageDataEntry [ ] ;
19752054 try {
2055+ await ensureViewerIsPolling ( uuid ) ;
19762056 pageData = await waitForPageData ( requestId , signal ) ;
19772057 } catch ( err ) {
19782058 return {
@@ -2021,6 +2101,7 @@ URL: ${normalized}`,
20212101
20222102 let pageData : PageDataEntry [ ] ;
20232103 try {
2104+ await ensureViewerIsPolling ( uuid ) ;
20242105 pageData = await waitForPageData ( requestId , signal ) ;
20252106 } catch ( err ) {
20262107 return {
@@ -2105,6 +2186,7 @@ URL: ${normalized}`,
21052186 enqueueCommand ( uuid , { type : "save_as" , requestId } ) ;
21062187 let data : string ;
21072188 try {
2189+ await ensureViewerIsPolling ( uuid ) ;
21082190 data = await waitForSaveData ( requestId , signal ) ;
21092191 } catch ( err ) {
21102192 return {
@@ -2356,7 +2438,7 @@ Example — add a signature image and a stamp, then screenshot to verify:
23562438
23572439 // Process commands sequentially, collecting all content parts
23582440 const allContent : ContentPart [ ] = [ ] ;
2359- let hasError = false ;
2441+ let failedAt = - 1 ;
23602442
23612443 for ( let i = 0 ; i < commandList . length ; i ++ ) {
23622444 const result = await processInteractCommand (
@@ -2365,15 +2447,27 @@ Example — add a signature image and a stamp, then screenshot to verify:
23652447 extra . signal ,
23662448 ) ;
23672449 if ( result . isError ) {
2368- hasError = true ;
2450+ // Error content first. Some hosts flatten isError results to
2451+ // content[0].text — if we push the error after prior successes,
2452+ // the user sees "Queued: Filled 7 fields" with isError:true and
2453+ // the actual failure is silently dropped.
2454+ allContent . unshift ( ...result . content ) ;
2455+ failedAt = i ;
2456+ break ;
23692457 }
23702458 allContent . push ( ...result . content ) ;
2371- if ( hasError ) break ; // Stop on first error
2459+ }
2460+
2461+ if ( failedAt >= 0 && commandList . length > 1 ) {
2462+ allContent . unshift ( {
2463+ type : "text" ,
2464+ text : `Batch failed at step ${ failedAt + 1 } /${ commandList . length } (${ commandList [ failedAt ] . action } ):` ,
2465+ } ) ;
23722466 }
23732467
23742468 return {
23752469 content : allContent ,
2376- ...( hasError ? { isError : true } : { } ) ,
2470+ ...( failedAt >= 0 ? { isError : true } : { } ) ,
23772471 } ;
23782472 } ,
23792473 ) ;
@@ -2473,6 +2567,7 @@ Example — add a signature image and a stamp, then screenshot to verify:
24732567 _meta : { ui : { visibility : [ "app" ] } } ,
24742568 } ,
24752569 async ( { viewUUID : uuid } ) : Promise < CallToolResult > => {
2570+ viewsPolled . add ( uuid ) ;
24762571 // If commands are already queued, wait briefly to let more accumulate
24772572 if ( commandQueues . has ( uuid ) ) {
24782573 await new Promise ( ( r ) => setTimeout ( r , POLL_BATCH_WAIT_MS ) ) ;
0 commit comments