From f4de3c87484f1d758948448966c205a55af26f19 Mon Sep 17 00:00:00 2001 From: Jack Cowey Date: Wed, 25 Mar 2026 13:23:04 +0000 Subject: [PATCH 1/2] fix(combo): sanitize TransformStream TextDecoder state corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sanitize TransformStream (commit 5a8c644) shared the same TextDecoder instance with the upstream transform stream. This corrupted UTF-8 state when decoding SSE chunks, producing garbled output that broke clients like openclaw that parse the stream. - Use a separate TextDecoder for the sanitize stream - Always decode→encode in sanitize (don't mix raw passthrough with decoded text) - Add flush() handler to emit remaining buffered bytes - Fix double-escaped regex (\\n → \n) for tag stripping --- open-sse/services/combo.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 5bc36d6bc0..829f97b988 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -526,18 +526,30 @@ export async function handleComboChat({ // visible content so they don't leak to the user. The tag is still // present in the full response for round-trip context pinning, but // we clean it from each SSE chunk's content field before delivery. + // + // IMPORTANT: Use a SEPARATE TextDecoder from the transform stream above. + // The transform stream's decoder accumulates UTF-8 state; reusing it here + // would corrupt multi-byte characters split across chunk boundaries. + const sanitizeDecoder = new TextDecoder(); const sanitize = new TransformStream({ transform(chunk, controller) { - const text = decoder.decode(chunk, { stream: true }); - // Only run replacement if the chunk actually contains the tag + const text = sanitizeDecoder.decode(chunk, { stream: true }); if (text.includes("")) { - const cleaned = text.replace( - /(?:\\\\n|\\n)?[^<]+<\/omniModel>(?:\\\\n|\\n)?/g, - "" - ); + const cleaned = text.replace(/\n?[^<]+<\/omniModel>\n?/g, ""); controller.enqueue(encoder.encode(cleaned)); } else { - controller.enqueue(chunk); + controller.enqueue(encoder.encode(text)); + } + }, + flush(controller) { + const tail = sanitizeDecoder.decode(); + if (tail) { + if (tail.includes("")) { + const cleaned = tail.replace(/\n?[^<]+<\/omniModel>\n?/g, ""); + if (cleaned) controller.enqueue(encoder.encode(cleaned)); + } else { + controller.enqueue(encoder.encode(tail)); + } } }, }); From 600149fc2b499a7fc1c1d6f4e02a05e41444cc7c Mon Sep 17 00:00:00 2001 From: Jack Cowey Date: Wed, 25 Mar 2026 13:28:34 +0000 Subject: [PATCH 2/2] fix(combo): guard against empty text in sanitize transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns transform logic with flush — skip enqueuing when decoded text is empty. Addresses review feedback on PR #614. --- open-sse/services/combo.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 829f97b988..317a9f97a4 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -534,11 +534,13 @@ export async function handleComboChat({ const sanitize = new TransformStream({ transform(chunk, controller) { const text = sanitizeDecoder.decode(chunk, { stream: true }); - if (text.includes("")) { - const cleaned = text.replace(/\n?[^<]+<\/omniModel>\n?/g, ""); - controller.enqueue(encoder.encode(cleaned)); - } else { - controller.enqueue(encoder.encode(text)); + if (text) { + if (text.includes("")) { + const cleaned = text.replace(/\n?[^<]+<\/omniModel>\n?/g, ""); + if (cleaned) controller.enqueue(encoder.encode(cleaned)); + } else { + controller.enqueue(encoder.encode(text)); + } } }, flush(controller) {