From 93176805c1833e7f016b50f4ddfc5301c4269400 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 21:11:34 -0400 Subject: [PATCH] fix(app): parse multiline SSE events when observing activation --- .../telemetry-sse-regression.test.ts | 14 ++++++++ apps/app/src/lib/launch-telemetry.ts | 35 ++++++++++++------- 2 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 apps/app/src/lib/__tests__/telemetry-sse-regression.test.ts diff --git a/apps/app/src/lib/__tests__/telemetry-sse-regression.test.ts b/apps/app/src/lib/__tests__/telemetry-sse-regression.test.ts new file mode 100644 index 0000000..328e397 --- /dev/null +++ b/apps/app/src/lib/__tests__/telemetry-sse-regression.test.ts @@ -0,0 +1,14 @@ +// @vitest-environment node +import { it, expect } from "vitest"; +import { observeRunResult } from "../launch-telemetry"; +it.each(["\n","\r\n","\r"])("observes multiline events separated by %j", async newline => { + const body = ['data: {"type":"TEXT_MESSAGE_CONTENT",','data: "delta":"你好"}',"",'data: {"type":"RUN_FINISHED"}',"",""].join(newline); + const bytes = new TextEncoder().encode(body); + const stream = new ReadableStream({start(c){for(const b of bytes)c.enqueue(new Uint8Array([b])); c.close();}}); + await expect(observeRunResult(stream)).resolves.toBe(true); + expect(stream.locked).toBe(false); +}); +it("releases the reader after a failed stream", async () => { + const stream = new ReadableStream({pull(c){c.error(new Error("offline"));}}); + await expect(observeRunResult(stream)).resolves.toBe(false); expect(stream.locked).toBe(false); +}); diff --git a/apps/app/src/lib/launch-telemetry.ts b/apps/app/src/lib/launch-telemetry.ts index f58437b..6331c2f 100644 --- a/apps/app/src/lib/launch-telemetry.ts +++ b/apps/app/src/lib/launch-telemetry.ts @@ -213,31 +213,42 @@ export async function observeRunResult(stream: ReadableStream { - if (!line.startsWith("data:")) return; + let dataLines: string[] = []; + const inspect = () => { + const data = dataLines.join("\n"); + dataLines = []; + if (!data) return; let event: { type?: string; delta?: string }; - try { - event = JSON.parse(line.slice(5)); - } catch { - return; - } + try { event = JSON.parse(data); } catch { return; } if (event.type === "TEXT_MESSAGE_CONTENT" && typeof event.delta === "string" && event.delta.trim()) text = true; if (event.type === "RUN_ERROR") failed = true; if (event.type === "RUN_FINISHED") finished = true; }; - + const line = (value: string) => { + if (!value) inspect(); + else if (value.startsWith("data:")) dataLines.push(value.slice(5).replace(/^ /, "")); + }; + const consume = (final = false) => { + let match: RegExpExecArray | null; + while ((match = /\r\n|\r(?!$)|\n/.exec(carry))) { + line(carry.slice(0, match.index)); + carry = carry.slice(match.index + match[0].length); + } + if (final) { line(carry.replace(/\r$/, "")); inspect(); carry = ""; } + }; try { for (;;) { const { done, value } = await reader.read(); if (done) break; carry += typeof value === "string" ? value : decoder.decode(value, { stream: true }); - const lines = carry.split("\n"); - carry = lines.pop() ?? ""; - lines.forEach((line) => inspect(line.trim())); + consume(); } - inspect(carry.trim()); + carry += decoder.decode(); + consume(true); } catch { return false; + } finally { + reader.releaseLock(); } return finished && text && !failed; }