fix(perplexity-web): stop empty-content responses from live schematized SSE (#6955)

* fix(perplexity-web): stop empty-content responses from live schematized SSE

Align the request payload and stream parser with the current www.perplexity.ai
browser capture so non-streaming pplx-web calls no longer return
"Provider returned empty content".

- Map pplx-sonar → copilot/turbo (live browser default; experimental was empty)
- Advertise workflow_widgets/navigation_results + supports_tool_approval_modal
- Use event: end_of_stream as the TLS stream EOF (not OpenAI [DONE])
- Recover answers from COMPLETED FINAL double-encoded text step-blobs
- Prefer the longest dual ask_text / ask_text_N_markdown track
- Promote buffered SSE text to a ReadableStream when looksLikeSse false-negatives

Regression: 31/31 perplexity-web unit tests pass.

* fix(sse): satisfy no-explicit-any budget in perplexity-web test additions

Two new assertions in the pre-merge sweep used `as any` beyond the file's
frozen eslint-suppressions allowance (11); replace them with narrow local
result-shape casts so the file stays within the existing budget.

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>

* test(perplexity-web): replace any with derived types + rebaseline test-file-size

Fixes the ~13 @typescript-eslint/no-explicit-any promised in review but never
pushed: real interfaces (PplxChatCompletionJson/PplxErrorJson) replace the
`as any` json casts, fetch cast uses `typeof fetch`. Removes the now-stale
perplexity-web.test.ts entry from eslint-suppressions.json (0 errors, no
suppressions). Rebaselines the frozen test-file-size (999 -> 1200) to reflect
the PR's own legitimate test growth after merging release/v3.8.49.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
NOXX - Commiter
2026-07-20 02:52:31 +03:00
committed by GitHub
parent 747bce2f10
commit 4007149183
6 changed files with 431 additions and 33 deletions

View File

@@ -0,0 +1 @@
- **fix(sse):** `perplexity-web`'s default `pplx-auto`/`pplx-sonar` targets now post `mode: "copilot"` (with `pplx-sonar` mapped to `model_preference: "turbo"` instead of `experimental`), match Perplexity's `event: end_of_stream` SSE terminator instead of OpenAI's `[DONE]`, and fall back to the COMPLETED frame's double-encoded FINAL step-blob when no `diff_block`/`markdown_block` streamed — fixing `[perplexity-web/pplx-sonar] Provider returned empty content` against the current schematized `www.perplexity.ai` SSE API. (thanks @artickc)

View File

@@ -1672,11 +1672,6 @@
"count": 2
}
},
"tests/unit/perplexity-web.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 11
}
},
"tests/unit/persist-429-cooldown-account-fallback.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 7

View File

@@ -327,7 +327,7 @@
"tests/unit/model-sync-route.test.ts": 1016,
"tests/unit/models-catalog-route.test.ts": 1605,
"tests/unit/oauth-providers-config.test.ts": 845,
"tests/unit/perplexity-web.test.ts": 999,
"tests/unit/perplexity-web.test.ts": 1200,
"tests/unit/provider-models-route.test.ts": 1752,
"tests/unit/provider-validation-specialty.test.ts": 2980,
"tests/unit/providers-page-utils.test.ts": 1294,

View File

@@ -19,6 +19,7 @@ import { sanitizeErrorMessage } from "../utils/error.ts";
import {
PPLX_SSE_ENDPOINT,
PPLX_USER_AGENT,
PPLX_STREAM_EOF_SYMBOL,
MODEL_MAP,
THINKING_MAP,
cleanResponse,
@@ -423,7 +424,8 @@ export class PerplexityWebExecutor extends BaseExecutor {
body: JSON.stringify(pplxBody),
signal: signal ?? null,
stream: true,
streamEofSymbol: "[DONE]",
// Live wire terminator is `event: end_of_stream` (not OpenAI `[DONE]`).
streamEofSymbol: PPLX_STREAM_EOF_SYMBOL,
});
} catch (err) {
const isTlsUnavail = err instanceof TlsClientUnavailableError;
@@ -469,6 +471,37 @@ export class PerplexityWebExecutor extends BaseExecutor {
return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody };
}
// If the TLS client buffered the body (looksLikeSse false-negative, or a
// non-streaming error page), promote a text body that still looks like SSE
// into a ReadableStream so extractContent can recover the answer.
if (!response.body && response.text) {
const buffered = response.text;
if (/^(?:\s*)(?:data|event|id|retry):/im.test(buffered) || buffered.includes("\ndata:")) {
const encoder = new TextEncoder();
response = {
...response,
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(buffered));
controller.close();
},
}),
text: null,
};
} else {
const errResp = new Response(
JSON.stringify({
error: {
message: `Perplexity returned non-SSE body: ${sanitizeErrorMessage(buffered.slice(0, 240))}`,
type: "upstream_error",
},
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody };
}
}
if (!response.body) {
const errResp = new Response(
JSON.stringify({

View File

@@ -38,16 +38,28 @@ export const PPLX_SUPPORTED_BLOCK_USE_CASES = [
"inline_claims",
"unified_assets",
"workflow_steps",
"workflow_widgets",
"navigation_results",
"background_agents",
];
// Perplexity's live SSE terminator (not OpenAI's `data: [DONE]`). Using the wrong
// EOF symbol can truncate or hang the Firefox-TLS stream tailer before answer
// chunks land — which surfaces as "Provider returned empty content".
export const PPLX_STREAM_EOF_SYMBOL = "event: end_of_stream";
// Firefox 148 — must match the `firefox_148` TLS profile used by perplexityTlsClient.
// A mismatched UA vs TLS fingerprint is itself a Cloudflare bot signal (issue #2459).
export const PPLX_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0";
// mode / model_preference pairs. Live www.perplexity.ai still posts mode:"copilot"
// for the default turbo path; search mode is used for the curated catalog models.
export const MODEL_MAP: Record<string, [string, string]> = {
"pplx-auto": ["search", "pplx_pro"],
"pplx-sonar": ["search", "experimental"],
// pplx-auto/pplx-sonar use "copilot" mode (was "search", which for pplx-sonar
// maps to "experimental" — that model no longer streams answer-text blocks
// for many sessions → empty content, issue #6955). The live web client uses
// mode:"copilot" + model_preference:"turbo" for the default turbo path.
"pplx-auto": ["copilot", "pplx_pro"],
"pplx-sonar": ["copilot", "turbo"],
"pplx-gpt-5.6-terra": ["search", "gpt56_terra"],
"pplx-gpt-5.6-sol": ["search", "gpt56_sol"],
"pplx-gemini": ["search", "gemini31pro_high"],
@@ -288,6 +300,7 @@ export function buildPplxRequestBody(
override_no_search: false,
client_search_results_cache_key: requestId,
should_ask_for_mcp_tool_confirmation: true,
supports_tool_approval_modal: true,
browser_agent_allow_once_from_toggle: false,
force_enable_browser_agent: false,
supported_features: ["browser_agent_permission_banner_v1.1"],
@@ -364,8 +377,15 @@ export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPat
for (const patch of patches) {
const path = patch.path ?? "";
if (path === "") {
const value = (patch.value ?? {}) as { chunks?: unknown };
acc.chunks = Array.isArray(value.chunks) ? value.chunks.map((c) => String(c)) : [];
const value = (patch.value ?? {}) as { chunks?: unknown; answer?: unknown };
if (Array.isArray(value.chunks)) {
acc.chunks = value.chunks.map((c) => String(c));
} else if (typeof value.answer === "string" && value.answer.length > 0) {
// Some COMPLETED/replace frames only materialize `answer` (no chunks).
acc.chunks = [value.answer];
} else {
acc.chunks = [];
}
continue;
}
const chunkMatch = /^\/chunks\/(\d+)$/.exec(path);
@@ -376,6 +396,93 @@ export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPat
}
}
/**
* Extract the assistant answer from the COMPLETED frame's `text` step-blob.
*
* Live shape (Jul 2026 browser capture):
* text: '[{"step_type":"FINAL","content":{"answer":"{\\"answer\\":\\"Hi…\\",\\"chunks\\":[…]}"}}]'
*
* The nested `content.answer` is often a *double-encoded* JSON string. Used as a
* safety net when diff_block / markdown_block frames were missed (truncated TLS
* stream, FINAL-only delivery, etc.) so we don't return empty content.
*/
export function extractAnswerFromFinalText(text: string | undefined | null): string | null {
if (!text || typeof text !== "string") return null;
const trimmed = text.trim();
if (!trimmed) return null;
// Plain non-JSON text (legacy non-schematized path).
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return trimmed;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
const steps = Array.isArray(parsed) ? parsed : [parsed];
for (const step of steps) {
if (!step || typeof step !== "object") continue;
const s = step as Record<string, unknown>;
const stepType = String(s.step_type || s.stepType || "");
if (stepType && stepType !== "FINAL") continue;
const content = s.content as Record<string, unknown> | string | undefined;
let rawAnswer: unknown =
typeof content === "string"
? content
: content && typeof content === "object"
? (content as Record<string, unknown>).answer
: undefined;
if (rawAnswer == null && typeof s.answer === "string") rawAnswer = s.answer;
if (rawAnswer == null) continue;
if (typeof rawAnswer === "string") {
const inner = rawAnswer.trim();
if (!inner) continue;
// Double-encoded JSON blob: {"answer":"…","chunks":[…],"structured_answer":[…]}
if (inner.startsWith("{") || inner.startsWith("[")) {
try {
const obj = JSON.parse(inner) as Record<string, unknown>;
if (typeof obj.answer === "string" && obj.answer.trim()) return obj.answer;
if (Array.isArray(obj.chunks) && obj.chunks.length > 0) {
return obj.chunks.map((c) => String(c)).join("");
}
if (Array.isArray(obj.structured_answer)) {
const joined = (obj.structured_answer as Array<Record<string, unknown>>)
.map((b) => (typeof b?.text === "string" ? b.text : ""))
.join("");
if (joined.trim()) return joined;
}
} catch {
// Fall through — treat as plain markdown.
}
}
return inner;
}
}
} catch {
return null;
}
return null;
}
/** Pick the longest reconstructed answer across dual ask_text / ask_text_N_markdown tracks. */
export function longestMarkdownAnswer(
mdState: Map<string, MarkdownAccumulator>,
preferredUsage: string | null
): { usage: string | null; answer: string } {
let bestUsage: string | null = preferredUsage;
let bestAnswer = preferredUsage ? (mdState.get(preferredUsage)?.chunks ?? []).join("") : "";
for (const [usage, acc] of mdState) {
const joined = (acc.chunks ?? []).join("");
if (joined.length > bestAnswer.length) {
bestAnswer = joined;
bestUsage = usage;
}
}
return { usage: bestUsage, answer: bestAnswer };
}
export async function* extractContent(
eventStream: ReadableStream<Uint8Array>,
signal?: AbortSignal | null
@@ -387,6 +494,7 @@ export async function* extractContent(
// Per-usage reconstructed answer-text blocks + the locked primary usage.
const mdState = new Map<string, MarkdownAccumulator>();
let primaryUsage: string | null = null;
let lastEventText: string | undefined;
for await (const event of readPplxSseEvents(eventStream, signal)) {
if (event.error_code || event.error_message) {
@@ -398,6 +506,7 @@ export async function* extractContent(
}
if (event.backend_uuid) backendUuid = event.backend_uuid;
if (event.text) lastEventText = event.text;
const blocks = event.blocks ?? [];
for (const block of blocks) {
@@ -439,6 +548,16 @@ export async function* extractContent(
// Content: answer-text blocks (schematized diff frames OR materialized
// markdown_block on the final COMPLETED frame).
if (!isAnswerTextUsage(usage)) continue;
// Only apply markdown patches when the diff targets markdown_block (or field
// is absent on older frames). Ignore answer_tabs/plan/etc. diffs that share
// the same event but different field names.
if (
block.diff_block &&
block.diff_block.field &&
block.diff_block.field !== "markdown_block"
) {
continue;
}
let acc = mdState.get(usage);
if (!acc) {
acc = { chunks: [] };
@@ -464,21 +583,20 @@ export async function* extractContent(
}
}
// Emit at most one content delta per event, from the locked primary usage.
if (primaryUsage) {
const currentAnswer = (mdState.get(primaryUsage)?.chunks ?? []).join("");
if (currentAnswer.length > seenLen) {
const delta = currentAnswer.slice(seenLen);
fullAnswer = currentAnswer;
seenLen = currentAnswer.length;
yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined };
}
// Emit at most one content delta per event from the longest reconstructed
// answer track (ask_text and ask_text_0_markdown often stream in parallel).
const { answer: currentAnswer } = longestMarkdownAnswer(mdState, primaryUsage);
if (currentAnswer.length > seenLen) {
const delta = currentAnswer.slice(seenLen);
fullAnswer = currentAnswer;
seenLen = currentAnswer.length;
yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined };
}
// Legacy fallback: a plain non-JSON `text` field with no structured blocks.
// The schematized API's `text` field is a JSON step-blob (not user-facing),
// so only use it when there are no answer-text blocks at all.
if (!primaryUsage && blocks.length === 0 && event.text) {
if (!primaryUsage && mdState.size === 0 && blocks.length === 0 && event.text) {
const t = event.text.trim();
const looksLikeJson = t.startsWith("{") || t.startsWith("[");
if (!looksLikeJson && t.length > seenLen) {
@@ -492,7 +610,30 @@ export async function* extractContent(
// Only stop on the terminal COMPLETED frame. A `final:true` flag can appear
// on a still-PENDING frame BEFORE the COMPLETED frame that materializes the
// full markdown_block — breaking on `final` there drops the answer.
if (event.status === "COMPLETED") break;
if (event.status === "COMPLETED") {
// Safety net: if diff/markdown tracks stayed empty, pull the answer from
// the COMPLETED frame's double-encoded FINAL step blob.
if (!fullAnswer.trim()) {
const fromText = extractAnswerFromFinalText(event.text || lastEventText);
if (fromText && fromText.trim()) {
const delta = fromText.slice(seenLen);
fullAnswer = fromText;
seenLen = fromText.length;
if (delta) {
yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined };
}
}
}
break;
}
}
// End-of-stream without a COMPLETED frame still try the last text blob.
if (!fullAnswer.trim() && lastEventText) {
const fromText = extractAnswerFromFinalText(lastEventText);
if (fromText && fromText.trim()) {
fullAnswer = fromText;
}
}
yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true };

View File

@@ -2,6 +2,22 @@
import test from "node:test";
import assert from "node:assert/strict";
// ─── Response JSON shapes (real types derived from OpenAI-compatible bodies) ─
interface PplxChatCompletionJson {
id: string;
object: string;
choices: Array<{
message: { role: string; content: string };
finish_reason: string;
}>;
usage: { total_tokens: number };
}
interface PplxErrorJson {
error: { message: string };
}
// ─── Import the executor and its dependencies ──────────────────────────────
const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts");
@@ -13,7 +29,7 @@ const { __setTlsFetchOverrideForTesting, TlsClientUnavailableError } =
// global fetch. Install one persistent bridge so the tests below can keep stubbing
// globalThis.fetch (returning a Response) and have it surface as a TlsFetchResult.
__setTlsFetchOverrideForTesting(async (url, opts) => {
const res = await (globalThis.fetch as any)(url, opts);
const res = await (globalThis.fetch as typeof fetch)(url, opts);
return {
status: res.status,
headers: res.headers,
@@ -128,7 +144,7 @@ test("Non-streaming: simple text response", async () => {
});
assert.equal(result.response.status, 200);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxChatCompletionJson;
assert.equal(json.object, "chat.completion");
assert.equal(json.choices[0].message.role, "assistant");
assert.equal(json.choices[0].message.content, "Hello, world!");
@@ -168,7 +184,7 @@ test("Non-streaming: strips citations from response", async () => {
log: null,
});
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxChatCompletionJson;
assert.ok(!json.choices[0].message.content.includes("[1]"));
assert.ok(!json.choices[0].message.content.includes("[2]"));
assert.ok(!json.choices[0].message.content.includes("[3]"));
@@ -474,7 +490,7 @@ test("Error: 401 returns auth error message", async () => {
});
assert.equal(result.response.status, 401);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxErrorJson;
assert.ok(json.error.message.includes("auth failed"));
assert.ok(json.error.message.includes("session-token"));
} finally {
@@ -496,7 +512,7 @@ test("Error: 429 returns rate limit message", async () => {
});
assert.equal(result.response.status, 429);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxErrorJson;
assert.ok(json.error.message.includes("rate limited"));
} finally {
restore();
@@ -517,7 +533,7 @@ test("Error: fetch failure returns 502", async () => {
});
assert.equal(result.response.status, 502);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxErrorJson;
assert.ok(json.error.message.includes("ECONNREFUSED"));
} finally {
restore();
@@ -536,7 +552,7 @@ test("Error: empty messages returns 400", async () => {
});
assert.equal(result.response.status, 400);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxErrorJson;
assert.ok(json.error.message.includes("Missing or empty messages"));
});
@@ -572,7 +588,7 @@ test("Non-streaming: Perplexity stream error returns 502", async () => {
});
assert.equal(result.response.status, 502);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxErrorJson;
assert.ok(json.error.message.includes("Too many requests"));
} finally {
restore();
@@ -804,6 +820,54 @@ test("Model mapping: GPT-5.6 Terra sends its current internal preference", async
}
});
test("Model mapping: pplx-sonar maps to turbo/copilot (live browser default)", async () => {
let capturedBody = null;
const original = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
capturedBody = JSON.parse(opts.body);
return new Response(
mockPplxStream([
{
blocks: [
{
intended_usage: "markdown",
markdown_block: { chunks: ["ok"], progress: "DONE" },
},
],
status: "COMPLETED",
},
]),
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
);
};
try {
const executor = new PerplexityWebExecutor();
await executor.execute({
model: "pplx-sonar",
body: { messages: [{ role: "user", content: "hello" }], stream: false },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10000),
log: null,
});
assert.equal(capturedBody.params.model_preference, "turbo");
assert.equal(capturedBody.params.mode, "copilot");
assert.equal(capturedBody.params.supports_tool_approval_modal, true);
assert.ok(
capturedBody.params.supported_block_use_cases.includes("workflow_widgets"),
"payload must advertise workflow_widgets like the live browser"
);
assert.ok(
capturedBody.params.supported_block_use_cases.includes("navigation_results"),
"payload must advertise navigation_results like the live browser"
);
} finally {
globalThis.fetch = original;
}
});
test("Model mapping: thinking mode uses thinking variant", async () => {
let capturedBody = null;
const original = globalThis.fetch;
@@ -841,6 +905,7 @@ test("Model mapping: thinking mode uses thinking variant", async () => {
});
assert.equal(capturedBody.params.model_preference, "claude50sonnetthinking");
// Thinking variants still go through mode "search" (THINKING_MAP path).
assert.equal(capturedBody.params.mode, "search");
} finally {
globalThis.fetch = original;
@@ -864,13 +929,176 @@ test("Non-streaming: falls back to text field when no blocks", async () => {
log: null,
});
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxChatCompletionJson;
assert.ok(json.choices[0].message.content.includes("Fallback answer text"));
} finally {
restore();
}
});
// Live COMPLETED frame carries the answer only in a double-encoded FINAL step
// blob (no markdown_block / diff_block). Without this fallback the executor
// returns empty content → chatCore 502 "Provider returned empty content".
test("Non-streaming: recovers answer from COMPLETED FINAL text step-blob", async () => {
const answerObj = {
answer: "Hi Bilal — nice to meet you! How can I help today?",
chunks: ["Hi Bilal — ", "nice to meet ", "you! How can I ", "help to", "day?"],
web_results: [],
};
const pplxEvents = [
{
status: "COMPLETED",
final: true,
final_sse_message: true,
blocks: [],
text: JSON.stringify([
{ step_type: "INITIAL_QUERY", content: { query: "hello" } },
{ step_type: "FINAL", content: { answer: JSON.stringify(answerObj) } },
]),
},
];
const restore = mockFetch(200, pplxEvents);
try {
const executor = new PerplexityWebExecutor();
const result = await executor.execute({
model: "pplx-sonar",
body: { messages: [{ role: "user", content: "hello" }], stream: false },
stream: false,
credentials: { apiKey: "test-cookie" },
signal: AbortSignal.timeout(10000),
log: null,
});
assert.equal(result.response.status, 200);
const json = (await result.response.json()) as {
choices: Array<{ message: { content: string } }>;
};
assert.equal(
json.choices[0].message.content,
"Hi Bilal — nice to meet you! How can I help today?"
);
} finally {
restore();
}
});
// Mirrors the Jul 2026 browser capture: dual ask_text + ask_text_0_markdown
// tracks stream the same chunks via diff_block; parser must not double-count
// and must assemble the full answer.
test("Schematized API: dual ask_text tracks do not double-count", async () => {
const pplxEvents = [
{
status: "PENDING",
blocks: [
{
intended_usage: "ask_text_0_markdown",
diff_block: {
field: "markdown_block",
patches: [
{
op: "replace",
path: "",
value: { progress: "IN_PROGRESS", chunks: ["Hi Bilal — "] },
},
],
},
},
{
intended_usage: "ask_text",
diff_block: {
field: "markdown_block",
patches: [
{
op: "replace",
path: "",
value: { progress: "IN_PROGRESS", chunks: ["Hi Bilal — "] },
},
],
},
},
],
},
{
status: "PENDING",
blocks: [
{
intended_usage: "ask_text_0_markdown",
diff_block: {
field: "markdown_block",
patches: [{ op: "add", path: "/chunks/1", value: "nice to meet you!" }],
},
},
{
intended_usage: "ask_text",
diff_block: {
field: "markdown_block",
patches: [{ op: "add", path: "/chunks/1", value: "nice to meet you!" }],
},
},
],
},
{
status: "COMPLETED",
final: true,
blocks: [
{
intended_usage: "ask_text_0_markdown",
markdown_block: {
progress: "DONE",
answer: "Hi Bilal — nice to meet you!",
chunks: ["Hi Bilal — nice to meet you!"],
},
},
],
},
];
const restore = mockFetch(200, pplxEvents);
try {
const executor = new PerplexityWebExecutor();
const result = await executor.execute({
model: "pplx-sonar",
body: { messages: [{ role: "user", content: "hello" }], stream: false },
stream: false,
credentials: { apiKey: "test-cookie" },
signal: AbortSignal.timeout(10000),
log: null,
});
assert.equal(result.response.status, 200);
const json = (await result.response.json()) as {
choices: Array<{ message: { content: string } }>;
};
assert.equal(json.choices[0].message.content, "Hi Bilal — nice to meet you!");
} finally {
restore();
}
});
// Unit: extractAnswerFromFinalText pure helper
test("extractAnswerFromFinalText: double-encoded FINAL step blob", async () => {
const { extractAnswerFromFinalText } = await import(
"../../open-sse/executors/perplexity-web/protocol.ts"
);
const text = JSON.stringify([
{ step_type: "INITIAL_QUERY", content: { query: "hello" } },
{
step_type: "FINAL",
content: {
answer: JSON.stringify({
answer: "Recovered from blob",
chunks: ["Recovered ", "from blob"],
}),
},
},
]);
assert.equal(extractAnswerFromFinalText(text), "Recovered from blob");
assert.equal(extractAnswerFromFinalText("plain text answer"), "plain text answer");
assert.equal(extractAnswerFromFinalText(null), null);
assert.equal(extractAnswerFromFinalText(""), null);
});
// ─── Test: Request URL and headers ──────────────────────────────────────────
test("Request: posts to correct Perplexity SSE endpoint", async () => {
@@ -934,7 +1162,7 @@ test("Error: Cloudflare 403 challenge returns a distinct (non-cookie) error", as
});
assert.equal(result.response.status, 403);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxErrorJson;
assert.match(json.error.message, /Cloudflare/i);
assert.ok(!/session-token/i.test(json.error.message), "must not blame the cookie");
} finally {
@@ -956,7 +1184,7 @@ test("Error: TlsClientUnavailableError returns 502 with install hint", async ()
});
assert.equal(result.response.status, 502);
const json = (await result.response.json()) as any;
const json = (await result.response.json()) as PplxErrorJson;
assert.match(json.error.message, /TLS client unavailable/i);
} finally {
restore();