Skip to content

Add explicit SSE event names for local v3 streaming#1858

Open
monadoid wants to merge 4 commits intomainfrom
samfinton/local-typed-sse-events
Open

Add explicit SSE event names for local v3 streaming#1858
monadoid wants to merge 4 commits intomainfrom
samfinton/local-typed-sse-events

Conversation

@monadoid
Copy link
Contributor

@monadoid monadoid commented Mar 19, 2026

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:
Screenshot 2026-03-20 at 11 01 50 AM


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.

Written for commit 96cd037. Summary will update on new commits. Review in cubic

@changeset-bot
Copy link

changeset-bot bot commented Mar 19, 2026

🦋 Changeset detected

Latest commit: 96cd037

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@browserbasehq/stagehand Patch
@browserbasehq/stagehand-server-v3 Patch
@browserbasehq/browse-cli Patch
@browserbasehq/stagehand-evals Patch
@browserbasehq/stagehand-server-v4 Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Mar 19, 2026

✱ Stainless preview builds

This PR will update the stagehand SDKs with the following commit message.

feat: Add explicit SSE event names for local v3 streaming

Edit this comment to update it. It will appear in the SDK's changelogs.

🚧 stagehand-java studio

Your SDK build had a "fatal" conclusion, and no code was generated, but this did not represent a regression.

New diagnostics (1 fatal)
FormatterCommandError: Formatter command failed after code generation: `/home/tempuser-d00kdk/run/codegen-output/browserbase/stagehand-java/scripts/fast-format`
stagehand-typescript studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅build ✅lint ✅test ✅

npm install https://pkg.stainless.com/s/stagehand-typescript/88605d3558700ea18114e3a665ee2ff0d9c8f352/dist.tar.gz
stagehand-ruby studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅build ✅lint ✅test ✅

stagehand-csharp studio · code · diff

Your SDK build had at least one "warning" diagnostic, but this did not represent a regression.
generate ⚠️build ❗lint ❗test ✅

stagehand-kotlin studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅build ✅lint ✅test ✅

stagehand-python studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅build ✅lint ✅test ✅

pip install https://pkg.stainless.com/s/stagehand-python/6e7cb5992dbce8ad3509bbf5878e112c3af47fc8/stagehand-3.6.0-py3-none-any.whl
stagehand-go studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅build ✅lint ✅test ✅

go get github.com/stainless-sdks/stagehand-go@4ee0e22fae392b8964c4e429050e41c53284ee18
stagehand-openapi studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅

stagehand-php studio · code · diff

Your SDK build had at least one "note" diagnostic, but this did not represent a regression.
generate ✅lint ✅test ✅


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-03-19 22:47:13 UTC

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 19, 2026

Greptile Summary

This PR upgrades the local v3 SSE streaming protocol by emitting an explicit event: <status> line before every data: frame, and updates the Stainless streaming config to use event_type matchers instead of fragile data_starts_with string prefix matching. The documentation, OpenAPI spec, and an integration test are all updated consistently.

Key changes:

  • stream.ts: sendData now prefixes every SSE frame with event: <status>, making event demultiplexing on the client side reliable and standards-conformant.
  • stainless.yml: Replaces data_starts_with pattern matching (which required fragile JSON key-ordering) with clean event_type handlers. Worth noting: the old data_starts_with: error matcher was never actually matching error events (JSON starts with {, not error), so event_type: error is a real functional fix here.
  • stainless.yml: The finished event changes from handle: done to handle: yield — this removes the explicit stream-termination signal for Stainless-generated SDKs. Stream termination now relies solely on connection close (reply.raw.end()), which is correct given the server always closes the connection immediately after sending finished. However, the finished event payload will now be surfaced to SDK consumers instead of being silently consumed as a termination signal, which could be a breaking change for existing SDK users.
  • Integration test: correctly validates event: field presence and that SSE event names mirror the JSON status field.

Confidence Score: 3/5

  • Safe to merge if the behavioral change of surfacing finished events to Stainless SDK consumers is intentional and tested against generated clients.
  • The server-side changes are correct and well-tested. The main risk is in stainless.yml: changing finished from handle: done to handle: yield means generated SDK consumers will now receive the finished event as a yielded payload rather than having it silently terminate the stream. This is a potentially breaking change in the generated SDK's public event API that isn't verified by the integration test (which tests the raw HTTP wire format, not the generated SDK behavior).
  • Pay close attention to stainless.yml — specifically the removal of handle: done for the finished event type.

Important Files Changed

Filename Overview
packages/server-v3/src/lib/stream.ts Adds explicit SSE event: name to every emitted frame and introduces the StreamEventName / StreamPayloadType helper types. The sendData signature change is consistent across all call sites. Logic is correct and backward-compatible since the JSON data: payload is unchanged.
stainless.yml Replaces fragile data_starts_with matchers with event_type handlers. Key concern: finished changed from handle: done to handle: yield, which may surface the finished event to generated SDK consumers for the first time (potential breaking change in the SDK contract).
packages/server-v3/test/integration/v3/act.test.ts New test correctly uses readSSEStream (which captures the event field) and validates that starting and finished frames carry matching event: names and payload statuses. Consistent with existing test patterns.
packages/server-v3/test/integration/utils.ts Comment updated to document the new event: <status> + data: <JSON> wire format. The readSSEStream parser already handles event: lines, so no code changes were needed.
packages/core/lib/v3/types/public/api.ts Documentation and meta.description updated to describe the new event: <status>\ndata: <JSON> wire format. No logic changes.
packages/server-v3/openapi.v3.yaml OpenAPI schema description regenerated to match the new SSE wire format. Purely documentation update, no schema structure changes.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Server as stream.ts
    participant SDK as Stainless SDK

    Client->>Server: POST /v1/sessions/:id/act (streamResponse: true)
    Server-->>Client: HTTP 200 text/event-stream

    Server-->>Client: event: starting\ndata: {"data":{"status":"starting"},"type":"system","id":"..."}
    SDK-->>SDK: event_type=starting → handle: yield (emit to consumer)

    Server-->>Client: event: connected\ndata: {"data":{"status":"connected"},"type":"system","id":"..."}
    SDK-->>SDK: event_type=connected → handle: yield (emit to consumer)

    loop log messages
        Server-->>Client: event: running\ndata: {"data":{"status":"running","message":"..."},"type":"log","id":"..."}
        SDK-->>SDK: event_type=running → handle: yield (emit to consumer)
    end

    alt Success
        Server-->>Client: event: finished\ndata: {"data":{"status":"finished","result":...},"type":"system","id":"..."}
        Note over SDK: Previously handle: done (not yielded)<br/>Now handle: yield (surfaced to consumer)
        SDK-->>SDK: event_type=finished → handle: yield (emit to consumer)
    else Error
        Server-->>Client: event: error\ndata: {"data":{"status":"error","error":"..."},"type":"system","id":"..."}
        SDK-->>SDK: event_type=error → handle: error (throw)
    end

    Server-->>Client: [connection close — reply.raw.end()]
Loading

Last reviewed commit: "Add typed SSE event ..."

Comment on lines +204 to 207
- event_type: error
handle: error
- event_type: null
- event_type: [starting, connected, running, finished]
handle: yield
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 finished changed from handle: done to handle: yield — potential breaking change for generated SDK consumers

In the old config, the finished event had handle: done, which in Stainless streaming means the SDK treats that event as a stream-termination signal and does not yield it to consumer code. With handle: yield, the finished event is now emitted to SDK consumers like any other event.

This is a semantic change in the contract of the generated SDK: any caller iterating over the stream would now receive a finished event in their loop, whereas before the stream simply ended. Unless generated SDK consumers already expect and handle a finished event in their event handler, this is a breaking change.

Since the server still calls reply.raw.end() immediately after sending finished, the stream termination itself is unaffected — but whether the finished payload is surfaced to SDK consumers changes. This should be explicitly tested/verified against the generated SDK client, or a comment added to the config explaining the deliberate shift.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah dont we still need handle: done somewhere? will stainless properly know when the stream is complete without some branch reporting done?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, you don't need handle: done. The stream completes when the HTTP connection closes, which happens right after the finished event is sent.

Looking at packages/server-v3/src/lib/stream.ts:208-214:

sendData("finished", "system", {
  status: "finished",
  result: result?.result,
  actionId,
});

if (shouldStreamResponse) {
  reply.raw.end();  // ← HTTP stream closes here
  return reply;
}

The old handle: done was preventing the finished event from being yielded to SDK consumers — which was exactly the bug this PR fixes ("The stainless sdks are dropping the final finished SSE event instead of yielding it").

With the new config:

  1. finished event is sent with event: finished\ndata: {...}
  2. Event is yielded to SDK consumers (they can now see it)
  3. HTTP stream immediately closes via reply.raw.end()
  4. Stainless detects stream completion from the closed connection

The handle: done was just an optimization to say "don't yield this event" — but Stainless doesn't need it to know the stream is complete. The closed HTTP connection is the real termination signal.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant Client as Client (SDK / Stainless)
    participant Server as Server-v3 (stream.ts)
    participant Runner as Action Runner (Core)

    Note over Client,Server: Request with streamResponse: true

    Client->>Server: POST /v1/sessions/:id/act
    Server->>Server: Initialize createStreamingResponse()

    rect rgb(240, 240, 240)
        Note right of Server: SSE Stream Start
        Server->>Client: NEW: event: starting \n data: { "status": "starting", ... }
    end

    Server->>Runner: execute(action)

    loop Action Execution
        Runner-->>Server: Log update
        Server->>Client: NEW: event: running \n data: { "type": "log", "data": { "message": "..." } }
    end

    Server->>Client: NEW: event: connected \n data: { "status": "connected" }

    alt Success Path
        Runner-->>Server: Return Result
        Server->>Client: NEW: event: finished \n data: { "status": "finished", "result": "..." }
    else Error Path
        Runner-->>Server: Throw Exception
        Server->>Client: NEW: event: error \n data: { "status": "error", "error": "message" }
    end

    Note over Client,Server: CHANGED: Client SDK now routes by "event:" name <br/> instead of parsing JSON prefix in "data:"

    Server->>Client: reply.raw.end() (Close SSE)
Loading

@monadoid monadoid changed the base branch from samfinton/revert-finished-sse-yield to main March 19, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants