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
This commit is contained in:
Claude
2026-04-03 11:02:53 +00:00
committed by Ran Shem Tov
parent f4f74a90b5
commit 0a65f70f85
7 changed files with 334 additions and 23 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ export class CopilotKit {
readonly runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly();
readonly #runtimeUrl = signal<string | undefined>(undefined);
readonly runtimeUrl = this.#runtimeUrl.asReadonly();
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("rest");
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("auto");
readonly runtimeTransport = this.#runtimeTransport.asReadonly();
readonly #headers = signal<Record<string, string>>({});
readonly headers = this.#headers.asReadonly();
@@ -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;
+44 -1
View File
@@ -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<string, string>,
): Promise<RuntimeInfo> {
// 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,
+79 -16
View File
@@ -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<string, string>,
credentials: RequestCredentials | undefined,
): Promise<RuntimeInfo> {
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<string, string>,
credentials: RequestCredentials | undefined,
): Promise<RuntimeInfo> {
// 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
*/
+1 -1
View File
@@ -230,7 +230,7 @@ export class CopilotKitCore {
constructor({
runtimeUrl,
runtimeTransport = "rest",
runtimeTransport = "auto",
headers = {},
credentials,
properties = {},
+1 -1
View File
@@ -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 };
/**
@@ -172,7 +172,7 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
frontendTools,
humanInTheLoop,
showDevConsole = false,
useSingleEndpoint = false,
useSingleEndpoint,
onError,
a2ui,
}) => {
@@ -389,7 +389,12 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
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<CopilotKitProviderProps> = ({
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);