-
Notifications
You must be signed in to change notification settings - Fork 654
fix: finish passthrough SSE after terminal events #1055
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -517,11 +517,14 @@ export function relaySseWithHeartbeat( | |
| if (!body) return null; | ||
| const reader = body.getReader(); | ||
| const decoder = new TextDecoder(); | ||
| const heartbeat = new TextEncoder().encode(": opencodex keepalive\n\n"); | ||
| const encoder = new TextEncoder(); | ||
| const heartbeat = encoder.encode(": opencodex keepalive\n\n"); | ||
| const doneSentinel = encoder.encode("data: [DONE]\n\n"); | ||
| let timer: ReturnType<typeof setInterval> | undefined; | ||
| let closed = false; | ||
| let clientCancelled = false; | ||
| let terminalReported = false; | ||
| let doneSeen = false; | ||
| let buffer = ""; | ||
|
|
||
| const reportTerminal = (status: ResponsesTerminalStatus) => { | ||
|
|
@@ -532,6 +535,10 @@ export function relaySseWithHeartbeat( | |
|
|
||
| const inspectPayload = (payload: string | null) => { | ||
| if (!payload) return; | ||
| if (payload === "[DONE]") { | ||
| doneSeen = true; | ||
| return; | ||
| } | ||
| const status = terminalStatusFromSsePayload(payload); | ||
| if (status) reportTerminal(status); | ||
| }; | ||
|
|
@@ -571,13 +578,24 @@ export function relaySseWithHeartbeat( | |
| if (done) { | ||
| buffer += decoder.decode(); | ||
| if (buffer.trim()) inspectPayload(sseDataPayload(buffer)); | ||
| if (terminalReported && !doneSeen) { | ||
| controller.enqueue(doneSentinel); | ||
| doneSeen = true; | ||
| } | ||
| if (!terminalReported && !clientCancelled) reportTerminal("incomplete"); | ||
| cleanup(); | ||
| controller.close(); | ||
| return; | ||
| } | ||
| inspectChunk(value); | ||
| controller.enqueue(value); | ||
| if (terminalReported && !doneSeen) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an upstream batches a Responses terminal event and Useful? React with 👍 / 👎. |
||
| controller.enqueue(doneSentinel); | ||
| doneSeen = true; | ||
| cleanup(); | ||
| controller.close(); | ||
| reader.cancel("Responses terminal event received").catch(() => {}); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a gateway writes Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
590
to
+598
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Do not forward bytes after the terminal SSE block. Line 590 can leave an incomplete later block in Return the terminal block boundary from the inspector and enqueue only bytes through that boundary. Discard post-terminal bytes before emitting the synthetic sentinel. Add a split-chunk regression in 🤖 Prompt for AI Agents |
||
| } catch (err) { | ||
| if (!clientCancelled) reportTerminal("incomplete"); | ||
| cleanup(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -221,6 +221,21 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { | |
| expect(terminals).toEqual(["completed"]); | ||
| }); | ||
|
|
||
| test("SSE passthrough appends DONE after a terminal payload without upstream DONE", async () => { | ||
| const enc = new TextEncoder(); | ||
| const ac = new AbortController(); | ||
| const terminals: string[] = []; | ||
| const relayed = relaySseWithHeartbeat(streamFromChunks([ | ||
| enc.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"r1","status":"completed"}}\n\n'), | ||
| ]), ac, 15_000, status => terminals.push(status))!; | ||
|
Comment on lines
+228
to
+230
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This regression constructs AGENTS.md reference: AGENTS.md:L228-L230 Useful? React with 👍 / 👎. |
||
|
|
||
| const raw = await readAll(relayed); | ||
|
|
||
| expect(raw).toContain('event: response.completed\ndata: {"type":"response.completed","response":{"id":"r1","status":"completed"}}\n\n'); | ||
| expect(raw).toEndWith("data: [DONE]\n\n"); | ||
| expect(terminals).toEqual(["completed"]); | ||
| }); | ||
|
|
||
| test("SSE passthrough treats DONE without a terminal as incomplete", async () => { | ||
| const enc = new TextEncoder(); | ||
| const ac = new AbortController(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the upstream closes after a terminal frame that lacks the final blank SSE delimiter, the relay already forwarded those raw bytes during the previous pull and this EOF path then appends
data: [DONE]\n\ndirectly after them. That turns the terminal payload and[DONE]into one malformed SSE block (data: <json>\ndata: [DONE]) instead of dispatching the terminal event followed by a separate DONE sentinel; insert a delimiter before the synthetic sentinel or use the frame-aware terminal boundary for this path.Useful? React with 👍 / 👎.