Skip to content

Commit 6fa01fb

Browse files
ranst91claude
andcommitted
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) <noreply@anthropic.com>
1 parent 0a65f70 commit 6fa01fb

6 files changed

Lines changed: 333 additions & 14 deletions

File tree

packages/angular/src/lib/agent.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class CopilotKitStub {
7070
CopilotKitCoreRuntimeConnectionStatus.Disconnected,
7171
);
7272
readonly #runtimeUrl = signal<string | undefined>(undefined);
73-
readonly #runtimeTransport = signal<"rest" | "single">("rest");
73+
readonly #runtimeTransport = signal<"rest" | "single" | "auto">("auto");
7474
readonly #headers = signal<Record<string, string>>({});
7575
getAgent = vi.fn((id: string) => this.#agents()[id]);
7676
agents = this.#agents.asReadonly();
@@ -80,7 +80,7 @@ class CopilotKitStub {
8080
headers = this.#headers.asReadonly();
8181
core = {
8282
runtimeUrl: undefined as string | undefined,
83-
runtimeTransport: "rest" as const,
83+
runtimeTransport: "auto" as const,
8484
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected,
8585
headers: {} as Record<string, string>,
8686
};
@@ -105,7 +105,7 @@ class CopilotKitStub {
105105
this.core = { ...this.core, headers: value };
106106
}
107107

108-
setRuntimeTransport(value: "rest" | "single") {
108+
setRuntimeTransport(value: "rest" | "single" | "auto") {
109109
this.#runtimeTransport.set(value);
110110
this.core = { ...this.core, runtimeTransport: value };
111111
}

packages/angular/src/lib/copilotkit.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ vi.mock("@copilotkit/core", () => {
4141
readonly getAgent = mockGetAgent;
4242
agents: Record<string, any> = {};
4343
runtimeUrl = undefined;
44-
runtimeTransport = "rest";
44+
runtimeTransport = "auto";
4545
headers: Record<string, string> = {};
4646
runtimeConnectionStatus =
4747
CopilotKitCoreRuntimeConnectionStatus.Disconnected;

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

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
501751
describe("AgentRegistry runtime info requests", () => {
502752
const originalFetch = global.fetch;
503753
const originalWindow = (globalThis as { window?: unknown }).window;

packages/core/src/agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,8 +446,11 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
446446
headers: { ...headers },
447447
...(this.credentials ? { credentials: this.credentials } : {}),
448448
});
449+
// Only treat a successful (2xx) response as a valid REST runtime.
450+
// 404/405 means the endpoint doesn't exist; other non-2xx errors
451+
// (500, 403, etc.) should also fall through to single-endpoint.
449452
const status = "status" in response ? (response as Response).status : 200;
450-
if (status !== 404 && status !== 405) {
453+
if (status >= 200 && status < 300) {
451454
this.transport = "rest";
452455
return (await response.json()) as RuntimeInfo;
453456
}

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

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -388,10 +388,6 @@ export class AgentRegistry {
388388
return (await response.json()) as RuntimeInfo;
389389
}
390390

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-
*/
395391
/**
396392
* Auto-detect transport by trying REST first, then falling back to single-endpoint.
397393
* Updates `_runtimeTransport` to the detected value so subsequent requests use it directly.
@@ -406,15 +402,15 @@ export class AgentRegistry {
406402
headers: { ...headers },
407403
...(credentials ? { credentials } : {}),
408404
});
409-
// If the response indicates a clear failure (404/405), the endpoint
410-
// doesn't exist — fall through to the single-endpoint probe.
405+
// Only treat a successful (2xx) response as a valid REST runtime.
406+
// 404/405 means the endpoint doesn't exist; other non-2xx errors
407+
// (500, 403, etc.) should also fall through to single-endpoint.
411408
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 {
409+
if (status >= 200 && status < 300) {
415410
this._runtimeTransport = "rest";
416411
return (await response.json()) as RuntimeInfo;
417412
}
413+
// Non-2xx — try single-endpoint below
418414
} catch {
419415
// REST failed (network error, etc.) — fall through to single-endpoint attempt
420416
}

0 commit comments

Comments
 (0)