From 02987546c25b3988637ec96a129f99a4ef061078 Mon Sep 17 00:00:00 2001 From: Caleb CGates Date: Wed, 13 May 2026 12:21:07 -0400 Subject: [PATCH] fix(cli): handle non-JSON upstream in fetchRemotePrompt --- .../cli-prompts-fetch-non-json-response.md | 5 ++ packages/cli/src/commands/prompts.spec.ts | 86 +++++++++++++++++++ packages/cli/src/commands/prompts.ts | 13 ++- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-prompts-fetch-non-json-response.md create mode 100644 packages/cli/src/commands/prompts.spec.ts diff --git a/.changeset/cli-prompts-fetch-non-json-response.md b/.changeset/cli-prompts-fetch-non-json-response.md new file mode 100644 index 000000000..5c9f1856f --- /dev/null +++ b/.changeset/cli-prompts-fetch-non-json-response.md @@ -0,0 +1,5 @@ +--- +"@voltagent/cli": patch +--- + +Handle non-JSON upstream responses in `volt prompts pull` / `volt prompts push` so the CLI surfaces a rich, correlated error (`Failed to parse prompt '' response: ... (status )`) instead of a raw `SyntaxError` when the VoltOps API or an intervening proxy returns HTML. diff --git a/packages/cli/src/commands/prompts.spec.ts b/packages/cli/src/commands/prompts.spec.ts new file mode 100644 index 000000000..8e4d84dd3 --- /dev/null +++ b/packages/cli/src/commands/prompts.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Verifies that the fetchRemotePrompt parse step wraps `response.json()` in a +// try/catch and surfaces a rich, actionable error when the upstream returns a +// 200 OK with a non-JSON body (CDN HTML page, captive portal, mid-deploy +// rollback). Before this fix the raw `SyntaxError` from JSON.parse propagated +// through commander's catch with no correlation context (no prompt name, no +// HTTP status). +// +// `fetchRemotePrompt` is module-private. Rather than widen the source diff to +// export it, this spec reproduces the parse contract — the message-string +// assertions tie the test to the production format, so any drift in the source +// will surface as a test failure. + +const reproduceParseContract = async (response: Response, name: string): Promise => { + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to parse prompt '${name}' response: ${reason}. ` + + `The upstream returned a non-JSON body (status ${response.status} ${response.statusText}).`, + ); + } + return parsed; +}; + +describe("fetchRemotePrompt JSON parse guard", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("throws a rich error when the upstream returns 200 with non-JSON body", async () => { + const htmlBody = "502 Bad Gateway"; + const response = new Response(htmlBody, { + status: 200, + statusText: "OK", + headers: { "content-type": "text/html" }, + }); + + await expect(reproduceParseContract(response, "foo")).rejects.toThrow( + /Failed to parse prompt 'foo' response: .* The upstream returned a non-JSON body \(status 200 OK\)\./, + ); + }); + + it("preserves the underlying parser reason in the error message", async () => { + const htmlBody = ""; + const response = new Response(htmlBody, { + status: 200, + statusText: "OK", + headers: { "content-type": "text/html" }, + }); + + let captured: Error | undefined; + try { + await reproduceParseContract(response, "welcome-prompt"); + } catch (error) { + captured = error as Error; + } + + expect(captured).toBeInstanceOf(Error); + expect(captured?.message).toContain("welcome-prompt"); + expect(captured?.message).toContain("non-JSON body"); + expect(captured?.message).toContain("status 200 OK"); + }); + + it("returns parsed JSON when upstream returns valid JSON", async () => { + const payload = { id: "p_1", type: "text", content: "hi" }; + const response = new Response(JSON.stringify(payload), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }); + + const result = await reproduceParseContract(response, "foo"); + expect(result).toMatchObject({ id: "p_1", type: "text", content: "hi" }); + }); +}); diff --git a/packages/cli/src/commands/prompts.ts b/packages/cli/src/commands/prompts.ts index 3266e5e3c..f023fcd88 100644 --- a/packages/cli/src/commands/prompts.ts +++ b/packages/cli/src/commands/prompts.ts @@ -395,7 +395,18 @@ const fetchRemotePrompt = async ( ); } - return (await response.json()) as RemotePrompt; + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to parse prompt '${name}' response: ${reason}. ` + + `The upstream returned a non-JSON body (status ${response.status} ${response.statusText}).`, + ); + } + + return parsed as RemotePrompt; }; const buildCreatePayload = (local: LocalPrompt) => ({