From 16741f64bf9899d1d31724d1f376f22a346104c0 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 21 Jul 2026 15:05:45 -0700 Subject: [PATCH] fix(ai,search,cli): read embed-shim bodies once (#1610); zero-hit source hint (#1484); log swallowed vector-arm failures (#1626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three backlog fixes: - #1610: voyageCompatFetch/zeroEntropyCompatFetch read the response body ONCE via text() + JSON.parse instead of resp.clone().json(). On bun < 1.1.27 clone() truncates large bodies (oven-sh/bun#6348), the parse threw, and the catch fell back to the raw provider shape the AI SDK schema rejects — multi-chunk pages died with "Invalid JSON response". Every JSON return path now rebuilds the Response and strips the stale Content-Length/Content-Encoding headers. Shims exported as test seams; behavioral coverage simulates the truncating clone(). - #1484: a bare `gbrain query`/`search` that returns zero results on a multi-source brain now prints a stderr hint naming the source that was actually searched and how to widen scope (--source-id __all__). Fires only when the caller didn't scope explicitly; best-effort (lookup failure is silent). Default scope stays unchanged — widening to __all__ is a separate maintainer policy call. - #1626: hybridSearch's text-vector arm no longer swallows failures dark. The arm only runs when the embedding provider probed available, so a throw (embed timeout, transient pooler error on the searchVector fan-out) now logs the reason via warnOncePerProcess while keeping the keyword fallback — a cross-source __all__ run can no longer collapse to "No results" with zero diagnostics. Co-Authored-By: Claude Fable 5 --- src/cli.ts | 43 +++++++ src/core/ai/gateway.ts | 69 ++++++++--- src/core/search/hybrid.ts | 14 ++- test/ai/compat-fetch-body-read.serial.test.ts | 115 ++++++++++++++++++ test/ai/zeroentropy-compat-fetch.test.ts | 10 +- test/cli-source-scope-hint.test.ts | 61 ++++++++++ test/hybrid-vector-arm-warn.serial.test.ts | 62 ++++++++++ test/voyage-response-cap.test.ts | 8 +- 8 files changed, 353 insertions(+), 29 deletions(-) create mode 100644 test/ai/compat-fetch-body-read.serial.test.ts create mode 100644 test/cli-source-scope-hint.test.ts create mode 100644 test/hybrid-vector-arm-warn.serial.test.ts diff --git a/src/cli.ts b/src/cli.ts index 804d7dbf3..dae004689 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -466,6 +466,11 @@ async function main() { const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer)); const output = formatResult(op.name, result); if (output) process.stdout.write(output); + // #1484 — invisible-miss hint: a bare query/search that hit zero results + // on a multi-source brain tells the user (stderr) which source it + // actually searched and how to widen the scope. + const hint = await sourceScopeHint(op.name, params, ctx.sourceId, engine, result); + if (hint) console.error(hint); } catch (e: unknown) { // v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally` // STILL runs (drains every background-work sink + disconnects). A bare @@ -837,6 +842,44 @@ async function makeContext(engine: BrainEngine, params: Record) }; } +/** + * #1484 — a bare `gbrain query`/`search` silently scopes to the resolved + * source (usually 'default'); on a multi-source brain a zero-hit run looks + * identical to "the brain doesn't know this" even when the answer lives in + * another source. Returns a stderr hint when (a) the op is query/search, + * (b) it returned zero results, (c) the caller did NOT scope explicitly + * (--source / --source-id / --all-sources), and (d) the brain has >1 + * registered source. Best-effort: any lookup failure returns null. + * + * Exported for tests (same import-safety contract as formatResult). + */ +export async function sourceScopeHint( + opName: string, + params: Record, + sourceId: string, + engine: BrainEngine, + result: unknown, +): Promise { + if (opName !== 'query' && opName !== 'search') return null; + if (!Array.isArray(result) || result.length > 0) return null; + // Explicit scoping (flag tier) = user intent; don't second-guess it. + if (params.source || params.source_id || params.all_sources) return null; + if (sourceId === '__all__') return null; + try { + const rows = await engine.executeRaw<{ n: number }>( + `SELECT count(*)::int AS n FROM sources`, + ); + const n = Number(rows[0]?.n ?? 0); + if (n <= 1) return null; + return ( + `Hint: this brain has ${n} sources; you searched only "${sourceId}". ` + + `Retry with --source-id __all__ (all sources) or --source-id .` + ); + } catch { + return null; // hint is best-effort; never fail the query over it + } +} + // Exported for tests (same import-safety contract as cliAliases/printOpHelp). export function formatResult(opName: string, result: unknown): string { switch (opName) { diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 8a2674484..da664a2f5 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -1000,9 +1000,24 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) // Voyage diverges from OpenAI in two places that break the parser: // - `embedding` is a base64 string (SDK schema expects `number[]`) // - `usage` lacks `prompt_tokens` (SDK schema requires it when usage present) + // + // #1610: read the body ONCE via text() and JSON.parse it. The pre-fix + // `await resp.clone().json()` truncated large bodies on bun < 1.1.27 + // (oven-sh/bun#6348) — the parse threw, the catch fell back to the raw + // response, and multi-chunk pages died with "Invalid JSON response". + // Every JSON return path below rebuilds the Response so a stale + // Content-Length/Content-Encoding header from the original can't lie + // about the rewritten body. + const bodyText = await resp.text(); + const rebuild = (body: string) => { + const headers = new Headers(resp.headers); + headers.delete('content-length'); + headers.delete('content-encoding'); + return new Response(body, { status: resp.status, statusText: resp.statusText, headers }); + }; try { - const json: any = await resp.clone().json(); - if (!json || typeof json !== 'object') return resp; + const json: any = JSON.parse(bodyText); + if (!json || typeof json !== 'object') return rebuild(bodyText); let modified = false; if (Array.isArray(json.data)) { for (const item of json.data) { @@ -1037,22 +1052,19 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) : 0; modified = true; } - if (!modified) return resp; - return new Response(JSON.stringify(json), { - status: resp.status, - statusText: resp.statusText, - headers: resp.headers, - }); + if (!modified) return rebuild(bodyText); + return rebuild(JSON.stringify(json)); } catch (err) { // OOM-cap throws MUST propagate. The catch is here for "Voyage returned // JSON I can't reshape" (parse error, unexpected schema) — falling back - // to the original response is correct in that case. Letting the + // to the original body is correct in that case. Letting the // too-large response through here would defeat the entire purpose of // Layer 2 (the per-embedding cap that fires when Content-Length wasn't // available to Layer 1). if (err instanceof VoyageResponseTooLargeError) throw err; - // If parsing/transformation fails, fall back to the original response. - return resp; + // If parsing/transformation fails, pass the original body through + // (rebuilt — resp's body stream is already consumed by text()). + return rebuild(bodyText); } }) as unknown as typeof fetch; @@ -1192,9 +1204,21 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI // validates. Also map usage.total_tokens → prompt_tokens (SDK requires // prompt_tokens when `usage` is present — same divergence Voyage hit at // gateway.ts:655). + // + // #1610: read the body ONCE via text() + JSON.parse — `resp.clone().json()` + // truncated large bodies on bun < 1.1.27 (oven-sh/bun#6348), so the parse + // threw and the catch fell back to the RAW ZE `{results: ...}` shape, which + // the AI SDK schema rejects → "Invalid JSON response" on multi-chunk pages. + const bodyText = await resp.text(); + const rebuild = (body: string) => { + const headers = new Headers(resp.headers); + headers.delete('content-length'); + headers.delete('content-encoding'); + return new Response(body, { status: resp.status, statusText: resp.statusText, headers }); + }; try { - const json: any = await resp.clone().json(); - if (!json || typeof json !== 'object') return resp; + const json: any = JSON.parse(bodyText); + if (!json || typeof json !== 'object') return rebuild(bodyText); let modified = false; if (Array.isArray(json.results) && !Array.isArray(json.data)) { // Layer 2 OOM cap — per-embedding size. ZE returns float[] arrays, @@ -1228,20 +1252,25 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI // SDK also expects total_tokens; ZE provides it directly. modified = true; } - if (!modified) return resp; - return new Response(JSON.stringify(json), { - status: resp.status, - statusText: resp.statusText, - headers: resp.headers, - }); + if (!modified) return rebuild(bodyText); + return rebuild(JSON.stringify(json)); } catch (err) { // OOM-cap throws MUST propagate. Voyage's pattern: instanceof check on // its own tagged class. Same here — only rethrow our own cap class. if (err instanceof ZeroEntropyResponseTooLargeError) throw err; - return resp; + return rebuild(bodyText); } }) as unknown as typeof fetch; +/** + * Test-only seams (#1610): the compat shims are module-private closures; + * exporting them lets tests drive the response-rewrite paths behaviorally + * (truncating clone(), stale Content-Length) without a live provider. + * Same pattern as __getShrinkStateForTests. + */ +export const __voyageCompatFetchForTests = voyageCompatFetch; +export const __zeroEntropyCompatFetchForTests = zeroEntropyCompatFetch; + /** * Generic asymmetric-embedding shim for openai-compatible recipes that * ship no compat fetch of their own (llama-server, litellm, ollama, ...). diff --git a/src/core/search/hybrid.ts b/src/core/search/hybrid.ts index de0a95eb4..57dde4544 100644 --- a/src/core/search/hybrid.ts +++ b/src/core/search/hybrid.ts @@ -1323,8 +1323,18 @@ export async function hybridSearch( if (effectiveModality === 'both' && imageVectorList !== null) { vectorLists = [...vectorLists, imageVectorList]; } - } catch { - // Embedding failure is non-fatal, fall back to keyword-only + } catch (err) { + // Embedding/vector failure is non-fatal — fall back to keyword-only — + // but say WHY (#1626): this arm only runs when the embedding provider + // probed available, so a throw here is a real failure (embed timeout, + // transient pooler error on the searchVector fan-out). Pre-fix the bare + // catch made a cross-source `--source __all__` run silently collapse to + // keyword-only/"No results" with zero diagnostics. + warnOncePerProcess( + 'hybrid-vector-arm-failed', + `[gbrain] vector arm failed (fail-open, keyword-only fallback): ` + + `${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/test/ai/compat-fetch-body-read.serial.test.ts b/test/ai/compat-fetch-body-read.serial.test.ts new file mode 100644 index 000000000..3b300c2b2 --- /dev/null +++ b/test/ai/compat-fetch-body-read.serial.test.ts @@ -0,0 +1,115 @@ +/** + * #1610 — Voyage/ZeroEntropy compat shims must read the response body ONCE + * via text() instead of `resp.clone().json()`. + * + * On bun < 1.1.27, Response.clone() truncates large bodies (oven-sh/bun#6348): + * the clone().json() parse threw, the shim's catch fell back to the ORIGINAL + * response — whose wire shape (ZE `{results: ...}`, Voyage base64 embeddings) + * the AI SDK's openai-compatible Zod schema rejects — and multi-chunk pages + * failed with "Invalid JSON response". + * + * These tests simulate the truncating clone() and assert the shims still + * return the fully rewritten body. They also pin that the rewritten Response + * does NOT carry the original (now stale) Content-Length header, which lied + * about the rewritten body's size (gateway.ts previously copied + * `headers: resp.headers` verbatim). + */ + +import { afterEach, describe, expect, test } from 'bun:test'; +import { + __voyageCompatFetchForTests, + __zeroEntropyCompatFetchForTests, +} from '../../src/core/ai/gateway.ts'; + +const origFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = origFetch; +}); + +/** Build a Response whose clone() truncates the body (bun < 1.1.27 behavior). */ +function truncatingCloneResponse(body: string): Response { + const headers = { + 'content-type': 'application/json', + // Deliberately stale after any rewrite: the original wire body's length. + 'content-length': String(Buffer.byteLength(body)), + }; + const resp = new Response(body, { status: 200, headers }); + (resp as any).clone = () => + new Response(body.slice(0, 32), { status: 200, headers }); + return resp; +} + +describe('voyageCompatFetch — single body read (#1610)', () => { + test('rewrites base64 embeddings even when clone() truncates the body', async () => { + const floats = new Float32Array([0.5, 0.25, -1]); + const b64 = Buffer.from(floats.buffer).toString('base64'); + const wireBody = JSON.stringify({ + object: 'list', + data: [{ object: 'embedding', embedding: b64, index: 0 }], + model: 'voyage-3', + usage: { total_tokens: 7 }, + }); + globalThis.fetch = (async () => truncatingCloneResponse(wireBody)) as unknown as typeof fetch; + + const out = await __voyageCompatFetchForTests('https://api.voyageai.com/v1/embeddings', { + method: 'POST', + body: JSON.stringify({ input: ['hello'], model: 'voyage-3' }), + headers: { 'content-type': 'application/json' }, + }); + + const json: any = await out.json(); + expect(Array.from(json.data[0].embedding)).toEqual([0.5, 0.25, -1]); + expect(json.usage.prompt_tokens).toBe(7); + // Stale Content-Length from the wire body must not survive the rewrite. + expect(out.headers.get('content-length')).toBeNull(); + expect(out.headers.get('content-encoding')).toBeNull(); + }); +}); + +describe('zeroEntropyCompatFetch — single body read (#1610)', () => { + test('rewrites {results} → {data} even when clone() truncates the body', async () => { + const wireBody = JSON.stringify({ + results: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }], + usage: { total_bytes: 42, total_tokens: 9 }, + }); + let fetchedUrl = ''; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrl = String(url); + return truncatingCloneResponse(wireBody); + }) as unknown as typeof fetch; + + const out = await __zeroEntropyCompatFetchForTests('https://api.zeroentropy.dev/v1/embeddings', { + method: 'POST', + body: JSON.stringify({ input: ['hello'], model: 'zembed-1' }), + headers: { 'content-type': 'application/json' }, + }); + + expect(fetchedUrl.endsWith('/v1/models/embed')).toBe(true); + const json: any = await out.json(); + // The AI SDK schema requires {data: [{embedding, index}]} — the raw ZE + // {results} fallback is exactly the pre-fix "Invalid JSON response". + expect(json.results).toBeUndefined(); + expect(json.data).toHaveLength(2); + expect(json.data[0]).toEqual({ object: 'embedding', embedding: [0.1, 0.2], index: 0 }); + expect(json.data[1].index).toBe(1); + expect(json.usage.prompt_tokens).toBe(9); + expect(out.headers.get('content-length')).toBeNull(); + }); + + test('non-JSON body falls back to the original bytes (rebuilt, still readable)', async () => { + const wireBody = 'plain text, not json'; + globalThis.fetch = (async () => + new Response(wireBody, { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch; + + const out = await __zeroEntropyCompatFetchForTests('https://api.zeroentropy.dev/v1/embeddings', { + method: 'POST', + body: JSON.stringify({ input: ['hello'] }), + }); + // Body was consumed by the shim's single read; the fallback must + // rebuild a readable Response rather than return the drained original. + expect(await out.text()).toBe(wireBody); + }); +}); diff --git a/test/ai/zeroentropy-compat-fetch.test.ts b/test/ai/zeroentropy-compat-fetch.test.ts index e12824b6d..8be2660cf 100644 --- a/test/ai/zeroentropy-compat-fetch.test.ts +++ b/test/ai/zeroentropy-compat-fetch.test.ts @@ -98,16 +98,18 @@ describe('zeroEntropyCompatFetch — OOM caps', () => { expect(src).toMatch(/MAX_ZEROENTROPY_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/); }); - test('Layer 1: Content-Length pre-check before resp.clone().json()', async () => { + test('Layer 1: Content-Length pre-check before the body is read', async () => { const src = await Bun.file(GATEWAY_PATH).text(); // Find the zeroEntropyCompatFetch block bounds, then assert ordering - // within it (mirroring the voyage cap test pattern). + // within it (mirroring the voyage cap test pattern). #1610 moved the + // body read from `resp.clone().json()` to a single `resp.text()` (bun + // < 1.1.27 truncates clone()d bodies, oven-sh/bun#6348). const zeFetchStart = src.indexOf('const zeroEntropyCompatFetch'); expect(zeFetchStart).toBeGreaterThan(0); - const block = src.slice(zeFetchStart, zeFetchStart + 8000); + const block = src.slice(zeFetchStart, zeFetchStart + 9000); const preCheckIdx = block.indexOf("resp.headers.get('content-length')"); - const jsonParseIdx = block.indexOf('await resp.clone().json()'); + const jsonParseIdx = block.indexOf('const bodyText = await resp.text()'); expect(preCheckIdx).toBeGreaterThan(0); expect(jsonParseIdx).toBeGreaterThan(0); // The pre-check MUST appear before the JSON parse — Voyage's lesson diff --git a/test/cli-source-scope-hint.test.ts b/test/cli-source-scope-hint.test.ts new file mode 100644 index 000000000..783217743 --- /dev/null +++ b/test/cli-source-scope-hint.test.ts @@ -0,0 +1,61 @@ +/** + * #1484 — invisible-miss hint. A bare `gbrain query` resolves to a single + * source (usually 'default'); on a multi-source brain a zero-hit run gave no + * signal that the answer might live in another source. sourceScopeHint + * returns the stderr hint exactly when: query/search op + zero results + + * no explicit scoping param + >1 registered source. + */ + +import { describe, expect, test } from 'bun:test'; +import { sourceScopeHint } from '../src/cli.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; + +function fakeEngine(sourceCount: number, fail = false): BrainEngine { + return { + executeRaw: async () => { + if (fail) throw new Error('sources table missing'); + return [{ n: sourceCount }]; + }, + } as unknown as BrainEngine; +} + +describe('sourceScopeHint (#1484)', () => { + test('fires on a bare zero-hit query against a multi-source brain', async () => { + const hint = await sourceScopeHint('query', {}, 'default', fakeEngine(3), []); + expect(hint).toContain('3 sources'); + expect(hint).toContain('"default"'); + expect(hint).toContain('--source-id __all__'); + }); + + test('fires for search too', async () => { + const hint = await sourceScopeHint('search', {}, 'wiki', fakeEngine(2), []); + expect(hint).toContain('"wiki"'); + }); + + test('silent when results were found', async () => { + expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3), [{ slug: 'a' }])).toBeNull(); + }); + + test('silent when the caller scoped explicitly', async () => { + expect(await sourceScopeHint('query', { source_id: 'wiki' }, 'wiki', fakeEngine(3), [])).toBeNull(); + expect(await sourceScopeHint('query', { source: 'wiki' }, 'wiki', fakeEngine(3), [])).toBeNull(); + expect(await sourceScopeHint('query', { all_sources: true }, '__all__', fakeEngine(3), [])).toBeNull(); + }); + + test('silent when the resolved scope is already __all__', async () => { + expect(await sourceScopeHint('query', {}, '__all__', fakeEngine(3), [])).toBeNull(); + }); + + test('silent on a single-source brain', async () => { + expect(await sourceScopeHint('query', {}, 'default', fakeEngine(1), [])).toBeNull(); + }); + + test('silent for non-search ops and non-array results', async () => { + expect(await sourceScopeHint('get_stats', {}, 'default', fakeEngine(3), [])).toBeNull(); + expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3), { rows: [] })).toBeNull(); + }); + + test('best-effort: sources lookup failure returns null, never throws', async () => { + expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3, true), [])).toBeNull(); + }); +}); diff --git a/test/hybrid-vector-arm-warn.serial.test.ts b/test/hybrid-vector-arm-warn.serial.test.ts new file mode 100644 index 000000000..cd95f324e --- /dev/null +++ b/test/hybrid-vector-arm-warn.serial.test.ts @@ -0,0 +1,62 @@ +/** + * #1626 — hybridSearch's text-vector arm must not fail DARK. + * + * The arm only runs when the embedding provider probed available, so a throw + * inside it (embed timeout, transient pooler error on searchVector) is a real + * failure. Pre-fix, a bare `catch {}` swallowed it and the run silently + * collapsed to keyword-only — under `--source __all__` on a strained pooler + * that read as a non-deterministic "No results". The fix logs the swallowed + * reason via warnOncePerProcess while keeping the keyword fallback. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { hybridSearch } from '../src/core/search/hybrid.ts'; +import { __setEmbedTransportForTests } from '../src/core/ai/gateway.ts'; +import { _resetWarnOnceForTests } from '../src/core/utils.ts'; + +let engine: PGLiteEngine; +const origWarn = console.warn; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + await engine.putPage('people/alice-example', { + type: 'person', + title: 'Alice Example', + compiled_truth: 'Alice Example is a test person for the vector-arm warn test.', + }); +}); + +afterAll(async () => { + console.warn = origWarn; + __setEmbedTransportForTests(null); + await engine.disconnect(); +}); + +describe('hybridSearch vector-arm failure telemetry (#1626)', () => { + test('embed failure logs the swallowed reason and falls back to keyword', async () => { + _resetWarnOnceForTests(); + // Installing a transport makes isAvailable('embedding') true (test-seam + // fast path), so the vector arm RUNS — and then throws. + __setEmbedTransportForTests(() => { + throw new Error('pooler exploded mid-fanout'); + }); + const warnings: string[] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }; + try { + const results = await hybridSearch(engine, 'alice'); + // Keyword fallback still returns results — fail-open preserved. + expect(results.some((r) => r.slug === 'people/alice-example')).toBe(true); + } finally { + console.warn = origWarn; + __setEmbedTransportForTests(null); + } + const armWarnings = warnings.filter((w) => w.includes('vector arm failed')); + expect(armWarnings).toHaveLength(1); + expect(armWarnings[0]).toContain('pooler exploded mid-fanout'); + }); +}); diff --git a/test/voyage-response-cap.test.ts b/test/voyage-response-cap.test.ts index c873ed223..2a6e0a66e 100644 --- a/test/voyage-response-cap.test.ts +++ b/test/voyage-response-cap.test.ts @@ -34,7 +34,7 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => { expect(source).toMatch(/MAX_VOYAGE_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/); }); - test('Layer 1: Content-Length pre-check fires BEFORE resp.clone().json() (D10 OOM defense)', async () => { + test('Layer 1: Content-Length pre-check fires BEFORE the body is read (D10 OOM defense)', async () => { const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text(); // Anchor relative to the post-fetch handler block. The function declaration // contains an OUTBOUND request body section earlier; we want to verify @@ -47,8 +47,10 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => { // doesn't pin to comment text. const preCheckIdx = inboundBlock.indexOf("resp.headers.get('content-length')"); // Use the full lvalue assignment so the match doesn't accidentally hit - // comment text that mentions `await resp.clone().json()` for context. - const jsonParseIdx = inboundBlock.indexOf('const json: any = await resp.clone().json()'); + // comment text that mentions the body read for context. (#1610 moved the + // read from `resp.clone().json()` to a single `resp.text()` — bun < + // 1.1.27 truncates clone()d bodies, oven-sh/bun#6348.) + const jsonParseIdx = inboundBlock.indexOf('const bodyText = await resp.text()'); expect(preCheckIdx).toBeGreaterThan(0); expect(jsonParseIdx).toBeGreaterThan(0); // The pre-check MUST appear before the JSON parse — otherwise the OOM