@@ -498,6 +498,256 @@ describe("Auto-detect transport from runtime info response", () => {
498498 } ) ;
499499} ) ;
500500
501+ describe ( "Auto-detect transport edge cases (AgentRegistry)" , ( ) => {
502+ const originalFetch = global . fetch ;
503+ const originalWindow = ( globalThis as { window ?: unknown } ) . window ;
504+
505+ const infoResponse = {
506+ version : "1.0.0" ,
507+ agents : {
508+ remote : {
509+ description : "Remote agent" ,
510+ } ,
511+ } ,
512+ } ;
513+
514+ beforeEach ( ( ) => {
515+ ( globalThis as { window ?: unknown } ) . window = { } ;
516+ } ) ;
517+
518+ afterEach ( ( ) => {
519+ vi . restoreAllMocks ( ) ;
520+ global . fetch = originalFetch ;
521+ if ( originalWindow === undefined ) {
522+ delete ( globalThis as { window ?: unknown } ) . window ;
523+ } else {
524+ ( globalThis as { window ?: unknown } ) . window = originalWindow ;
525+ }
526+ } ) ;
527+
528+ it ( "falls back to single-endpoint when REST probe returns 500 with JSON body" , async ( ) => {
529+ const runtimeUrl = "https://runtime.example/auto-500" ;
530+ const fetchMock = vi
531+ . fn ( )
532+ . mockImplementation ( ( url : string , init ?: RequestInit ) => {
533+ // REST attempt: GET /info → 500 with a JSON error body.
534+ // The bug: without the fix, the code treats any non-404/405 as REST
535+ // and parses this JSON as RuntimeInfo, corrupting the agent list.
536+ if (
537+ typeof url === "string" &&
538+ url . endsWith ( "/info" ) &&
539+ ( ! init ?. method || init . method === "GET" )
540+ ) {
541+ return Promise . resolve (
542+ new Response (
543+ JSON . stringify ( { error : "Internal Server Error" } ) ,
544+ { status : 500 , headers : { "content-type" : "application/json" } } ,
545+ ) ,
546+ ) ;
547+ }
548+ // Single-endpoint attempt: POST with { method: "info" }
549+ if ( init ?. method === "POST" ) {
550+ return Promise . resolve (
551+ new Response ( JSON . stringify ( infoResponse ) , {
552+ status : 200 ,
553+ headers : { "content-type" : "application/json" } ,
554+ } ) ,
555+ ) ;
556+ }
557+ return Promise . reject ( new Error ( "Unexpected fetch call" ) ) ;
558+ } ) ;
559+ // @ts -expect-error - override in test environment
560+ global . fetch = fetchMock ;
561+
562+ const core = new CopilotKitCore ( { runtimeUrl } ) ;
563+
564+ await vi . waitFor ( ( ) => {
565+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
566+ } ) ;
567+
568+ // First call: REST (GET /info → 500)
569+ const [ url1 ] = fetchMock . mock . calls [ 0 ] as [ string , RequestInit ] ;
570+ expect ( url1 ) . toBe ( `${ runtimeUrl } /info` ) ;
571+
572+ // Second call: single-endpoint (POST)
573+ const [ url2 , init2 ] = fetchMock . mock . calls [ 1 ] as [ string , RequestInit ] ;
574+ expect ( url2 ) . toBe ( runtimeUrl ) ;
575+ expect ( init2 . method ) . toBe ( "POST" ) ;
576+
577+ // Agent registered, transport resolved to "single"
578+ expect ( core . getAgent ( "remote" ) ) . toBeDefined ( ) ;
579+ expect ( core . runtimeTransport ) . toBe ( "single" ) ;
580+ } ) ;
581+
582+ it ( "falls back to single-endpoint when REST probe returns 403" , async ( ) => {
583+ const runtimeUrl = "https://runtime.example/auto-403" ;
584+ const fetchMock = vi
585+ . fn ( )
586+ . mockImplementation ( ( url : string , init ?: RequestInit ) => {
587+ if (
588+ typeof url === "string" &&
589+ url . endsWith ( "/info" ) &&
590+ ( ! init ?. method || init . method === "GET" )
591+ ) {
592+ return Promise . resolve ( new Response ( "Forbidden" , { status : 403 } ) ) ;
593+ }
594+ if ( init ?. method === "POST" ) {
595+ return Promise . resolve (
596+ new Response ( JSON . stringify ( infoResponse ) , {
597+ status : 200 ,
598+ headers : { "content-type" : "application/json" } ,
599+ } ) ,
600+ ) ;
601+ }
602+ return Promise . reject ( new Error ( "Unexpected fetch call" ) ) ;
603+ } ) ;
604+ // @ts -expect-error - override in test environment
605+ global . fetch = fetchMock ;
606+
607+ const core = new CopilotKitCore ( { runtimeUrl } ) ;
608+
609+ await vi . waitFor ( ( ) => {
610+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
611+ } ) ;
612+
613+ expect ( core . getAgent ( "remote" ) ) . toBeDefined ( ) ;
614+ expect ( core . runtimeTransport ) . toBe ( "single" ) ;
615+ } ) ;
616+
617+ it ( "falls back to single-endpoint when REST probe throws a network error" , async ( ) => {
618+ const runtimeUrl = "https://runtime.example/auto-net-err" ;
619+ const fetchMock = vi
620+ . fn ( )
621+ . mockImplementation ( ( url : string , init ?: RequestInit ) => {
622+ if (
623+ typeof url === "string" &&
624+ url . endsWith ( "/info" ) &&
625+ ( ! init ?. method || init . method === "GET" )
626+ ) {
627+ return Promise . reject ( new TypeError ( "Failed to fetch" ) ) ;
628+ }
629+ if ( init ?. method === "POST" ) {
630+ return Promise . resolve (
631+ new Response ( JSON . stringify ( infoResponse ) , {
632+ status : 200 ,
633+ headers : { "content-type" : "application/json" } ,
634+ } ) ,
635+ ) ;
636+ }
637+ return Promise . reject ( new Error ( "Unexpected fetch call" ) ) ;
638+ } ) ;
639+ // @ts -expect-error - override in test environment
640+ global . fetch = fetchMock ;
641+
642+ const core = new CopilotKitCore ( { runtimeUrl } ) ;
643+
644+ await vi . waitFor ( ( ) => {
645+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
646+ } ) ;
647+
648+ expect ( core . getAgent ( "remote" ) ) . toBeDefined ( ) ;
649+ expect ( core . runtimeTransport ) . toBe ( "single" ) ;
650+ } ) ;
651+
652+ it ( "reports error when both REST and single-endpoint probes fail" , async ( ) => {
653+ const runtimeUrl = "https://runtime.example/auto-both-fail" ;
654+ const fetchMock = vi
655+ . fn ( )
656+ . mockImplementation ( ( url : string , init ?: RequestInit ) => {
657+ if (
658+ typeof url === "string" &&
659+ url . endsWith ( "/info" ) &&
660+ ( ! init ?. method || init . method === "GET" )
661+ ) {
662+ return Promise . resolve ( new Response ( "Not Found" , { status : 404 } ) ) ;
663+ }
664+ if ( init ?. method === "POST" ) {
665+ return Promise . resolve (
666+ new Response ( "Internal Server Error" , { status : 500 } ) ,
667+ ) ;
668+ }
669+ return Promise . reject ( new Error ( "Unexpected fetch call" ) ) ;
670+ } ) ;
671+ // @ts -expect-error - override in test environment
672+ global . fetch = fetchMock ;
673+
674+ const errorSpy = vi . fn ( ) ;
675+ const core = new CopilotKitCore ( { runtimeUrl } ) ;
676+ core . subscribe ( {
677+ onError : errorSpy ,
678+ } ) ;
679+
680+ await vi . waitFor ( ( ) => {
681+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
682+ } ) ;
683+
684+ // Should have emitted an error since single-endpoint also returned 500
685+ // The connection status should be Error
686+ await vi . waitFor ( ( ) => {
687+ expect ( core . runtimeConnectionStatus ) . toBe ( "error" ) ;
688+ } ) ;
689+ } ) ;
690+
691+ it ( "falls back to single-endpoint when REST probe returns 405" , async ( ) => {
692+ const runtimeUrl = "https://runtime.example/auto-405" ;
693+ const fetchMock = vi
694+ . fn ( )
695+ . mockImplementation ( ( url : string , init ?: RequestInit ) => {
696+ if (
697+ typeof url === "string" &&
698+ url . endsWith ( "/info" ) &&
699+ ( ! init ?. method || init . method === "GET" )
700+ ) {
701+ return Promise . resolve (
702+ new Response ( "Method Not Allowed" , { status : 405 } ) ,
703+ ) ;
704+ }
705+ if ( init ?. method === "POST" ) {
706+ return Promise . resolve (
707+ new Response ( JSON . stringify ( infoResponse ) , {
708+ status : 200 ,
709+ headers : { "content-type" : "application/json" } ,
710+ } ) ,
711+ ) ;
712+ }
713+ return Promise . reject ( new Error ( "Unexpected fetch call" ) ) ;
714+ } ) ;
715+ // @ts -expect-error - override in test environment
716+ global . fetch = fetchMock ;
717+
718+ const core = new CopilotKitCore ( { runtimeUrl } ) ;
719+
720+ await vi . waitFor ( ( ) => {
721+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
722+ } ) ;
723+
724+ expect ( core . getAgent ( "remote" ) ) . toBeDefined ( ) ;
725+ expect ( core . runtimeTransport ) . toBe ( "single" ) ;
726+ } ) ;
727+ } ) ;
728+
729+ describe ( "ProxiedCopilotRuntimeAgent construction and defaults" , ( ) => {
730+ it ( "defaults transport to 'auto' when not specified" , ( ) => {
731+ const agent = new ProxiedCopilotRuntimeAgent ( {
732+ runtimeUrl : "https://runtime.example/default" ,
733+ agentId : "test-agent" ,
734+ } ) ;
735+ // The agent should have been created without throwing.
736+ // When transport is "auto", the URL is set as REST-style initially.
737+ expect ( agent ) . toBeDefined ( ) ;
738+ expect ( agent . agentId ) . toBe ( "test-agent" ) ;
739+ } ) ;
740+
741+ it ( "normalizes trailing slashes on runtimeUrl" , ( ) => {
742+ const agent = new ProxiedCopilotRuntimeAgent ( {
743+ runtimeUrl : "https://runtime.example/trailing/" ,
744+ agentId : "test-agent" ,
745+ transport : "rest" ,
746+ } ) ;
747+ expect ( agent . runtimeUrl ) . toBe ( "https://runtime.example/trailing" ) ;
748+ } ) ;
749+ } ) ;
750+
501751describe ( "AgentRegistry runtime info requests" , ( ) => {
502752 const originalFetch = global . fetch ;
503753 const originalWindow = ( globalThis as { window ?: unknown } ) . window ;
0 commit comments