@@ -785,3 +785,180 @@ describe("file watching", () => {
785785 } ,
786786 ) ;
787787} ) ;
788+
789+ describe ( "interact tool" , ( ) => {
790+ // Helper: connected server+client pair with interact enabled.
791+ // Command queues are MODULE-LEVEL (shared across server instances), so each
792+ // test uses a distinct viewUUID to avoid cross-test interference.
793+ async function connect ( ) {
794+ const server = createServer ( { enableInteract : true } ) ;
795+ const client = new Client ( { name : "t" , version : "1" } ) ;
796+ const [ ct , st ] = InMemoryTransport . createLinkedPair ( ) ;
797+ await Promise . all ( [ server . connect ( st ) , client . connect ( ct ) ] ) ;
798+ return { server, client } ;
799+ }
800+
801+ // Helper: poll with an outer deadline so a failing test doesn't hang for the
802+ // full 30s long-poll. Safe ONLY when a command is already enqueued — poll
803+ // then returns after the 200ms batch window.
804+ async function poll ( client : Client , uuid : string , timeoutMs = 2000 ) {
805+ const result = await Promise . race ( [
806+ client . callTool ( {
807+ name : "poll_pdf_commands" ,
808+ arguments : { viewUUID : uuid } ,
809+ } ) ,
810+ new Promise < never > ( ( _ , reject ) =>
811+ setTimeout ( ( ) => reject ( new Error ( "poll timeout" ) ) , timeoutMs ) ,
812+ ) ,
813+ ] ) ;
814+ return (
815+ ( result as { structuredContent ?: { commands ?: unknown [ ] } } )
816+ . structuredContent ?. commands ?? [ ]
817+ ) as Array < Record < string , unknown > > ;
818+ }
819+
820+ function firstText ( r : Awaited < ReturnType < Client [ "callTool" ] > > ) : string {
821+ return ( r . content as Array < { type : string ; text : string } > ) [ 0 ] . text ;
822+ }
823+
824+ it ( "enqueue → poll roundtrip delivers the command" , async ( ) => {
825+ const { server, client } = await connect ( ) ;
826+ const uuid = "test-interact-roundtrip" ;
827+
828+ const r = await client . callTool ( {
829+ name : "interact" ,
830+ arguments : { viewUUID : uuid , action : "navigate" , page : 5 } ,
831+ } ) ;
832+ expect ( r . isError ) . toBeFalsy ( ) ;
833+ expect ( firstText ( r ) ) . toContain ( "Queued" ) ;
834+ expect ( firstText ( r ) ) . toContain ( "page 5" ) ;
835+
836+ // Core mechanism: the viewer polls for what the model enqueued.
837+ const cmds = await poll ( client , uuid ) ;
838+ expect ( cmds ) . toHaveLength ( 1 ) ;
839+ expect ( cmds [ 0 ] . type ) . toBe ( "navigate" ) ;
840+ expect ( cmds [ 0 ] . page ) . toBe ( 5 ) ;
841+
842+ await client . close ( ) ;
843+ await server . close ( ) ;
844+ } ) ;
845+
846+ it ( "navigate without `page` returns isError with a helpful message" , async ( ) => {
847+ const { server, client } = await connect ( ) ;
848+
849+ const r = await client . callTool ( {
850+ name : "interact" ,
851+ arguments : { viewUUID : "test-err-nav" , action : "navigate" } ,
852+ } ) ;
853+ expect ( r . isError ) . toBe ( true ) ;
854+ expect ( firstText ( r ) ) . toContain ( "navigate" ) ;
855+ expect ( firstText ( r ) ) . toContain ( "page" ) ;
856+
857+ await client . close ( ) ;
858+ await server . close ( ) ;
859+ } ) ;
860+
861+ it ( "fill_form without `fields` returns isError with a helpful message" , async ( ) => {
862+ const { server, client } = await connect ( ) ;
863+
864+ const r = await client . callTool ( {
865+ name : "interact" ,
866+ arguments : { viewUUID : "test-err-fill" , action : "fill_form" } ,
867+ } ) ;
868+ expect ( r . isError ) . toBe ( true ) ;
869+ expect ( firstText ( r ) ) . toContain ( "fill_form" ) ;
870+ expect ( firstText ( r ) ) . toContain ( "fields" ) ;
871+
872+ await client . close ( ) ;
873+ await server . close ( ) ;
874+ } ) ;
875+
876+ it ( "add_annotations without `annotations` returns isError with a helpful message" , async ( ) => {
877+ const { server, client } = await connect ( ) ;
878+
879+ const r = await client . callTool ( {
880+ name : "interact" ,
881+ arguments : { viewUUID : "test-err-ann" , action : "add_annotations" } ,
882+ } ) ;
883+ expect ( r . isError ) . toBe ( true ) ;
884+ expect ( firstText ( r ) ) . toContain ( "add_annotations" ) ;
885+ expect ( firstText ( r ) ) . toContain ( "annotations" ) ;
886+
887+ await client . close ( ) ;
888+ await server . close ( ) ;
889+ } ) ;
890+
891+ it ( "isolates command queues across distinct viewUUIDs" , async ( ) => {
892+ const { server, client } = await connect ( ) ;
893+ const uuidA = "test-isolate-A" ;
894+ const uuidB = "test-isolate-B" ;
895+
896+ await client . callTool ( {
897+ name : "interact" ,
898+ arguments : { viewUUID : uuidA , action : "navigate" , page : 3 } ,
899+ } ) ;
900+ await client . callTool ( {
901+ name : "interact" ,
902+ arguments : { viewUUID : uuidB , action : "search" , query : "quantum" } ,
903+ } ) ;
904+
905+ const cmdsA = await poll ( client , uuidA ) ;
906+ expect ( cmdsA ) . toHaveLength ( 1 ) ;
907+ expect ( cmdsA [ 0 ] . type ) . toBe ( "navigate" ) ;
908+ expect ( cmdsA [ 0 ] . page ) . toBe ( 3 ) ;
909+
910+ const cmdsB = await poll ( client , uuidB ) ;
911+ expect ( cmdsB ) . toHaveLength ( 1 ) ;
912+ expect ( cmdsB [ 0 ] . type ) . toBe ( "search" ) ;
913+ expect ( cmdsB [ 0 ] . query ) . toBe ( "quantum" ) ;
914+
915+ await client . close ( ) ;
916+ await server . close ( ) ;
917+ } ) ;
918+
919+ // SKIPPED: the unknown-UUID path enters the long-poll branch and blocks for
920+ // the full LONG_POLL_TIMEOUT_MS (30s, module-local const, not configurable).
921+ // The handler does dequeue [] at the end, so the return value IS
922+ // {commands: []} — but there's no fast path to reach it without waiting.
923+ // See the `stopFileWatch prevents further commands` test above for indirect
924+ // coverage of the same blocking behaviour.
925+ it . skip ( "poll with unknown viewUUID returns {commands: []} after long-poll" , ( ) => { } ) ;
926+
927+ it ( "fill_form passes all fields through when viewFieldNames is not registered" , async ( ) => {
928+ const { server, client } = await connect ( ) ;
929+ // Fresh UUID never seen by display_pdf → viewFieldNames.get(uuid) is
930+ // undefined → the known-fields guard (`knownFields && !knownFields.has()`)
931+ // is falsy for every field → everything is enqueued.
932+ const uuid = "test-fillform-passthrough" ;
933+
934+ const r = await client . callTool ( {
935+ name : "interact" ,
936+ arguments : {
937+ viewUUID : uuid ,
938+ action : "fill_form" ,
939+ fields : [
940+ { name : "anything" , value : "goes" } ,
941+ { name : "unchecked" , value : true } ,
942+ ] ,
943+ } ,
944+ } ) ;
945+ expect ( r . isError ) . toBeFalsy ( ) ;
946+ expect ( firstText ( r ) ) . toContain ( "Filled 2 field(s)" ) ;
947+ // No rejection complaint — the registry has no entry for this UUID
948+ expect ( firstText ( r ) ) . not . toContain ( "Unknown" ) ;
949+
950+ const cmds = await poll ( client , uuid ) ;
951+ expect ( cmds ) . toHaveLength ( 1 ) ;
952+ expect ( cmds [ 0 ] . type ) . toBe ( "fill_form" ) ;
953+ const fields = cmds [ 0 ] . fields as Array < { name : string ; value : unknown } > ;
954+ expect ( fields ) . toHaveLength ( 2 ) ;
955+ expect ( fields . map ( ( f ) => f . name ) . sort ( ) ) . toEqual ( [ "anything" , "unchecked" ] ) ;
956+
957+ // Note: the "registered → reject unknown" branch needs viewFieldNames
958+ // populated, which only happens inside display_pdf (requires a real PDF).
959+ // That map isn't exported, so the rejection path is covered by e2e only.
960+
961+ await client . close ( ) ;
962+ await server . close ( ) ;
963+ } ) ;
964+ } ) ;
0 commit comments