From 6fa01fb8a1275981bc71482bbc9f5b082bf45dbd Mon Sep 17 00:00:00 2001 From: Ran Shem Tov Date: Fri, 3 Apr 2026 14:54:08 +0200 Subject: [PATCH] test: add edge case coverage and fix auto-detect status check - Fix fetchRuntimeInfoAutoDetect in agent.ts to use 2xx-only check (was checking only 404/405, now aligns with agent-registry.ts) - Remove duplicate JSDoc block on fetchRuntimeInfoAutoDetect - Add 7 edge case tests: 500/403/405/network-error/both-fail scenarios - Add 4 tests for useSingleEndpoint->runtimeTransport ternary mapping - Update Angular test stubs to use "auto" default (was "rest") Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/angular/src/lib/agent.spec.ts | 6 +- packages/angular/src/lib/copilotkit.spec.ts | 2 +- .../proxied-runtime-transport.test.ts | 250 ++++++++++++++++++ packages/core/src/agent.ts | 5 +- packages/core/src/core/agent-registry.ts | 14 +- .../__tests__/CopilotKitProvider.test.tsx | 70 +++++ 6 files changed, 333 insertions(+), 14 deletions(-) diff --git a/packages/angular/src/lib/agent.spec.ts b/packages/angular/src/lib/agent.spec.ts index b2d7c391f1..fb0cc046ce 100644 --- a/packages/angular/src/lib/agent.spec.ts +++ b/packages/angular/src/lib/agent.spec.ts @@ -70,7 +70,7 @@ class CopilotKitStub { CopilotKitCoreRuntimeConnectionStatus.Disconnected, ); readonly #runtimeUrl = signal(undefined); - readonly #runtimeTransport = signal<"rest" | "single">("rest"); + readonly #runtimeTransport = signal<"rest" | "single" | "auto">("auto"); readonly #headers = signal>({}); getAgent = vi.fn((id: string) => this.#agents()[id]); agents = this.#agents.asReadonly(); @@ -80,7 +80,7 @@ class CopilotKitStub { headers = this.#headers.asReadonly(); core = { runtimeUrl: undefined as string | undefined, - runtimeTransport: "rest" as const, + runtimeTransport: "auto" as const, runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected, headers: {} as Record, }; @@ -105,7 +105,7 @@ class CopilotKitStub { this.core = { ...this.core, headers: value }; } - setRuntimeTransport(value: "rest" | "single") { + setRuntimeTransport(value: "rest" | "single" | "auto") { this.#runtimeTransport.set(value); this.core = { ...this.core, runtimeTransport: value }; } diff --git a/packages/angular/src/lib/copilotkit.spec.ts b/packages/angular/src/lib/copilotkit.spec.ts index c23d3ce87f..7d55639d1e 100644 --- a/packages/angular/src/lib/copilotkit.spec.ts +++ b/packages/angular/src/lib/copilotkit.spec.ts @@ -41,7 +41,7 @@ vi.mock("@copilotkit/core", () => { readonly getAgent = mockGetAgent; agents: Record = {}; runtimeUrl = undefined; - runtimeTransport = "rest"; + runtimeTransport = "auto"; headers: Record = {}; runtimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Disconnected; diff --git a/packages/core/src/__tests__/proxied-runtime-transport.test.ts b/packages/core/src/__tests__/proxied-runtime-transport.test.ts index be077a6e00..1667243b86 100644 --- a/packages/core/src/__tests__/proxied-runtime-transport.test.ts +++ b/packages/core/src/__tests__/proxied-runtime-transport.test.ts @@ -498,6 +498,256 @@ describe("Auto-detect transport from runtime info response", () => { }); }); +describe("Auto-detect transport edge cases (AgentRegistry)", () => { + const originalFetch = global.fetch; + const originalWindow = (globalThis as { window?: unknown }).window; + + const infoResponse = { + version: "1.0.0", + agents: { + remote: { + description: "Remote agent", + }, + }, + }; + + beforeEach(() => { + (globalThis as { window?: unknown }).window = {}; + }); + + afterEach(() => { + vi.restoreAllMocks(); + global.fetch = originalFetch; + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = originalWindow; + } + }); + + it("falls back to single-endpoint when REST probe returns 500 with JSON body", async () => { + const runtimeUrl = "https://runtime.example/auto-500"; + const fetchMock = vi + .fn() + .mockImplementation((url: string, init?: RequestInit) => { + // REST attempt: GET /info → 500 with a JSON error body. + // The bug: without the fix, the code treats any non-404/405 as REST + // and parses this JSON as RuntimeInfo, corrupting the agent list. + if ( + typeof url === "string" && + url.endsWith("/info") && + (!init?.method || init.method === "GET") + ) { + return Promise.resolve( + new Response( + JSON.stringify({ error: "Internal Server Error" }), + { status: 500, headers: { "content-type": "application/json" } }, + ), + ); + } + // Single-endpoint attempt: POST with { method: "info" } + if (init?.method === "POST") { + return Promise.resolve( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error("Unexpected fetch call")); + }); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const core = new CopilotKitCore({ runtimeUrl }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + // First call: REST (GET /info → 500) + const [url1] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url1).toBe(`${runtimeUrl}/info`); + + // Second call: single-endpoint (POST) + const [url2, init2] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(url2).toBe(runtimeUrl); + expect(init2.method).toBe("POST"); + + // Agent registered, transport resolved to "single" + expect(core.getAgent("remote")).toBeDefined(); + expect(core.runtimeTransport).toBe("single"); + }); + + it("falls back to single-endpoint when REST probe returns 403", async () => { + const runtimeUrl = "https://runtime.example/auto-403"; + const fetchMock = vi + .fn() + .mockImplementation((url: string, init?: RequestInit) => { + if ( + typeof url === "string" && + url.endsWith("/info") && + (!init?.method || init.method === "GET") + ) { + return Promise.resolve(new Response("Forbidden", { status: 403 })); + } + if (init?.method === "POST") { + return Promise.resolve( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error("Unexpected fetch call")); + }); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const core = new CopilotKitCore({ runtimeUrl }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + expect(core.getAgent("remote")).toBeDefined(); + expect(core.runtimeTransport).toBe("single"); + }); + + it("falls back to single-endpoint when REST probe throws a network error", async () => { + const runtimeUrl = "https://runtime.example/auto-net-err"; + const fetchMock = vi + .fn() + .mockImplementation((url: string, init?: RequestInit) => { + if ( + typeof url === "string" && + url.endsWith("/info") && + (!init?.method || init.method === "GET") + ) { + return Promise.reject(new TypeError("Failed to fetch")); + } + if (init?.method === "POST") { + return Promise.resolve( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error("Unexpected fetch call")); + }); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const core = new CopilotKitCore({ runtimeUrl }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + expect(core.getAgent("remote")).toBeDefined(); + expect(core.runtimeTransport).toBe("single"); + }); + + it("reports error when both REST and single-endpoint probes fail", async () => { + const runtimeUrl = "https://runtime.example/auto-both-fail"; + const fetchMock = vi + .fn() + .mockImplementation((url: string, init?: RequestInit) => { + if ( + typeof url === "string" && + url.endsWith("/info") && + (!init?.method || init.method === "GET") + ) { + return Promise.resolve(new Response("Not Found", { status: 404 })); + } + if (init?.method === "POST") { + return Promise.resolve( + new Response("Internal Server Error", { status: 500 }), + ); + } + return Promise.reject(new Error("Unexpected fetch call")); + }); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const errorSpy = vi.fn(); + const core = new CopilotKitCore({ runtimeUrl }); + core.subscribe({ + onError: errorSpy, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + // Should have emitted an error since single-endpoint also returned 500 + // The connection status should be Error + await vi.waitFor(() => { + expect(core.runtimeConnectionStatus).toBe("error"); + }); + }); + + it("falls back to single-endpoint when REST probe returns 405", async () => { + const runtimeUrl = "https://runtime.example/auto-405"; + const fetchMock = vi + .fn() + .mockImplementation((url: string, init?: RequestInit) => { + if ( + typeof url === "string" && + url.endsWith("/info") && + (!init?.method || init.method === "GET") + ) { + return Promise.resolve( + new Response("Method Not Allowed", { status: 405 }), + ); + } + if (init?.method === "POST") { + return Promise.resolve( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error("Unexpected fetch call")); + }); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const core = new CopilotKitCore({ runtimeUrl }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + expect(core.getAgent("remote")).toBeDefined(); + expect(core.runtimeTransport).toBe("single"); + }); +}); + +describe("ProxiedCopilotRuntimeAgent construction and defaults", () => { + it("defaults transport to 'auto' when not specified", () => { + const agent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl: "https://runtime.example/default", + agentId: "test-agent", + }); + // The agent should have been created without throwing. + // When transport is "auto", the URL is set as REST-style initially. + expect(agent).toBeDefined(); + expect(agent.agentId).toBe("test-agent"); + }); + + it("normalizes trailing slashes on runtimeUrl", () => { + const agent = new ProxiedCopilotRuntimeAgent({ + runtimeUrl: "https://runtime.example/trailing/", + agentId: "test-agent", + transport: "rest", + }); + expect(agent.runtimeUrl).toBe("https://runtime.example/trailing"); + }); +}); + describe("AgentRegistry runtime info requests", () => { const originalFetch = global.fetch; const originalWindow = (globalThis as { window?: unknown }).window; diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 816e911575..f57b141aed 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -446,8 +446,11 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent { headers: { ...headers }, ...(this.credentials ? { credentials: this.credentials } : {}), }); + // Only treat a successful (2xx) response as a valid REST runtime. + // 404/405 means the endpoint doesn't exist; other non-2xx errors + // (500, 403, etc.) should also fall through to single-endpoint. const status = "status" in response ? (response as Response).status : 200; - if (status !== 404 && status !== 405) { + if (status >= 200 && status < 300) { this.transport = "rest"; return (await response.json()) as RuntimeInfo; } diff --git a/packages/core/src/core/agent-registry.ts b/packages/core/src/core/agent-registry.ts index 166d2496c3..b90b92a3cf 100644 --- a/packages/core/src/core/agent-registry.ts +++ b/packages/core/src/core/agent-registry.ts @@ -388,10 +388,6 @@ export class AgentRegistry { return (await response.json()) as RuntimeInfo; } - /** - * Auto-detect transport by trying REST first, then falling back to single-endpoint. - * Updates `_runtimeTransport` to the detected value so subsequent requests use it directly. - */ /** * Auto-detect transport by trying REST first, then falling back to single-endpoint. * Updates `_runtimeTransport` to the detected value so subsequent requests use it directly. @@ -406,15 +402,15 @@ export class AgentRegistry { headers: { ...headers }, ...(credentials ? { credentials } : {}), }); - // If the response indicates a clear failure (404/405), the endpoint - // doesn't exist — fall through to the single-endpoint probe. + // Only treat a successful (2xx) response as a valid REST runtime. + // 404/405 means the endpoint doesn't exist; other non-2xx errors + // (500, 403, etc.) should also fall through to single-endpoint. const status = "status" in response ? (response as Response).status : 200; - if (status === 404 || status === 405) { - // Not a REST runtime — try single-endpoint below - } else { + if (status >= 200 && status < 300) { this._runtimeTransport = "rest"; return (await response.json()) as RuntimeInfo; } + // Non-2xx — try single-endpoint below } catch { // REST failed (network error, etc.) — fall through to single-endpoint attempt } diff --git a/packages/react-core/src/v2/providers/__tests__/CopilotKitProvider.test.tsx b/packages/react-core/src/v2/providers/__tests__/CopilotKitProvider.test.tsx index 1d47e89376..df11efb01a 100644 --- a/packages/react-core/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +++ b/packages/react-core/src/v2/providers/__tests__/CopilotKitProvider.test.tsx @@ -573,6 +573,76 @@ describe("CopilotKitProvider", () => { }); }); + describe("useSingleEndpoint → runtimeTransport mapping", () => { + it("maps useSingleEndpoint=true to 'single' transport", () => { + const { result } = renderHook(() => useCopilotKit(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.copilotkit.runtimeTransport).toBe("single"); + }); + + it("maps useSingleEndpoint=false to 'rest' transport", () => { + const { result } = renderHook(() => useCopilotKit(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.copilotkit.runtimeTransport).toBe("rest"); + }); + + it("maps omitted useSingleEndpoint to 'auto' transport", () => { + const { result } = renderHook(() => useCopilotKit(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current.copilotkit.runtimeTransport).toBe("auto"); + }); + + it("updates transport when useSingleEndpoint prop changes", () => { + let capturedCopilotkit: ReturnType["copilotkit"]; + + function Collector({ children }: { children?: React.ReactNode }) { + const { copilotkit } = useCopilotKit(); + capturedCopilotkit = copilotkit; + return <>{children}; + } + + const { rerender } = render( + + + , + ); + + expect(capturedCopilotkit!.runtimeTransport).toBe("rest"); + + rerender( + + + , + ); + + expect(capturedCopilotkit!.runtimeTransport).toBe("single"); + + rerender( + + + , + ); + + expect(capturedCopilotkit!.runtimeTransport).toBe("auto"); + }); + }); + describe("Edge cases", () => { it("handles empty arrays for tools", () => { const { result } = renderHook(() => useCopilotKit(), {