diff --git a/changelog.d/fixes/6955-perplexity-web-empty-content.md b/changelog.d/fixes/6955-perplexity-web-empty-content.md new file mode 100644 index 0000000000..ac59619904 --- /dev/null +++ b/changelog.d/fixes/6955-perplexity-web-empty-content.md @@ -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) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index af68f5c97a..aed651921b 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 44091f15fb..c75f434896 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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, diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 21e9148882..ece96e0b64 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -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({ + 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({ diff --git a/open-sse/executors/perplexity-web/protocol.ts b/open-sse/executors/perplexity-web/protocol.ts index 8a0434aeba..73b5d4e139 100644 --- a/open-sse/executors/perplexity-web/protocol.ts +++ b/open-sse/executors/perplexity-web/protocol.ts @@ -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 = { - "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; + const stepType = String(s.step_type || s.stepType || ""); + if (stepType && stepType !== "FINAL") continue; + + const content = s.content as Record | string | undefined; + let rawAnswer: unknown = + typeof content === "string" + ? content + : content && typeof content === "object" + ? (content as Record).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; + 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>) + .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, + 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, 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(); 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 }; diff --git a/tests/unit/perplexity-web.test.ts b/tests/unit/perplexity-web.test.ts index 24f24b069e..1d892e1655 100644 --- a/tests/unit/perplexity-web.test.ts +++ b/tests/unit/perplexity-web.test.ts @@ -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();