Skip to content

Commit 8bf5db8

Browse files
authored
Add explicit SSE event names for local v3 streaming (browserbase#1858)
## Why The stainless sdks are dropping the final finished SSE event instead of yielding it. This is due to the fact that we were not using `event` fields (basically setting event types) as per the SSE spec. The fix is to emit explicit SSE `event:` names and match them in Stainless. But on the hosted API we cannot switch that on for everyone at once, because older clients still expect the old `data:`-only SSE framing. Thus, we will need to have branching logic in our hosted server: 1. Legacy (old stainless sdks, stagehand-js): continue to not return `event` field. 2. New Stainless SDKs on `>= 3.13.0`: use typed SSE framing with `event:` + `data:`. Once this and it's core counterpart PR are merged, then we will release another version of all stainless sdks - `3.13`, which will be the first typed-SSE release. ## What Changed - emit explicit SSE `event:` names from the local v3 streaming helper while keeping the JSON `data:` payload unchanged - switch Stainless streaming matching to explicit `event_type` handlers - update the documented stream shape, regenerated v3 OpenAPI, and a focused integration assertion ## Testing - `pnpm --dir /tmp/stagehand-local-sse.lSk5Av --filter @browserbasehq/stagehand-server-v3 run gen:openapi` - `pnpm --dir /tmp/stagehand-local-sse.lSk5Av exec prettier --check stainless.yml` - `pnpm --dir /tmp/stagehand-local-sse.lSk5Av --filter @browserbasehq/stagehand lint` - `pnpm --dir /tmp/stagehand-local-sse.lSk5Av --filter @browserbasehq/stagehand-server-v3 lint` A test run with the three different client types against the updated server: <img width="765" height="230" alt="Screenshot 2026-03-20 at 11 01 50 AM" src="https://github.com/user-attachments/assets/af97c8ec-d7f9-4ae6-95a0-16a3c2906934" /> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Emit explicit SSE event names for v3 streaming (`starting`, `connected`, `running`, `finished`, `error`) while keeping the JSON `data:` payload unchanged. Updates the streaming helper, Stainless config, OpenAPI docs, and tests to use and verify typed events. - New Features - Server now sends `event: <status>` with `data: { data, type, id }`. - Switched `stainless.yml` streaming matching to `event_type` (yields on starting/connected/running/finished; handles `error`). - Added integration test asserting event names match payload status; updated OpenAPI description and core type docs. - Dependencies - Added changeset to publish patch updates for `@browserbasehq/stagehand` and `@browserbasehq/stagehand-server-v3`. <sup>Written for commit 96cd037. Summary will update on new commits. <a href="https://cubic.dev/pr/browserbase/stagehand/pull/1858">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. -->
1 parent 4f5de70 commit 8bf5db8

7 files changed

Lines changed: 80 additions & 21 deletions

File tree

.changeset/typed-sse-events.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
"@browserbasehq/stagehand-server-v3": patch
4+
---
5+
6+
Add explicit SSE event names for local v3 streaming and update the generated SDK contract to match.

packages/core/lib/v3/types/public/api.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -931,11 +931,9 @@ export const StreamEventLogDataSchema = z
931931
/**
932932
* SSE stream event sent during streaming responses.
933933
*
934-
* IMPORTANT: Key ordering matters for Stainless SDK generation.
935-
* The `data` field MUST be serialized first, with `status` as the first key within it.
936-
* This allows Stainless to use `data_starts_with: '{"data":{"status":"finished"'` for event handling.
937-
*
938-
* Expected serialization order: {"data":{"status":...},"type":...,"id":...}
934+
* The SSE wire format includes an `event:` line that mirrors the stream status
935+
* (`starting`, `connected`, `running`, `finished`, or `error`) followed by a
936+
* JSON `data:` line containing the typed payload below.
939937
*/
940938
export const StreamEventSchema = z
941939
.object({
@@ -949,7 +947,7 @@ export const StreamEventSchema = z
949947
.meta({
950948
id: "StreamEvent",
951949
description:
952-
"Server-Sent Event emitted during streaming responses. Events are sent as `data: <JSON>\\n\\n`. Key order: data (with status first), type, id.",
950+
"Server-Sent Event emitted during streaming responses. Events are sent as `event: <status>\\ndata: <JSON>\\n\\n`, where the JSON payload has the shape `{ data, type, id }`.",
953951
});
954952

955953
// =============================================================================

packages/server-v3/openapi.v3.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,8 +1910,8 @@ components:
19101910
additionalProperties: false
19111911
StreamEvent:
19121912
description: "Server-Sent Event emitted during streaming responses. Events are
1913-
sent as `data: <JSON>\\n\\n`. Key order: data (with status first), type,
1914-
id."
1913+
sent as `event: <status>\\ndata: <JSON>\\n\\n`, where the JSON payload
1914+
has the shape `{ data, type, id }`."
19151915
type: object
19161916
properties:
19171917
data:

packages/server-v3/src/lib/stream.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ interface StreamingResponseOptions<TV3> {
2727
operation?: string;
2828
}
2929

30+
type StreamEventName =
31+
| "starting"
32+
| "connected"
33+
| "running"
34+
| "finished"
35+
| "error";
36+
type StreamPayloadType = "system" | "log";
37+
3038
export async function createStreamingResponse<TV3>({
3139
sessionId,
3240
request,
@@ -104,23 +112,29 @@ export async function createStreamingResponse<TV3>({
104112
}
105113
}
106114

107-
const sendData = (type: string, data: object) => {
115+
const sendData = (
116+
event: StreamEventName,
117+
type: StreamPayloadType,
118+
data: object,
119+
) => {
108120
if (!shouldStreamResponse) {
109121
return;
110122
}
111123

112-
reply.raw.write(`data: ${JSON.stringify({ data, type, id: v4() })}\n\n`);
124+
reply.raw.write(
125+
`event: ${event}\ndata: ${JSON.stringify({ data, type, id: v4() })}\n\n`,
126+
);
113127
};
114128

115129
const actionId = v4();
116130

117-
sendData("system", { status: "starting" });
131+
sendData("starting", "system", { status: "starting" });
118132

119133
const requestContext: RequestContext = {
120134
modelApiKey,
121135
logger: shouldStreamResponse
122136
? (message) => {
123-
sendData("log", { status: "running", message });
137+
sendData("running", "log", { status: "running", message });
124138
}
125139
: undefined,
126140
};
@@ -134,7 +148,7 @@ export async function createStreamingResponse<TV3>({
134148
} catch (err) {
135149
const loadError = err instanceof Error ? err : new Error(String(err));
136150

137-
sendData("system", { status: "error", error: loadError.message });
151+
sendData("error", "system", { status: "error", error: loadError.message });
138152

139153
if (shouldStreamResponse) {
140154
reply.raw.end();
@@ -150,7 +164,7 @@ export async function createStreamingResponse<TV3>({
150164
);
151165
}
152166

153-
sendData("system", { status: "connected" });
167+
sendData("connected", "system", { status: "connected" });
154168

155169
let result: Awaited<ReturnType<typeof handler>> | null = null;
156170
let handlerError: Error | null = null;
@@ -180,7 +194,7 @@ export async function createStreamingResponse<TV3>({
180194
? handlerError.getClientMessage()
181195
: `${operation ?? "operation"} failed`;
182196

183-
sendData("system", { status: "error", error: clientMessage });
197+
sendData("error", "system", { status: "error", error: clientMessage });
184198

185199
if (shouldStreamResponse) {
186200
reply.raw.end();
@@ -194,7 +208,7 @@ export async function createStreamingResponse<TV3>({
194208
return error(reply, clientMessage, statusCode);
195209
}
196210

197-
sendData("system", {
211+
sendData("finished", "system", {
198212
status: "finished",
199213
result: result?.result,
200214
actionId,

packages/server-v3/test/integration/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ export async function readSSEStream(response: Response): Promise<SSEEvent[]> {
359359
// =============================================================================
360360

361361
// Actual SSE event format from backend (see stream.ts):
362-
// { data: { status: "starting" | "connected" | "finished", result?: ... }, type: "system" | "log", id: "<uuid>" }
362+
// event: <status>
363+
// data: { data: { status: "starting" | "connected" | "finished", result?: ... }, type: "system" | "log", id: "<uuid>" }
363364
export interface TypedSSEEvent<TResult = unknown> {
364365
data: {
365366
status: string;

packages/server-v3/test/integration/v3/act.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
navigateSession,
2020
OPENAI_API_KEY,
2121
readTypedSSEStreamWithContext,
22+
readSSEStream,
2223
requireEnv,
2324
} from "../utils.js";
2425

@@ -401,6 +402,47 @@ describe("POST /v1/sessions/:id/act with SSE streaming (V3)", () => {
401402
);
402403
});
403404

405+
it("should include explicit SSE event names that match streamed statuses", async () => {
406+
const url = getBaseUrl();
407+
408+
const response = await fetch(`${url}/v1/sessions/${sessionId}/act`, {
409+
method: "POST",
410+
headers: {
411+
...getHeaders("3.0.0"),
412+
},
413+
body: JSON.stringify({
414+
input: "click the Learn more link",
415+
streamResponse: true,
416+
}),
417+
});
418+
419+
const events = await readSSEStream(response);
420+
421+
assert.ok(events.length >= 2, "Should emit multiple SSE frames");
422+
assert.ok(
423+
events.every((event) => typeof event.event === "string" && event.event),
424+
"Every streamed frame should include an SSE event name",
425+
);
426+
427+
const startingEvent = events.find((event) => event.event === "starting");
428+
assert.ok(startingEvent, "Should include a starting SSE event");
429+
assert.equal(
430+
(startingEvent.parsed as { data?: { status?: string } } | undefined)?.data
431+
?.status,
432+
"starting",
433+
"Starting SSE event should match the payload status",
434+
);
435+
436+
const finishedEvent = events.find((event) => event.event === "finished");
437+
assert.ok(finishedEvent, "Should include a finished SSE event");
438+
assert.equal(
439+
(finishedEvent.parsed as { data?: { status?: string } } | undefined)?.data
440+
?.status,
441+
"finished",
442+
"Finished SSE event should match the payload status",
443+
);
444+
});
445+
404446
it("should stream SSE events with inline model config", async () => {
405447
const url = getBaseUrl();
406448
const openaiApiKey = requireEnv("OPENAI_API_KEY", OPENAI_API_KEY);

stainless.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,9 @@ resources:
201201

202202
streaming:
203203
on_event:
204-
- data_starts_with: '{"data":{"status":"finished"'
205-
handle: done
206-
- data_starts_with: error
204+
- event_type: error
207205
handle: error
208-
- event_type: null
206+
- event_type: [starting, connected, running, finished]
209207
handle: yield
210208

211209
settings:

0 commit comments

Comments
 (0)