From 0a65f70f853342cbf0c259a1c1934b5d2e982bc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 11:02:53 +0000 Subject: [PATCH] feat: auto-detect single-endpoint transport from runtime info response Instead of requiring users to explicitly set transport: "single", the client now auto-detects the transport mode by trying REST (GET /info) first and falling back to single-endpoint (POST with { method: "info" }) if the REST probe returns 404/405 or fails. The explicit flag is kept for backward compatibility. https://claude.ai/code/session_016xtYN15TY2BaBviryTcVyY --- packages/angular/src/lib/copilotkit.ts | 2 +- .../proxied-runtime-transport.test.ts | 194 ++++++++++++++++++ packages/core/src/agent.ts | 45 +++- packages/core/src/core/agent-registry.ts | 95 +++++++-- packages/core/src/core/core.ts | 2 +- packages/core/src/types.ts | 2 +- .../src/v2/providers/CopilotKitProvider.tsx | 17 +- 7 files changed, 334 insertions(+), 23 deletions(-) diff --git a/packages/angular/src/lib/copilotkit.ts b/packages/angular/src/lib/copilotkit.ts index 0594953622..7bc355177b 100644 --- a/packages/angular/src/lib/copilotkit.ts +++ b/packages/angular/src/lib/copilotkit.ts @@ -39,7 +39,7 @@ export class CopilotKit { readonly runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly(); readonly #runtimeUrl = signal(undefined); readonly runtimeUrl = this.#runtimeUrl.asReadonly(); - readonly #runtimeTransport = signal("rest"); + readonly #runtimeTransport = signal("auto"); readonly runtimeTransport = this.#runtimeTransport.asReadonly(); readonly #headers = signal>({}); readonly headers = this.#headers.asReadonly(); diff --git a/packages/core/src/__tests__/proxied-runtime-transport.test.ts b/packages/core/src/__tests__/proxied-runtime-transport.test.ts index d8a46e372b..be077a6e00 100644 --- a/packages/core/src/__tests__/proxied-runtime-transport.test.ts +++ b/packages/core/src/__tests__/proxied-runtime-transport.test.ts @@ -304,6 +304,200 @@ function createSseResponse(): Response { }); } +describe("Auto-detect transport from runtime info response", () => { + 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("auto-detects REST transport when GET /info succeeds", async () => { + const runtimeUrl = "https://runtime.example/rest-auto"; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + // No runtimeTransport specified — defaults to "auto" + const core = new CopilotKitCore({ + runtimeUrl, + headers: { Authorization: "Bearer token" }, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + // Should have tried REST first (GET /info) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${runtimeUrl}/info`); + expect(init.method ?? "GET").toBe("GET"); + + // Remote agent should be registered + const remoteAgent = core.getAgent("remote"); + expect(remoteAgent).toBeDefined(); + expect(remoteAgent?.agentId).toBe("remote"); + + // Transport should have been resolved to "rest" + expect(core.runtimeTransport).toBe("rest"); + }); + + it("auto-detects single-endpoint transport when GET /info fails", async () => { + const runtimeUrl = "https://runtime.example/single-auto"; + const fetchMock = vi + .fn() + .mockImplementation((url: string, init?: RequestInit) => { + // REST attempt: GET /info → 404 + if ( + typeof url === "string" && + url.endsWith("/info") && + (!init?.method || init.method === "GET") + ) { + return Promise.resolve(new Response("Not Found", { status: 404 })); + } + // 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; + + // No runtimeTransport specified — defaults to "auto" + const core = new CopilotKitCore({ + runtimeUrl, + headers: { Authorization: "Bearer token" }, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + // First call should be REST attempt (GET /info) + const [url1] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url1).toBe(`${runtimeUrl}/info`); + + // Second call should be single-endpoint attempt (POST with { method: "info" }) + const [url2, init2] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(url2).toBe(runtimeUrl); + expect(init2.method).toBe("POST"); + const body = JSON.parse(init2.body as string); + expect(body).toEqual({ method: "info" }); + + // Remote agent should be registered + const remoteAgent = core.getAgent("remote"); + expect(remoteAgent).toBeDefined(); + expect(remoteAgent?.agentId).toBe("remote"); + + // Transport should have been resolved to "single" + expect(core.runtimeTransport).toBe("single"); + }); + + it("explicit transport='single' flag still works without auto-detection", async () => { + const runtimeUrl = "https://runtime.example/explicit-single"; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const core = new CopilotKitCore({ + runtimeUrl, + runtimeTransport: "single", + headers: { Authorization: "Bearer token" }, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + // Should have gone directly to single-endpoint (POST with { method: "info" }) + // without trying REST first + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(runtimeUrl); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ method: "info" }); + + // Remote agent should be registered + const remoteAgent = core.getAgent("remote"); + expect(remoteAgent).toBeDefined(); + expect(remoteAgent?.agentId).toBe("remote"); + + // Transport stays "single" + expect(core.runtimeTransport).toBe("single"); + }); + + it("explicit transport='rest' flag still works without auto-detection", async () => { + const runtimeUrl = "https://runtime.example/explicit-rest"; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(infoResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + // @ts-expect-error - override in test environment + global.fetch = fetchMock; + + const core = new CopilotKitCore({ + runtimeUrl, + runtimeTransport: "rest", + headers: { Authorization: "Bearer token" }, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + // Should have gone directly to REST (GET /info) without trying single + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${runtimeUrl}/info`); + expect(init.method ?? "GET").toBe("GET"); + + // Remote agent should be registered + const remoteAgent = core.getAgent("remote"); + expect(remoteAgent).toBeDefined(); + + // Transport stays "rest" + expect(core.runtimeTransport).toBe("rest"); + }); +}); + 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 cc7b63114a..816e911575 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -95,7 +95,7 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent { const normalizedRuntimeUrl = config.runtimeUrl ? config.runtimeUrl.replace(/\/$/, "") : undefined; - const transport = config.transport ?? "rest"; + const transport = config.transport ?? "auto"; const runUrl = transport === "single" ? (normalizedRuntimeUrl ?? config.runtimeUrl ?? "") @@ -403,6 +403,10 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent { ...this.headers, }; + if (this.transport === "auto") { + return this.fetchRuntimeInfoAutoDetect(headers); + } + let init: RequestInit; let url: string; @@ -433,6 +437,45 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent { return (await response.json()) as RuntimeInfo; } + private async fetchRuntimeInfoAutoDetect( + headers: Record, + ): Promise { + // Try REST first (GET /info) + try { + const response = await fetch(`${this.runtimeUrl}/info`, { + headers: { ...headers }, + ...(this.credentials ? { credentials: this.credentials } : {}), + }); + const status = "status" in response ? (response as Response).status : 200; + if (status !== 404 && status !== 405) { + this.transport = "rest"; + return (await response.json()) as RuntimeInfo; + } + } catch { + // REST failed — fall through to single-endpoint attempt + } + + // Try single-endpoint (POST with { method: "info" }) + const singleHeaders = { ...headers }; + if (!singleHeaders["Content-Type"]) { + singleHeaders["Content-Type"] = "application/json"; + } + const response = await fetch(this.runtimeUrl!, { + method: "POST", + headers: singleHeaders, + body: JSON.stringify({ method: "info" }), + ...(this.credentials ? { credentials: this.credentials } : {}), + }); + if ("ok" in response && !response.ok) { + throw new Error( + `Runtime info request failed with status ${response.status}`, + ); + } + this.transport = "single"; + this.singleEndpointUrl = this.runtimeUrl; + return (await response.json()) as RuntimeInfo; + } + private createSingleRouteRequestInit( input: RunAgentInput, method: string, diff --git a/packages/core/src/core/agent-registry.ts b/packages/core/src/core/agent-registry.ts index 767990905f..166d2496c3 100644 --- a/packages/core/src/core/agent-registry.ts +++ b/packages/core/src/core/agent-registry.ts @@ -36,7 +36,7 @@ export class AgentRegistry { private _runtimeVersion?: string; private _runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Disconnected; - private _runtimeTransport: CopilotRuntimeTransport = "rest"; + private _runtimeTransport: CopilotRuntimeTransport = "auto"; private _audioFileTranscriptionEnabled: boolean = false; private _runtimeMode: RuntimeMode = RUNTIME_MODE_SSE; private _intelligence?: IntelligenceRuntimeInfo; @@ -347,23 +347,14 @@ export class AgentRegistry { }; if (this._runtimeTransport === "single") { - if (!headers["Content-Type"]) { - headers["Content-Type"] = "application/json"; - } - const response = await fetch(this.runtimeUrl, { - method: "POST", - headers, - body: JSON.stringify({ method: "info" }), - ...(credentials ? { credentials } : {}), - }); - if ("ok" in response && !(response as Response).ok) { - throw new Error( - `Runtime info request failed with status ${response.status}`, - ); - } - return (await response.json()) as RuntimeInfo; + return this.fetchRuntimeInfoSingle(headers, credentials); } + if (this._runtimeTransport === "auto") { + return this.fetchRuntimeInfoAutoDetect(headers, credentials); + } + + // REST transport const response = await fetch(`${this.runtimeUrl}/info`, { headers, ...(credentials ? { credentials } : {}), @@ -376,6 +367,78 @@ export class AgentRegistry { return (await response.json()) as RuntimeInfo; } + private async fetchRuntimeInfoSingle( + headers: Record, + credentials: RequestCredentials | undefined, + ): Promise { + if (!headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + const response = await fetch(this.runtimeUrl!, { + method: "POST", + headers, + body: JSON.stringify({ method: "info" }), + ...(credentials ? { credentials } : {}), + }); + if ("ok" in response && !(response as Response).ok) { + throw new Error( + `Runtime info request failed with status ${response.status}`, + ); + } + 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. + */ + private async fetchRuntimeInfoAutoDetect( + headers: Record, + credentials: RequestCredentials | undefined, + ): Promise { + // Try REST first (GET /info) + try { + const response = await fetch(`${this.runtimeUrl}/info`, { + 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. + const status = "status" in response ? (response as Response).status : 200; + if (status === 404 || status === 405) { + // Not a REST runtime — try single-endpoint below + } else { + this._runtimeTransport = "rest"; + return (await response.json()) as RuntimeInfo; + } + } catch { + // REST failed (network error, etc.) — fall through to single-endpoint attempt + } + + // Try single-endpoint (POST with { method: "info" }) + const singleHeaders = { ...headers }; + if (!singleHeaders["Content-Type"]) { + singleHeaders["Content-Type"] = "application/json"; + } + const response = await fetch(this.runtimeUrl!, { + method: "POST", + headers: singleHeaders, + body: JSON.stringify({ method: "info" }), + ...(credentials ? { credentials } : {}), + }); + if ("ok" in response && !(response as Response).ok) { + throw new Error( + `Runtime info request failed with status ${response.status}`, + ); + } + this._runtimeTransport = "single"; + return (await response.json()) as RuntimeInfo; + } + /** * Assign agent IDs to a record of agents */ diff --git a/packages/core/src/core/core.ts b/packages/core/src/core/core.ts index bf98b01c7e..3ae3ff2b9b 100644 --- a/packages/core/src/core/core.ts +++ b/packages/core/src/core/core.ts @@ -230,7 +230,7 @@ export class CopilotKitCore { constructor({ runtimeUrl, - runtimeTransport = "rest", + runtimeTransport = "auto", headers = {}, credentials, properties = {}, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fd7f54d730..df951dd26a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,7 +15,7 @@ export enum ToolCallStatus { Complete = "complete", } -export type CopilotRuntimeTransport = "rest" | "single"; +export type CopilotRuntimeTransport = "rest" | "single" | "auto"; export type { RuntimeMode, IntelligenceRuntimeInfo, RuntimeLicenseStatus }; /** diff --git a/packages/react-core/src/v2/providers/CopilotKitProvider.tsx b/packages/react-core/src/v2/providers/CopilotKitProvider.tsx index 74c6a6b206..9209335648 100644 --- a/packages/react-core/src/v2/providers/CopilotKitProvider.tsx +++ b/packages/react-core/src/v2/providers/CopilotKitProvider.tsx @@ -172,7 +172,7 @@ export const CopilotKitProvider: React.FC = ({ frontendTools, humanInTheLoop, showDevConsole = false, - useSingleEndpoint = false, + useSingleEndpoint, onError, a2ui, }) => { @@ -389,7 +389,12 @@ export const CopilotKitProvider: React.FC = ({ if (copilotkitRef.current === null) { copilotkitRef.current = new CopilotKitCoreReact({ runtimeUrl: chatApiEndpoint, - runtimeTransport: useSingleEndpoint ? "single" : "rest", + runtimeTransport: + useSingleEndpoint === true + ? "single" + : useSingleEndpoint === false + ? "rest" + : "auto", headers: mergedHeaders, credentials, properties, @@ -490,7 +495,13 @@ export const CopilotKitProvider: React.FC = ({ useEffect(() => { copilotkit.setRuntimeUrl(chatApiEndpoint); - copilotkit.setRuntimeTransport(useSingleEndpoint ? "single" : "rest"); + copilotkit.setRuntimeTransport( + useSingleEndpoint === true + ? "single" + : useSingleEndpoint === false + ? "rest" + : "auto", + ); copilotkit.setHeaders(mergedHeaders); copilotkit.setCredentials(credentials); copilotkit.setProperties(properties);