Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-prompts-fetch-non-json-response.md
Original file line number Diff line number Diff line change
@@ -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 '<name>' response: ... (status <status> <statusText>)`) instead of a raw `SyntaxError` when the VoltOps API or an intervening proxy returns HTML.
86 changes: 86 additions & 0 deletions packages/cli/src/commands/prompts.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> => {
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;
};
Comment on lines +15 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, let's confirm the file exists and get a sense of its structure
fd -type f "prompts.spec.ts"

Repository: VoltAgent/voltagent

Length of output: 234


🏁 Script executed:

# Also check for the production file that might contain fetchRemotePrompt
fd -type f "prompts.ts" | grep -v spec

Repository: VoltAgent/voltagent

Length of output: 234


🏁 Script executed:

# Check the overall structure of the packages/cli directory
ls -la packages/cli/src/commands/ 2>/dev/null || echo "Directory not found"

Repository: VoltAgent/voltagent

Length of output: 894


🏁 Script executed:

# Read the prompts.spec.ts file to understand the context
cat -n packages/cli/src/commands/prompts.spec.ts

Repository: VoltAgent/voltagent

Length of output: 3791


🏁 Script executed:

# Read the prompts.ts file to find fetchRemotePrompt and understand the production code
cat -n packages/cli/src/commands/prompts.ts | head -100

Repository: VoltAgent/voltagent

Length of output: 3774


🏁 Script executed:

# Search for fetchRemotePrompt definition in prompts.ts
rg "fetchRemotePrompt" packages/cli/src/commands/prompts.ts -A 10

Repository: VoltAgent/voltagent

Length of output: 938


🏁 Script executed:

# Check for safeStringify imports in the repository
rg "safeStringify" packages/cli/src/ --type ts

Repository: VoltAgent/voltagent

Length of output: 1536


🏁 Script executed:

# Get more context on the fetchRemotePrompt function to understand the full parse logic
rg "const fetchRemotePrompt" packages/cli/src/commands/prompts.ts -A 30

Repository: VoltAgent/voltagent

Length of output: 905


🏁 Script executed:

# Get more of fetchRemotePrompt including the JSON parse step
rg "const fetchRemotePrompt" packages/cli/src/commands/prompts.ts -A 50 | head -80

Repository: VoltAgent/voltagent

Length of output: 1469


🏁 Script executed:

# Check if there are any other JSON.stringify calls in the spec file
rg "JSON\.stringify" packages/cli/src/commands/prompts.spec.ts -n

Repository: VoltAgent/voltagent

Length of output: 127


Replace JSON.stringify with safeStringify at line 77.

The test helper uses JSON.stringify(payload) which violates the coding guideline requiring safeStringify from @voltagent/internal. Update line 77 to use safeStringify(payload) instead.

Regarding the test design: the reproduceParseContract helper is intentionally designed as a contract test (documented in the file comments). While direct testing of the production function would provide stronger isolation, the message-string assertions at lines 49–72 are designed to catch drift in the error format between test and production code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/prompts.spec.ts` around lines 15 - 27, The test
uses JSON.stringify where our coding guideline mandates safeStringify from
`@voltagent/internal`: replace the JSON.stringify(payload) call used in the test
helper with safeStringify(payload). Locate the reproduceParseContract helper and
any other places in this file where JSON.stringify is used for test payload
serialization (specifically the call that builds the mock response body) and
import/use safeStringify instead to ensure safe serialization consistent with
project utilities.


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 = "<!DOCTYPE html><html><body>502 Bad Gateway</body></html>";
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 = "<!DOCTYPE html><html></html>";
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), {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and read around line 77
if [ -f "packages/cli/src/commands/prompts.spec.ts" ]; then
  echo "=== File content around line 77 ==="
  cat -n "packages/cli/src/commands/prompts.spec.ts" | sed -n '70,85p'
else
  echo "File not found. Let's search for the file:"
  find . -name "prompts.spec.ts" -type f
fi

Repository: VoltAgent/voltagent

Length of output: 850


🏁 Script executed:

# Search for JSON.stringify usage in the target file
echo "=== All JSON.stringify usages in the file ==="
rg "JSON\.stringify" "packages/cli/src/commands/prompts.spec.ts" -n

Repository: VoltAgent/voltagent

Length of output: 173


🏁 Script executed:

# Check current imports in the file
echo "=== Import statements in the file ==="
head -30 "packages/cli/src/commands/prompts.spec.ts" | cat -n

Repository: VoltAgent/voltagent

Length of output: 1637


🏁 Script executed:

# Verify the correct import path for safeStringify
echo "=== Search for safeStringify in codebase ==="
rg "safeStringify" --type ts -l | head -20

Repository: VoltAgent/voltagent

Length of output: 1024


🏁 Script executed:

# Check import path for safeStringify in an actual usage
echo "=== Checking safeStringify import in one of the files ==="
head -20 "packages/cli/src/services/eval/dataset-push.ts" | rg "import.*safeStringify|from.*safeStringify" -A 1 -B 1

Repository: VoltAgent/voltagent

Length of output: 228


🏁 Script executed:

# Get more context around the import
rg "import.*safeStringify" "packages/cli/src/services/eval/dataset-push.ts" -A 1 -B 1

Repository: VoltAgent/voltagent

Length of output: 170


🏁 Script executed:

# Search across the codebase for safeStringify import pattern
echo "=== All safeStringify import patterns ==="
rg "import.*safeStringify.*from" --type ts | head -10

Repository: VoltAgent/voltagent

Length of output: 1211


Replace JSON.stringify with safeStringify.

Line 77 violates the TS guideline banning JSON.stringify.

Suggested fix
+import { safeStringify } from "@voltagent/internal";
...
-    const response = new Response(JSON.stringify(payload), {
+    const response = new Response(safeStringify(payload), {

As per coding guidelines, "Never use JSON.stringify; use the safeStringify function instead, imported from @voltagent/internal".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = new Response(JSON.stringify(payload), {
import { safeStringify } from "@voltagent/internal";
...
const response = new Response(safeStringify(payload), {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/prompts.spec.ts` at line 77, Replace the direct use
of JSON.stringify in the test response construction with the safeStringify
utility: import safeStringify from '@voltagent/internal' (or add it to existing
imports in prompts.spec.ts) and call safeStringify(payload) where
Response(JSON.stringify(payload), ...) is used; ensure the import name matches
how safeStringify is exported and update the Response instantiation to use the
safeStringify output so the test adheres to the TS guideline banning
JSON.stringify.

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" });
});
});
13 changes: 12 additions & 1 deletion packages/cli/src/commands/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n packages/cli/src/commands/prompts.ts | sed -n '400,420p'

Repository: VoltAgent/voltagent

Length of output: 860


🏁 Script executed:

# Check for RemotePrompt type definition
rg "type RemotePrompt|interface RemotePrompt" packages/cli/src/commands/prompts.ts -A 5

Repository: VoltAgent/voltagent

Length of output: 202


🏁 Script executed:

# Get broader context around line 409 to understand the function
cat -n packages/cli/src/commands/prompts.ts | sed -n '380,425p'

Repository: VoltAgent/voltagent

Length of output: 1569


🏁 Script executed:

rg "JSON\.stringify" packages/cli/src/commands/prompts.ts

Repository: VoltAgent/voltagent

Length of output: 45


🏁 Script executed:

# Also check if safeStringify is used or imported
rg "safeStringify" packages/cli/src/commands/prompts.ts

Repository: VoltAgent/voltagent

Length of output: 762


Add runtime type validation before returning upstream payload.

The unchecked cast parsed as RemotePrompt on line 409 bypasses type safety for untrusted network data. Although the JSON parsing is wrapped in a try/catch, the response structure is never validated. Add a type guard to ensure the payload matches the RemotePrompt schema before casting.

Suggested fix
+const isRemotePrompt = (value: unknown): value is RemotePrompt => {
+  if (!value || typeof value !== "object") return false;
+  const v = value as Record<string, unknown>;
+  return (
+    typeof v.name === "string" &&
+    (v.type === "text" || v.type === "chat") &&
+    "prompt" in v
+  );
+};
...
-  return parsed as RemotePrompt;
+  if (!isRemotePrompt(parsed)) {
+    throw new Error(
+      `Failed to parse prompt '${name}' response: invalid payload shape (status ${response.status} ${response.statusText}).`,
+    );
+  }
+  return parsed;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return parsed as RemotePrompt;
const isRemotePrompt = (value: unknown): value is RemotePrompt => {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.name === "string" &&
(v.type === "text" || v.type === "chat") &&
"prompt" in v
);
};
if (!isRemotePrompt(parsed)) {
throw new Error(
`Failed to parse prompt '${name}' response: invalid payload shape (status ${response.status} ${response.statusText}).`,
);
}
return parsed;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/prompts.ts` at line 409, The unchecked cast "parsed
as RemotePrompt" must be replaced with runtime validation: implement a type
guard function (e.g., isRemotePrompt(obj): obj is RemotePrompt) that checks
required fields/types of the RemotePrompt interface and use it before returning;
in the function that currently does "return parsed as RemotePrompt" call
isRemotePrompt(parsed) and throw/handle an error if it returns false so only
validated RemotePrompt objects are returned. Ensure the type guard inspects all
unique properties used elsewhere (names/types referenced by RemotePrompt) and
update callers to rely on the guard rather than the direct cast.

};

const buildCreatePayload = (local: LocalPrompt) => ({
Expand Down
Loading