From e8147d54896d31b1faa74cb02eef7ffd5c72f89d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:35:33 +0900 Subject: [PATCH 1/4] fix(sidecars): keep caller aborts account-neutral --- src/vision/describe.ts | 6 +++- src/web-search/executor.ts | 6 +++- tests/sidecar-abort.test.ts | 62 ++++++++++++++++++++++++++++++++----- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/vision/describe.ts b/src/vision/describe.ts index 444bb4923b..3720ad8de7 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -120,8 +120,12 @@ export async function describeImage( if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error }; return { text: parsed.text }; } catch (e) { - recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"); const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + const callerAborted = abortSignal?.aborted === true + && linkedSignal.signal.aborted + && linkedSignal.signal.reason === abortSignal.reason + && e === linkedSignal.signal.reason; + recordOutcome?.(callerAborted ? "connect_neutral" : kind); console.warn(`[vision] sidecar ${kind} (${Date.now() - t0}ms)`); return { text: "", error: e instanceof Error ? e.message : String(e) }; } finally { diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index e5cd585b61..7cdfc5d7dc 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -102,8 +102,12 @@ export async function runWebSearch( detachBodyGuard(); } } catch (e) { - recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"); const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + const callerAborted = abortSignal?.aborted === true + && linkedSignal.signal.aborted + && linkedSignal.signal.reason === abortSignal.reason + && e === linkedSignal.signal.reason; + recordOutcome?.(callerAborted ? "connect_neutral" : kind); console.warn(`[web-search] sidecar ${kind} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) }; } finally { diff --git a/tests/sidecar-abort.test.ts b/tests/sidecar-abort.test.ts index 2aa9df394a..46c7d79b8a 100644 --- a/tests/sidecar-abort.test.ts +++ b/tests/sidecar-abort.test.ts @@ -37,7 +37,8 @@ function installAbortAwareFetch(): () => AbortSignal { globalThis.fetch = ((_, init) => { seenSignal = init?.signal as AbortSignal | undefined; return new Promise((_, reject) => { - seenSignal?.addEventListener("abort", () => reject(new Error("aborted by turn")), { once: true }); + const signal = seenSignal; + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); }); }) as typeof fetch; return () => { @@ -97,6 +98,7 @@ describe("sidecar abort propagation", () => { test("web-search sidecar fetch observes the WebSocket turn abort signal", async () => { const getSignal = installAbortAwareFetch(); const turn = new AbortController(); + const recorded: unknown[] = []; const outcome = runWebSearch( "current docs", { type: "web_search" }, @@ -104,13 +106,15 @@ describe("sidecar abort propagation", () => { new Headers({ authorization: "Bearer token" }), { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, turn.signal, + value => recorded.push(value), ); const signal = getSignal(); expect(signal.aborted).toBe(false); - turn.abort("replacement turn"); + turn.abort(new Error("aborted by turn")); expect(signal.aborted).toBe(true); expect((await outcome).error).toBe("aborted by turn"); + expect(recorded).toEqual(["connect_neutral"]); }); test("web-search sidecar records HTTP and connect outcomes", async () => { @@ -130,14 +134,19 @@ describe("sidecar abort propagation", () => { expect(httpOutcome.error).toBe("sidecar HTTP 401: expired"); expect(recorded).toEqual([401]); - globalThis.fetch = (() => Promise.reject(new Error("network down"))) as typeof fetch; + const lateAbort = new AbortController(); + globalThis.fetch = (() => { + const rejected = Promise.reject(new Error("network down")); + queueMicrotask(() => lateAbort.abort(new Error("late caller abort"))); + return rejected; + }) as typeof fetch; const connectOutcome = await runWebSearch( "current docs", { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, - undefined, + lateAbort.signal, outcome => recorded.push(outcome), ); @@ -213,6 +222,7 @@ describe("sidecar abort propagation", () => { test("vision sidecar fetch observes the WebSocket turn abort signal", async () => { const getSignal = installAbortAwareFetch(); const turn = new AbortController(); + const recorded: unknown[] = []; const outcome = describeImage( "data:image/png;base64,iVBORw0KGgo=", "high", @@ -221,13 +231,15 @@ describe("sidecar abort propagation", () => { new Headers({ authorization: "Bearer token" }), { model: "gpt-5.4-mini", timeoutMs: 30_000 }, turn.signal, + value => recorded.push(value), ); const signal = getSignal(); expect(signal.aborted).toBe(false); - turn.abort("replacement turn"); + turn.abort(new Error("aborted by turn")); expect(signal.aborted).toBe(true); expect((await outcome).error).toBe("aborted by turn"); + expect(recorded).toEqual(["connect_neutral"]); }); test("vision sidecar records HTTP and connect outcomes", async () => { @@ -248,7 +260,12 @@ describe("sidecar abort propagation", () => { expect(httpOutcome.error).toBe("vision sidecar HTTP 403: denied"); expect(recorded).toEqual([403]); - globalThis.fetch = (() => Promise.reject(new Error("vision network down"))) as typeof fetch; + const lateAbort = new AbortController(); + globalThis.fetch = (() => { + const rejected = Promise.reject(new Error("vision network down")); + queueMicrotask(() => lateAbort.abort(new Error("late caller abort"))); + return rejected; + }) as typeof fetch; const connectOutcome = await describeImage( "data:image/png;base64,iVBORw0KGgo=", "high", @@ -256,7 +273,7 @@ describe("sidecar abort propagation", () => { forwardProvider, new Headers({ authorization: "Bearer token" }), { model: "gpt-5.4-mini", timeoutMs: 30_000 }, - undefined, + lateAbort.signal, outcome => recorded.push(outcome), ); @@ -264,6 +281,37 @@ describe("sidecar abort propagation", () => { expect(recorded).toEqual([403, "connect_error"]); }); + test("sidecar deadlines remain timeout health evidence", async () => { + const webRecorded: unknown[] = []; + installAbortAwareFetch(); + const webOutcome = await runWebSearch( + "current docs", + { type: "web_search" }, + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 1 }, + undefined, + outcome => webRecorded.push(outcome), + ); + expect(webOutcome.error).toBe("Timeout elapsed"); + expect(webRecorded).toEqual(["timeout"]); + + const visionRecorded: unknown[] = []; + installAbortAwareFetch(); + const visionOutcome = await describeImage( + "data:image/png;base64,iVBORw0KGgo=", + "high", + "inspect screenshot", + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", timeoutMs: 1 }, + undefined, + outcome => visionRecorded.push(outcome), + ); + expect(visionOutcome.error).toBe("Timeout elapsed"); + expect(visionRecorded).toEqual(["timeout"]); + }); + test("vision sidecar redacts echoed bearer-shaped error bodies", async () => { globalThis.fetch = (() => Promise.resolve(new Response("upstream echoed Bearer sk-secret-sidecar", { status: 403 }))) as typeof fetch; const outcome = await describeImage( From c6a1367b884d03ac334b5c5dfbfa86a7d68c4b9a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:52:21 +0900 Subject: [PATCH 2/4] test(sidecars): cover response-body caller aborts --- tests/sidecar-abort.test.ts | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/sidecar-abort.test.ts b/tests/sidecar-abort.test.ts index 46c7d79b8a..dee99ad43f 100644 --- a/tests/sidecar-abort.test.ts +++ b/tests/sidecar-abort.test.ts @@ -47,6 +47,45 @@ function installAbortAwareFetch(): () => AbortSignal { }; } +function installBodyAbortFetch(): { getSignal: () => AbortSignal; getBody: () => ReadableStream } { + let seenSignal: AbortSignal | undefined; + let seenBody: ReadableStream | undefined; + globalThis.fetch = ((_, init) => { + seenSignal = init?.signal as AbortSignal | undefined; + let bodyController: ReadableStreamDefaultController | undefined; + seenBody = new ReadableStream({ + start(controller) { + bodyController = controller; + }, + }); + const signal = seenSignal; + signal?.addEventListener("abort", () => bodyController?.error(signal.reason), { once: true }); + return Promise.resolve(new Response(seenBody, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + })); + }) as typeof fetch; + return { + getSignal: () => { + if (!seenSignal) throw new Error("fetch was not called"); + return seenSignal; + }, + getBody: () => { + if (!seenBody) throw new Error("fetch was not called"); + return seenBody; + }, + }; +} + +async function waitForBodyReader(body: ReadableStream, turn: AbortController): Promise { + for (let attempt = 0; attempt < 200 && !body.locked; attempt += 1) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + if (body.locked) return; + turn.abort(new Error("sidecar response body reader was not attached")); + throw new Error("sidecar response body reader was not attached"); +} + function sseText(text: string): Response { return new Response( `event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":${JSON.stringify(text)}}\n\n` + @@ -242,6 +281,51 @@ describe("sidecar abort propagation", () => { expect(recorded).toEqual(["connect_neutral"]); }); + test("response-body caller aborts stay account-neutral for both sidecars", async () => { + const webFetch = installBodyAbortFetch(); + const webTurn = new AbortController(); + const webRecorded: unknown[] = []; + const webOutcome = runWebSearch( + "current docs", + { type: "web_search" }, + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + webTurn.signal, + value => webRecorded.push(value), + ); + const webSignal = webFetch.getSignal(); + const webBody = webFetch.getBody(); + await waitForBodyReader(webBody, webTurn); + const webReason = new Error("web body aborted by turn"); + webTurn.abort(webReason); + expect(webSignal.reason).toBe(webReason); + expect((await webOutcome).error).toBe("web body aborted by turn"); + expect(webRecorded).toEqual([200, "connect_neutral"]); + + const visionFetch = installBodyAbortFetch(); + const visionTurn = new AbortController(); + const visionRecorded: unknown[] = []; + const visionOutcome = describeImage( + "data:image/png;base64,iVBORw0KGgo=", + "high", + "inspect screenshot", + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + visionTurn.signal, + value => visionRecorded.push(value), + ); + const visionSignal = visionFetch.getSignal(); + const visionBody = visionFetch.getBody(); + await waitForBodyReader(visionBody, visionTurn); + const visionReason = new Error("vision body aborted by turn"); + visionTurn.abort(visionReason); + expect(visionSignal.reason).toBe(visionReason); + expect((await visionOutcome).error).toBe("vision body aborted by turn"); + expect(visionRecorded).toEqual([200, "connect_neutral"]); + }); + test("vision sidecar records HTTP and connect outcomes", async () => { const recorded: unknown[] = []; globalThis.fetch = (() => Promise.resolve(new Response("denied", { status: 403 }))) as typeof fetch; From 1ec8aac7ab7caf1bbdd51309199e086ae15dc470 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:01:21 +0900 Subject: [PATCH 3/4] fix(sidecars): defer success until body completion --- src/vision/describe.ts | 4 +- src/web-search/executor.ts | 7 ++- tests/sidecar-abort.test.ts | 104 +++++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/vision/describe.ts b/src/vision/describe.ts index 3720ad8de7..1cbdaa4cbb 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -102,8 +102,8 @@ export async function describeImage( }), { abortSignal: linkedSignal.signal, label: "vision-sidecar" }, ); - recordOutcome?.(res.status); if (!res.ok) { + recordOutcome?.(res.status); const t = await res.text().catch(() => ""); console.warn(`[vision] sidecar HTTP ${res.status} (${Date.now() - t0}ms)`); return { text: "", error: `vision sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; @@ -115,6 +115,8 @@ export async function describeImage( } finally { detachBodyGuard(); } + if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason; + recordOutcome?.(res.status); // The backend can return HTTP 200 then stream a `response.failed`/`error` event with no text; // surface that as a describe error instead of an empty (silently-blank) description. if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error }; diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 7cdfc5d7dc..672ba72a74 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -85,19 +85,22 @@ export async function runWebSearch( }), { abortSignal: linkedSignal.signal, label: "web-search-sidecar" }, ); - recordOutcome?.(res.status); // Attach the body guard before ANY branch reads it. The success path guarded itself below, // but the failure branch's `res.text()` runs first, so a cancel landing between fetch // resolution and reader attach orphaned the internal rejection (found investigating #1419). const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); if (!res.ok) { + recordOutcome?.(res.status); const t = await res.text().catch(() => ""); detachBodyGuard(); console.warn(`[web-search] sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; } try { - return await parseSidecarSSE(res); + const parsed = await parseSidecarSSE(res); + if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason; + recordOutcome?.(res.status); + return parsed; } finally { detachBodyGuard(); } diff --git a/tests/sidecar-abort.test.ts b/tests/sidecar-abort.test.ts index dee99ad43f..b770b76491 100644 --- a/tests/sidecar-abort.test.ts +++ b/tests/sidecar-abort.test.ts @@ -77,6 +77,36 @@ function installBodyAbortFetch(): { getSignal: () => AbortSignal; getBody: () => }; } +function installPreReaderAbortFetch( + turn: AbortController, + reason: Error, +): { getSignal: () => AbortSignal; wasBodyLockedAtAbort: () => boolean } { + let seenSignal: AbortSignal | undefined; + let bodyLockedAtAbort: boolean | undefined; + globalThis.fetch = ((_, init) => { + seenSignal = init?.signal as AbortSignal | undefined; + const body = new ReadableStream(); + queueMicrotask(() => { + bodyLockedAtAbort = body.locked; + turn.abort(reason); + }); + return Promise.resolve(new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + })); + }) as typeof fetch; + return { + getSignal: () => { + if (!seenSignal) throw new Error("fetch was not called"); + return seenSignal; + }, + wasBodyLockedAtAbort: () => { + if (bodyLockedAtAbort === undefined) throw new Error("abort did not run"); + return bodyLockedAtAbort; + }, + }; +} + async function waitForBodyReader(body: ReadableStream, turn: AbortController): Promise { for (let attempt = 0; attempt < 200 && !body.locked; attempt += 1) { await new Promise(resolve => setTimeout(resolve, 0)); @@ -301,7 +331,7 @@ describe("sidecar abort propagation", () => { webTurn.abort(webReason); expect(webSignal.reason).toBe(webReason); expect((await webOutcome).error).toBe("web body aborted by turn"); - expect(webRecorded).toEqual([200, "connect_neutral"]); + expect(webRecorded).toEqual(["connect_neutral"]); const visionFetch = installBodyAbortFetch(); const visionTurn = new AbortController(); @@ -323,7 +353,77 @@ describe("sidecar abort propagation", () => { visionTurn.abort(visionReason); expect(visionSignal.reason).toBe(visionReason); expect((await visionOutcome).error).toBe("vision body aborted by turn"); - expect(visionRecorded).toEqual([200, "connect_neutral"]); + expect(visionRecorded).toEqual(["connect_neutral"]); + }); + + test("pre-reader caller aborts stay account-neutral for both sidecars", async () => { + const webTurn = new AbortController(); + const webReason = new Error("web aborted before reader attach"); + const webFetch = installPreReaderAbortFetch(webTurn, webReason); + const webRecorded: unknown[] = []; + const webOutcome = await runWebSearch( + "current docs", + { type: "web_search" }, + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + webTurn.signal, + value => webRecorded.push(value), + ); + expect(webFetch.wasBodyLockedAtAbort()).toBe(false); + expect(webFetch.getSignal().reason).toBe(webReason); + expect(webOutcome.error).toBe("web aborted before reader attach"); + expect(webRecorded).toEqual(["connect_neutral"]); + + const visionTurn = new AbortController(); + const visionReason = new Error("vision aborted before reader attach"); + const visionFetch = installPreReaderAbortFetch(visionTurn, visionReason); + const visionRecorded: unknown[] = []; + const visionOutcome = await describeImage( + "data:image/png;base64,iVBORw0KGgo=", + "high", + "inspect screenshot", + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + visionTurn.signal, + value => visionRecorded.push(value), + ); + expect(visionFetch.wasBodyLockedAtAbort()).toBe(false); + expect(visionFetch.getSignal().reason).toBe(visionReason); + expect(visionOutcome.error).toBe("vision aborted before reader attach"); + expect(visionRecorded).toEqual(["connect_neutral"]); + }); + + test("successful SSE bodies record HTTP success once for both sidecars", async () => { + const webRecorded: unknown[] = []; + globalThis.fetch = (() => Promise.resolve(sseText("done"))) as typeof fetch; + const webOutcome = await runWebSearch( + "current docs", + { type: "web_search" }, + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + undefined, + value => webRecorded.push(value), + ); + expect(webOutcome.text).toBe("done"); + expect(webRecorded).toEqual([200]); + + const visionRecorded: unknown[] = []; + globalThis.fetch = (() => Promise.resolve(sseText("image description"))) as typeof fetch; + const visionOutcome = await describeImage( + "data:image/png;base64,iVBORw0KGgo=", + "high", + "inspect screenshot", + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + undefined, + value => visionRecorded.push(value), + ); + expect(visionOutcome.text).toBe("image description"); + expect(visionRecorded).toEqual([200]); }); test("vision sidecar records HTTP and connect outcomes", async () => { From c3234056545072385a082c535c098cddff41b7fa Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:10:37 +0900 Subject: [PATCH 4/4] fix(vision): guard HTTP error body cancellation --- src/vision/describe.ts | 27 +++++++++++----------- tests/sidecar-abort.test.ts | 46 ++++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/vision/describe.ts b/src/vision/describe.ts index 1cbdaa4cbb..d580a607e8 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -102,25 +102,24 @@ export async function describeImage( }), { abortSignal: linkedSignal.signal, label: "vision-sidecar" }, ); - if (!res.ok) { - recordOutcome?.(res.status); - const t = await res.text().catch(() => ""); - console.warn(`[vision] sidecar HTTP ${res.status} (${Date.now() - t0}ms)`); - return { text: "", error: `vision sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; - } const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); - let parsed; try { - parsed = await parseSidecarSSE(res); + if (!res.ok) { + recordOutcome?.(res.status); + const t = await res.text().catch(() => ""); + console.warn(`[vision] sidecar HTTP ${res.status} (${Date.now() - t0}ms)`); + return { text: "", error: `vision sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` }; + } + const parsed = await parseSidecarSSE(res); + if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason; + recordOutcome?.(res.status); + // The backend can return HTTP 200 then stream a `response.failed`/`error` event with no text; + // surface that as a describe error instead of an empty (silently-blank) description. + if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error }; + return { text: parsed.text }; } finally { detachBodyGuard(); } - if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason; - recordOutcome?.(res.status); - // The backend can return HTTP 200 then stream a `response.failed`/`error` event with no text; - // surface that as a describe error instead of an empty (silently-blank) description. - if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error }; - return { text: parsed.text }; } catch (e) { const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; const callerAborted = abortSignal?.aborted === true diff --git a/tests/sidecar-abort.test.ts b/tests/sidecar-abort.test.ts index b770b76491..13ee59eebe 100644 --- a/tests/sidecar-abort.test.ts +++ b/tests/sidecar-abort.test.ts @@ -80,18 +80,33 @@ function installBodyAbortFetch(): { getSignal: () => AbortSignal; getBody: () => function installPreReaderAbortFetch( turn: AbortController, reason: Error, -): { getSignal: () => AbortSignal; wasBodyLockedAtAbort: () => boolean } { + status = 200, +): { getSignal: () => AbortSignal; wasBodyLockedAtAbort: () => boolean; wasBodyCanceled: () => boolean } { let seenSignal: AbortSignal | undefined; let bodyLockedAtAbort: boolean | undefined; + let bodyCanceled = false; + let fallback: ReturnType | undefined; globalThis.fetch = ((_, init) => { seenSignal = init?.signal as AbortSignal | undefined; - const body = new ReadableStream(); + let bodyController: ReadableStreamDefaultController | undefined; + const body = new ReadableStream({ + start(controller) { + bodyController = controller; + }, + cancel() { + bodyCanceled = true; + if (fallback !== undefined) clearTimeout(fallback); + }, + }); queueMicrotask(() => { bodyLockedAtAbort = body.locked; turn.abort(reason); + fallback = setTimeout(() => { + if (!bodyCanceled) bodyController?.close(); + }, 25); }); return Promise.resolve(new Response(body, { - status: 200, + status, headers: { "Content-Type": "text/event-stream" }, })); }) as typeof fetch; @@ -104,6 +119,7 @@ function installPreReaderAbortFetch( if (bodyLockedAtAbort === undefined) throw new Error("abort did not run"); return bodyLockedAtAbort; }, + wasBodyCanceled: () => bodyCanceled, }; } @@ -371,6 +387,7 @@ describe("sidecar abort propagation", () => { value => webRecorded.push(value), ); expect(webFetch.wasBodyLockedAtAbort()).toBe(false); + expect(webFetch.wasBodyCanceled()).toBe(true); expect(webFetch.getSignal().reason).toBe(webReason); expect(webOutcome.error).toBe("web aborted before reader attach"); expect(webRecorded).toEqual(["connect_neutral"]); @@ -390,11 +407,34 @@ describe("sidecar abort propagation", () => { value => visionRecorded.push(value), ); expect(visionFetch.wasBodyLockedAtAbort()).toBe(false); + expect(visionFetch.wasBodyCanceled()).toBe(true); expect(visionFetch.getSignal().reason).toBe(visionReason); expect(visionOutcome.error).toBe("vision aborted before reader attach"); expect(visionRecorded).toEqual(["connect_neutral"]); }); + test("vision guards an HTTP-error body before a pre-reader caller abort", async () => { + const turn = new AbortController(); + const reason = new Error("vision HTTP body aborted before reader attach"); + const fetchState = installPreReaderAbortFetch(turn, reason, 403); + const recorded: unknown[] = []; + const outcome = await describeImage( + "data:image/png;base64,iVBORw0KGgo=", + "high", + "inspect screenshot", + forwardProvider, + new Headers({ authorization: "Bearer token" }), + { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + turn.signal, + value => recorded.push(value), + ); + expect(fetchState.wasBodyLockedAtAbort()).toBe(false); + expect(fetchState.wasBodyCanceled()).toBe(true); + expect(fetchState.getSignal().reason).toBe(reason); + expect(outcome.error).toBe("vision sidecar HTTP 403: "); + expect(recorded).toEqual([403]); + }); + test("successful SSE bodies record HTTP success once for both sidecars", async () => { const webRecorded: unknown[] = []; globalThis.fetch = (() => Promise.resolve(sseText("done"))) as typeof fetch;