Detail Bug Report
https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_f7ff0127-e1c9-41ff-b796-4b676c017699
Introduced in #3 by @WilliamAGH on Jan 24, 2026
Summary
- Context:
sse.ts parses Server-Sent Events from backend streams. When a stream ends with an incomplete data line (no trailing newline), the parser commits the raw line to the event buffer without stripping the data: prefix.
- Bug: Incomplete final data lines pass
data: {...} to processEvent instead of {...}, causing tryParseJson to return null and validateWithSchema to fail. The user sees "Received an invalid SSE event from the server" instead of the actual content.
- Actual vs. expected: The parser should strip the
data: prefix from incomplete final lines just as it does for complete lines (lines 415-424), so the content can be parsed and delivered.
- Impact: The user sees "Received an invalid SSE event from the server" instead of the actual content.
Code with Bug
// `frontend/src/lib/services/sse.ts` — stream-end handling
if (streamEnded) {
streamCompletedNormally = true;
const remainingDecodedText = decoder.decode();
if (remainingDecodedText) {
unprocessedText += remainingDecodedText;
}
// Commit any remaining buffered line before flushing event data
if (unprocessedText.length > 0) {
sseEventBuffer = sseEventBuffer
? `${sseEventBuffer}\n${unprocessedText}`
: unprocessedText; // <-- BUG 🔴 keeps "data:" prefix for final incomplete line
hasBufferedSseEvent = true;
unprocessedText = "";
}
flushSseEvent();
break;
}
// `frontend/src/lib/services/sse.ts` — normal line processing
if (receivedLine.startsWith("data:")) {
const sseEventText = receivedLine.startsWith("data: ")
? receivedLine.slice(6)
: receivedLine.slice(5); // Prefix stripped correctly
if (sseEventText === "[DONE]") {
continue;
}
if (hasBufferedSseEvent) {
sseEventBuffer += "\n";
}
sseEventBuffer += sseEventText; // Added without prefix
hasBufferedSseEvent = true;
}
Explanation
When the stream ends, the code appends the remaining buffered text (unprocessedText) directly into sseEventBuffer and flushes. If the last line is an incomplete SSE data: line (missing a trailing newline), this path does not strip the data: prefix. As a result, processEvent receives a payload like data: {"text":"hello"} instead of valid JSON, so tryParseJson returns null, schema validation fails, and the client throws Received an invalid SSE event from the server.
Codebase Inconsistency
Normal per-line parsing strips data:/data: before buffering event text, but the stream-end “commit remaining buffered line” path does not. Both paths are performing the same logical operation (buffering event data) but produce different formats.
Failing Test
// frontend/src/lib/services/sse.incomplete.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { streamSseGet } from "./sse"; // PUBLIC API - exported at line 287
describe("SSE incomplete final line bug", () => {
it("parses incomplete final data lines correctly", async () => {
const encoder = new TextEncoder();
// Input: event: text\ndata: {"text":"hello"} (no trailing newline)
const incompleteSseBytes = encoder.encode('event: text\ndata: {"text":"hello"}');
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(incompleteSseBytes);
controller.close(); // Stream ends normally (done: true) with incomplete event
},
}),
{ status: 200, statusText: "OK" }
)
));
const onText = vi.fn();
const onError = vi.fn();
await streamSseGet("/api/test/stream", { onText, onError }, "test");
// BUG: onText is never called because data: prefix wasn't stripped
expect(onText).toHaveBeenCalledWith("hello");
});
});
Test output:
stderr | sse.incomplete.test.ts > SSE incomplete final line bug > parses incomplete final data lines correctly
[Zod] validateWithSchema [test:text] validation failed
Issues:
- (root): Invalid input: expected object, received null (expected: object)
Payload keys:
[Zod] validateWithSchema [test:text] - full details: {
prettifiedError: '✖ Invalid input: expected object, received null',
issues: [
{
expected: 'object',
code: 'invalid_type',
path: [],
message: 'Invalid input: expected object, received null'
}
],
rawInput: null
}
FAIL sse.incomplete.test.ts > SSE incomplete final line bug > parses incomplete final data lines correctly
Error: Received an invalid SSE event from the server
❯ throwInvalidSseEvent src/lib/services/sse.ts:116:9
❯ processEvent src/lib/services/sse.ts:221:7
❯ flushSseEvent src/lib/services/sse.ts:357:5
❯ consumeSseStream src/lib/services/sse.ts:386:9
❯ streamSseGet src/lib/services/sse.ts:305:3
Recommended Fix
// `frontend/src/lib/services/sse.ts` (proposed fix)
if (unprocessedText.length > 0) {
let sseEventText = unprocessedText;
// Strip "data:" prefix if present (match normal line processing at lines 415-424)
if (unprocessedText.startsWith("data: ")) {
sseEventText = unprocessedText.slice(6);
} else if (unprocessedText.startsWith("data:")) {
sseEventText = unprocessedText.slice(5);
}
// Skip [DONE] token in incomplete final line
if (sseEventText === "[DONE]") {
unprocessedText = "";
} else {
sseEventBuffer = sseEventBuffer
? `${sseEventBuffer}\n${sseEventText}`
: sseEventText;
hasBufferedSseEvent = true;
unprocessedText = "";
}
}
flushSseEvent();
History
This bug was introduced in commit c5b1d8e. The commit added SSE event type parsing to route status, error, and text events to their respective handlers. The stream-end handling was updated to commit any remaining buffered line before flushing, but it failed to strip the data: prefix from incomplete lines — unlike the normal line processing which correctly strips the prefix. The bug slipped in because the stream-end case was a new addition that mirrored the multi-byte character buffering logic without accounting for SSE line format processing.
Detail Bug Report
https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_f7ff0127-e1c9-41ff-b796-4b676c017699
Introduced in #3 by @WilliamAGH on Jan 24, 2026
Summary
sse.tsparses Server-Sent Events from backend streams. When a stream ends with an incomplete data line (no trailing newline), the parser commits the raw line to the event buffer without stripping thedata:prefix.data: {...}toprocessEventinstead of{...}, causingtryParseJsonto return null andvalidateWithSchemato fail. The user sees "Received an invalid SSE event from the server" instead of the actual content.data:prefix from incomplete final lines just as it does for complete lines (lines 415-424), so the content can be parsed and delivered.Code with Bug
Explanation
When the stream ends, the code appends the remaining buffered text (
unprocessedText) directly intosseEventBufferand flushes. If the last line is an incomplete SSEdata:line (missing a trailing newline), this path does not strip thedata:prefix. As a result,processEventreceives a payload likedata: {"text":"hello"}instead of valid JSON, sotryParseJsonreturnsnull, schema validation fails, and the client throwsReceived an invalid SSE event from the server.Codebase Inconsistency
Normal per-line parsing strips
data:/data:before buffering event text, but the stream-end “commit remaining buffered line” path does not. Both paths are performing the same logical operation (buffering event data) but produce different formats.Failing Test
Test output:
Recommended Fix
History
This bug was introduced in commit c5b1d8e. The commit added SSE event type parsing to route status, error, and text events to their respective handlers. The stream-end handling was updated to commit any remaining buffered line before flushing, but it failed to strip the
data:prefix from incomplete lines — unlike the normal line processing which correctly strips the prefix. The bug slipped in because the stream-end case was a new addition that mirrored the multi-byte character buffering logic without accounting for SSE line format processing.