Skip to content

Commit 0a65f70

Browse files
clauderanst91
authored andcommitted
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
1 parent f4f74a9 commit 0a65f70

7 files changed

Lines changed: 333 additions & 22 deletions

File tree

packages/angular/src/lib/copilotkit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class CopilotKit {
3939
readonly runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly();
4040
readonly #runtimeUrl = signal<string | undefined>(undefined);
4141
readonly runtimeUrl = this.#runtimeUrl.asReadonly();
42-
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("rest");
42+
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("auto");
4343
readonly runtimeTransport = this.#runtimeTransport.asReadonly();
4444
readonly #headers = signal<Record<string, string>>({});
4545
readonly headers = this.#headers.asReadonly();

packages/core/src/__tests__/proxied-runtime-transport.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,200 @@ function createSseResponse(): Response {
304304
});
305305
}
306306

307+
describe("Auto-detect transport from runtime info response", () => {
308+
const originalFetch = global.fetch;
309+
const originalWindow = (globalThis as { window?: unknown }).window;
310+
311+
const infoResponse = {
312+
version: "1.0.0",
313+
agents: {
314+
remote: {
315+
description: "Remote agent",
316+
},
317+
},
318+
};
319+
320+
beforeEach(() => {
321+
(globalThis as { window?: unknown }).window = {};
322+
});
323+
324+
afterEach(() => {
325+
vi.restoreAllMocks();
326+
global.fetch = originalFetch;
327+
if (originalWindow === undefined) {
328+
delete (globalThis as { window?: unknown }).window;
329+
} else {
330+
(globalThis as { window?: unknown }).window = originalWindow;
331+
}
332+
});
333+
334+
it("auto-detects REST transport when GET /info succeeds", async () => {
335+
const runtimeUrl = "https://runtime.example/rest-auto";
336+
const fetchMock = vi.fn().mockResolvedValue(
337+
new Response(JSON.stringify(infoResponse), {
338+
status: 200,
339+
headers: { "content-type": "application/json" },
340+
}),
341+
);
342+
// @ts-expect-error - override in test environment
343+
global.fetch = fetchMock;
344+
345+
// No runtimeTransport specified — defaults to "auto"
346+
const core = new CopilotKitCore({
347+
runtimeUrl,
348+
headers: { Authorization: "Bearer token" },
349+
});
350+
351+
await vi.waitFor(() => {
352+
expect(fetchMock).toHaveBeenCalled();
353+
});
354+
355+
// Should have tried REST first (GET /info)
356+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
357+
expect(url).toBe(`${runtimeUrl}/info`);
358+
expect(init.method ?? "GET").toBe("GET");
359+
360+
// Remote agent should be registered
361+
const remoteAgent = core.getAgent("remote");
362+
expect(remoteAgent).toBeDefined();
363+
expect(remoteAgent?.agentId).toBe("remote");
364+
365+
// Transport should have been resolved to "rest"
366+
expect(core.runtimeTransport).toBe("rest");
367+
});
368+
369+
it("auto-detects single-endpoint transport when GET /info fails", async () => {
370+
const runtimeUrl = "https://runtime.example/single-auto";
371+
const fetchMock = vi
372+
.fn()
373+
.mockImplementation((url: string, init?: RequestInit) => {
374+
// REST attempt: GET /info → 404
375+
if (
376+
typeof url === "string" &&
377+
url.endsWith("/info") &&
378+
(!init?.method || init.method === "GET")
379+
) {
380+
return Promise.resolve(new Response("Not Found", { status: 404 }));
381+
}
382+
// Single-endpoint attempt: POST with { method: "info" }
383+
if (init?.method === "POST") {
384+
return Promise.resolve(
385+
new Response(JSON.stringify(infoResponse), {
386+
status: 200,
387+
headers: { "content-type": "application/json" },
388+
}),
389+
);
390+
}
391+
return Promise.reject(new Error("Unexpected fetch call"));
392+
});
393+
// @ts-expect-error - override in test environment
394+
global.fetch = fetchMock;
395+
396+
// No runtimeTransport specified — defaults to "auto"
397+
const core = new CopilotKitCore({
398+
runtimeUrl,
399+
headers: { Authorization: "Bearer token" },
400+
});
401+
402+
await vi.waitFor(() => {
403+
expect(fetchMock).toHaveBeenCalledTimes(2);
404+
});
405+
406+
// First call should be REST attempt (GET /info)
407+
const [url1] = fetchMock.mock.calls[0] as [string, RequestInit];
408+
expect(url1).toBe(`${runtimeUrl}/info`);
409+
410+
// Second call should be single-endpoint attempt (POST with { method: "info" })
411+
const [url2, init2] = fetchMock.mock.calls[1] as [string, RequestInit];
412+
expect(url2).toBe(runtimeUrl);
413+
expect(init2.method).toBe("POST");
414+
const body = JSON.parse(init2.body as string);
415+
expect(body).toEqual({ method: "info" });
416+
417+
// Remote agent should be registered
418+
const remoteAgent = core.getAgent("remote");
419+
expect(remoteAgent).toBeDefined();
420+
expect(remoteAgent?.agentId).toBe("remote");
421+
422+
// Transport should have been resolved to "single"
423+
expect(core.runtimeTransport).toBe("single");
424+
});
425+
426+
it("explicit transport='single' flag still works without auto-detection", async () => {
427+
const runtimeUrl = "https://runtime.example/explicit-single";
428+
const fetchMock = vi.fn().mockResolvedValue(
429+
new Response(JSON.stringify(infoResponse), {
430+
status: 200,
431+
headers: { "content-type": "application/json" },
432+
}),
433+
);
434+
// @ts-expect-error - override in test environment
435+
global.fetch = fetchMock;
436+
437+
const core = new CopilotKitCore({
438+
runtimeUrl,
439+
runtimeTransport: "single",
440+
headers: { Authorization: "Bearer token" },
441+
});
442+
443+
await vi.waitFor(() => {
444+
expect(fetchMock).toHaveBeenCalled();
445+
});
446+
447+
// Should have gone directly to single-endpoint (POST with { method: "info" })
448+
// without trying REST first
449+
expect(fetchMock).toHaveBeenCalledTimes(1);
450+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
451+
expect(url).toBe(runtimeUrl);
452+
expect(init.method).toBe("POST");
453+
const body = JSON.parse(init.body as string);
454+
expect(body).toEqual({ method: "info" });
455+
456+
// Remote agent should be registered
457+
const remoteAgent = core.getAgent("remote");
458+
expect(remoteAgent).toBeDefined();
459+
expect(remoteAgent?.agentId).toBe("remote");
460+
461+
// Transport stays "single"
462+
expect(core.runtimeTransport).toBe("single");
463+
});
464+
465+
it("explicit transport='rest' flag still works without auto-detection", async () => {
466+
const runtimeUrl = "https://runtime.example/explicit-rest";
467+
const fetchMock = vi.fn().mockResolvedValue(
468+
new Response(JSON.stringify(infoResponse), {
469+
status: 200,
470+
headers: { "content-type": "application/json" },
471+
}),
472+
);
473+
// @ts-expect-error - override in test environment
474+
global.fetch = fetchMock;
475+
476+
const core = new CopilotKitCore({
477+
runtimeUrl,
478+
runtimeTransport: "rest",
479+
headers: { Authorization: "Bearer token" },
480+
});
481+
482+
await vi.waitFor(() => {
483+
expect(fetchMock).toHaveBeenCalled();
484+
});
485+
486+
// Should have gone directly to REST (GET /info) without trying single
487+
expect(fetchMock).toHaveBeenCalledTimes(1);
488+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
489+
expect(url).toBe(`${runtimeUrl}/info`);
490+
expect(init.method ?? "GET").toBe("GET");
491+
492+
// Remote agent should be registered
493+
const remoteAgent = core.getAgent("remote");
494+
expect(remoteAgent).toBeDefined();
495+
496+
// Transport stays "rest"
497+
expect(core.runtimeTransport).toBe("rest");
498+
});
499+
});
500+
307501
describe("AgentRegistry runtime info requests", () => {
308502
const originalFetch = global.fetch;
309503
const originalWindow = (globalThis as { window?: unknown }).window;

packages/core/src/agent.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
9595
const normalizedRuntimeUrl = config.runtimeUrl
9696
? config.runtimeUrl.replace(/\/$/, "")
9797
: undefined;
98-
const transport = config.transport ?? "rest";
98+
const transport = config.transport ?? "auto";
9999
const runUrl =
100100
transport === "single"
101101
? (normalizedRuntimeUrl ?? config.runtimeUrl ?? "")
@@ -403,6 +403,10 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
403403
...this.headers,
404404
};
405405

406+
if (this.transport === "auto") {
407+
return this.fetchRuntimeInfoAutoDetect(headers);
408+
}
409+
406410
let init: RequestInit;
407411
let url: string;
408412

@@ -433,6 +437,45 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
433437
return (await response.json()) as RuntimeInfo;
434438
}
435439

440+
private async fetchRuntimeInfoAutoDetect(
441+
headers: Record<string, string>,
442+
): Promise<RuntimeInfo> {
443+
// Try REST first (GET /info)
444+
try {
445+
const response = await fetch(`${this.runtimeUrl}/info`, {
446+
headers: { ...headers },
447+
...(this.credentials ? { credentials: this.credentials } : {}),
448+
});
449+
const status = "status" in response ? (response as Response).status : 200;
450+
if (status !== 404 && status !== 405) {
451+
this.transport = "rest";
452+
return (await response.json()) as RuntimeInfo;
453+
}
454+
} catch {
455+
// REST failed — fall through to single-endpoint attempt
456+
}
457+
458+
// Try single-endpoint (POST with { method: "info" })
459+
const singleHeaders = { ...headers };
460+
if (!singleHeaders["Content-Type"]) {
461+
singleHeaders["Content-Type"] = "application/json";
462+
}
463+
const response = await fetch(this.runtimeUrl!, {
464+
method: "POST",
465+
headers: singleHeaders,
466+
body: JSON.stringify({ method: "info" }),
467+
...(this.credentials ? { credentials: this.credentials } : {}),
468+
});
469+
if ("ok" in response && !response.ok) {
470+
throw new Error(
471+
`Runtime info request failed with status ${response.status}`,
472+
);
473+
}
474+
this.transport = "single";
475+
this.singleEndpointUrl = this.runtimeUrl;
476+
return (await response.json()) as RuntimeInfo;
477+
}
478+
436479
private createSingleRouteRequestInit(
437480
input: RunAgentInput,
438481
method: string,

packages/core/src/core/agent-registry.ts

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class AgentRegistry {
3636
private _runtimeVersion?: string;
3737
private _runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus =
3838
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
39-
private _runtimeTransport: CopilotRuntimeTransport = "rest";
39+
private _runtimeTransport: CopilotRuntimeTransport = "auto";
4040
private _audioFileTranscriptionEnabled: boolean = false;
4141
private _runtimeMode: RuntimeMode = RUNTIME_MODE_SSE;
4242
private _intelligence?: IntelligenceRuntimeInfo;
@@ -347,32 +347,95 @@ export class AgentRegistry {
347347
};
348348

349349
if (this._runtimeTransport === "single") {
350-
if (!headers["Content-Type"]) {
351-
headers["Content-Type"] = "application/json";
352-
}
353-
const response = await fetch(this.runtimeUrl, {
354-
method: "POST",
355-
headers,
356-
body: JSON.stringify({ method: "info" }),
350+
return this.fetchRuntimeInfoSingle(headers, credentials);
351+
}
352+
353+
if (this._runtimeTransport === "auto") {
354+
return this.fetchRuntimeInfoAutoDetect(headers, credentials);
355+
}
356+
357+
// REST transport
358+
const response = await fetch(`${this.runtimeUrl}/info`, {
359+
headers,
360+
...(credentials ? { credentials } : {}),
361+
});
362+
if ("ok" in response && !(response as Response).ok) {
363+
throw new Error(
364+
`Runtime info request failed with status ${response.status}`,
365+
);
366+
}
367+
return (await response.json()) as RuntimeInfo;
368+
}
369+
370+
private async fetchRuntimeInfoSingle(
371+
headers: Record<string, string>,
372+
credentials: RequestCredentials | undefined,
373+
): Promise<RuntimeInfo> {
374+
if (!headers["Content-Type"]) {
375+
headers["Content-Type"] = "application/json";
376+
}
377+
const response = await fetch(this.runtimeUrl!, {
378+
method: "POST",
379+
headers,
380+
body: JSON.stringify({ method: "info" }),
381+
...(credentials ? { credentials } : {}),
382+
});
383+
if ("ok" in response && !(response as Response).ok) {
384+
throw new Error(
385+
`Runtime info request failed with status ${response.status}`,
386+
);
387+
}
388+
return (await response.json()) as RuntimeInfo;
389+
}
390+
391+
/**
392+
* Auto-detect transport by trying REST first, then falling back to single-endpoint.
393+
* Updates `_runtimeTransport` to the detected value so subsequent requests use it directly.
394+
*/
395+
/**
396+
* Auto-detect transport by trying REST first, then falling back to single-endpoint.
397+
* Updates `_runtimeTransport` to the detected value so subsequent requests use it directly.
398+
*/
399+
private async fetchRuntimeInfoAutoDetect(
400+
headers: Record<string, string>,
401+
credentials: RequestCredentials | undefined,
402+
): Promise<RuntimeInfo> {
403+
// Try REST first (GET /info)
404+
try {
405+
const response = await fetch(`${this.runtimeUrl}/info`, {
406+
headers: { ...headers },
357407
...(credentials ? { credentials } : {}),
358408
});
359-
if ("ok" in response && !(response as Response).ok) {
360-
throw new Error(
361-
`Runtime info request failed with status ${response.status}`,
362-
);
409+
// If the response indicates a clear failure (404/405), the endpoint
410+
// doesn't exist — fall through to the single-endpoint probe.
411+
const status = "status" in response ? (response as Response).status : 200;
412+
if (status === 404 || status === 405) {
413+
// Not a REST runtime — try single-endpoint below
414+
} else {
415+
this._runtimeTransport = "rest";
416+
return (await response.json()) as RuntimeInfo;
363417
}
364-
return (await response.json()) as RuntimeInfo;
418+
} catch {
419+
// REST failed (network error, etc.) — fall through to single-endpoint attempt
365420
}
366421

367-
const response = await fetch(`${this.runtimeUrl}/info`, {
368-
headers,
422+
// Try single-endpoint (POST with { method: "info" })
423+
const singleHeaders = { ...headers };
424+
if (!singleHeaders["Content-Type"]) {
425+
singleHeaders["Content-Type"] = "application/json";
426+
}
427+
const response = await fetch(this.runtimeUrl!, {
428+
method: "POST",
429+
headers: singleHeaders,
430+
body: JSON.stringify({ method: "info" }),
369431
...(credentials ? { credentials } : {}),
370432
});
371433
if ("ok" in response && !(response as Response).ok) {
372434
throw new Error(
373435
`Runtime info request failed with status ${response.status}`,
374436
);
375437
}
438+
this._runtimeTransport = "single";
376439
return (await response.json()) as RuntimeInfo;
377440
}
378441

0 commit comments

Comments
 (0)