Skip to content

fix(go,cli): render streamed events from the frame the server sent - #116

Open
2ynn wants to merge 9 commits into
mainfrom
fix/cli-stream-partial-step
Open

2ynn wants to merge 9 commits into
mainfrom
fix/cli-stream-partial-step

Conversation

@2ynn

@2ynn 2ynn commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Why

The generated CLI printed each streamed event by re-encoding the decoded Go model. When a server omits a field the schema marks required, decoding leaves it at its zero value, and re-encoding then goes wrong:

  1. An absent required union has no member set, so its MarshalJSON fails (could not marshal union type …: all fields are null) and the whole stream aborts with exit 1, in every output mode. This happens with APIs that announce an object before all of its fields are known, for example a tool-call event streamed before the tool's result exists.
  2. An absent or null required scalar is printed as its zero value (""), a value the server never sent.
  3. Fields the event schema does not declare are dropped.

What changed

Go SDK reader (types/stream/stream.go): EventStream keeps the event it hands to the unmarshaller and exposes it as RawValue(). That's a JSON object with the frame's SSE event, data and retry, plus the last event id seen on the stream. data is the JSON the server sent, or a JSON string for text data. This adds a public method to every generated Go SDK.

CLI streaming (internal/output/streaming.go): when the stream has RawValue, StreamResult rebuilds each event from the raw frame before any renderer sees it, and hands renderers a JSON-marshalling value. Text projection, json, yaml, toon, pretty and --jq all go through it.

  • Tables: table output renders the decoded model whenever it encodes, so it keeps the model's field order, sections and (empty) markers. Only events the model can't encode are rendered from the wire values.
  • Which keys are kept: the frame keys the event's envelope declares, read from its JSON tags (unexported const fields such as event included). When the stream element is a union of envelopes (a oneOf event-stream schema), the envelope is the member that decoded, through every nested union layer. If no member decoded, the whole frame is kept, as the decoded value does.
  • Values: every value the server sent prints as the decoded model renders it wherever the model has one. That covers numbers, dates, formatted and union raw values, with keys in the same order as before.
  • Values the model lacks: nulls, undeclared fields, and events the model can't encode keep the server's value. Numbers among them are rewritten to their shortest form when float64 holds the same value (1.0 → 1).
  • Error events: in-band error-event detection still uses the decoded event. If the decoded error can't be re-encoded, its body comes from the frame's data, so the stream still fails.
  • JSONL streams: they have no raw accessor and keep the previous path.

Expected output changes: for every event the model can encode, output is byte-identical to the previous rendering in all formats and --jq. Outside table, the exceptions are:

  • null values the server sent stay null instead of becoming the field's zero value;
  • fields absent from the frame stay absent, including fields the model would fill with a default or zero value (e.g. a *string with default: "");
  • fields the schema doesn't declare are kept.

Events that previously aborted the stream now print as sent.

Review SDKs: zSDKs/sdk-go (types/stream/stream.go, .speakeasy/gen.lock) and zSDKs/sdk-cli (internal/output/streaming.go, internal/output/output.go, internal/output/streaming_test.go, internal/sdk/types/stream/stream.go, .speakeasy/gen.lock).

Testing

  • TARGET=primary make test-cli: pass.
    • TestEventStreamAbsentRequiredUnionStreamsThrough is new: a tool variant of the discriminated test stream with a required outcome union that's absent on the wire, covering json, yaml, toon, pretty, table and --jq.
    • Also new:
      • TestEventStreamKeepsNullAndUndeclaredFields: null and undeclared fields are kept, and null or absent required scalars print as sent;
      • TestEventStreamErrorEventWithoutRequiredUnionFails: an error event (new tool_error variant) whose required union is absent still fails the stream, with its body taken from the frame;
      • TestEventStreamProjectionStreamsThroughAbsentRequiredUnion: text projection (new eventstreams discriminated-projected-events command) streams past an event the model can't encode;
      • TestEventStreamEquivalentNumberLexemesPrintCanonically: undeclared 1.0/1e3/1e-05 in json, yaml and toon;
      • TestEventStreamDeclaredValuesPrintAsTheModelRendersThem: -0 in an integer field and an over-precise double, in json and toon.
    • TestEventStreamRichEvents now checks event content (union-of-envelopes stream), not just the count.
    • Unit tests (internal/output/streaming_test.go): TestWireStreamItemUnwrapsNestedEnvelopeUnions (union of unions of envelopes, plus an inner union with no member) and TestWireStreamItemTableKeepsModelLayout (exact table output for object arrays, empty arrays and an event the model can't encode).
  • TARGET=primary make test-go: pass. New test: TestEventStreamRawValue.
  • New test IDs, recorded by the CLI and skipped by the other targets: event-stream-absent-required-union, event-stream-null-and-undeclared-fields and event-stream-equivalent-number-lexemes (recorded by both number-lexeme tests). TestEventStreamRawValue covers a Go-only accessor and records no ID.
  • Retired event-stream-truncated-before-sentinel-is-connection-error: it covered the same scenario as event-stream-truncated-before-sentinel, which the CLI test now records.
  • TARGET=review make build-go and TARGET=review make build-cli.
  • A CLI generated from a spec with this shape gives byte-identical output in every format for streams it could already print, including numbers such as 1.0, 1e3, 1e-5, -0 and integers beyond 2^53.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.

2ynn added 2 commits October 16, 2026 13:24
## Why

The CLI printed each streamed event by re-encoding the decoded Go model.
When the server omits a field the schema marks required, decoding leaves
it at its zero value, and re-encoding then goes wrong:

1. An absent required union has no member set, so its MarshalJSON fails
   ("could not marshal union type ...: all fields are null") and the
   whole stream aborts with exit 1, in every output mode. APIs do this
   when they announce an object before all of its fields are known, for
   example a tool-call event streamed before the tool's result exists.
2. An absent or null required scalar is printed as its zero value
   (`""`), a value the server never sent.
3. Fields the event schema does not declare are dropped.

## What changed

**Go SDK reader** (`types/stream/stream.go`): `EventStream` keeps the
event it hands to the unmarshaller and exposes it as `RawValue()`: a JSON
object with the SSE fields of the frame, where data is the JSON the
server sent, or a JSON string for text data.

**CLI streaming** (`internal/output/streaming.go`): when the stream has
`RawValue`, `StreamResult` rebuilds each event from it before any renderer
sees it, keeping only the envelope fields the event type declares, and
hands renderers a JSON-marshalling value. Every renderer (text
projection, json, yaml, toon, pretty, table, --jq) therefore shows the
values the server sent, with keys in the same order as before. In-band
error-event detection still uses the decoded event. JSONL streams have no
raw accessor and keep the previous path.

## Tests

- CLI primary: `TestEventStreamAbsentRequiredUnionStreamsThrough` (new
  `tool` variant of the discriminated stream with a required `outcome`
  union, absent on the wire; json, yaml, toon, pretty, table, --jq) and
  `TestEventStreamRendersWireValues` (null kept, undeclared field kept).
- Go primary: `TestEventStreamRawValue`.
- Review SDKs regenerated (`zSDKs/sdk-go`, `zSDKs/sdk-cli`).
- Numbers: rebuilding events from the raw frame kept the server's number
  lexemes, so `1.0`, `1e3` and `1e-05` printed as such in json, pretty
  and table, and as quoted strings in toon. `wireStreamItem` now rewrites
  a lexeme to its shortest form whenever float64 holds the same value, so
  these print as the decoded model printed them (`1`, `1000`, `0.00001`).
  Lexemes float64 would change (integers beyond 2^53, long decimals) are
  kept as sent instead of being rounded.
- Error events: `streamErrorEvent` falls back to the frame's `data` when
  the decoded error cannot be re-encoded, so the event still fails the
  stream instead of printing as data.
- `RawValue` doc: `id` is the last event ID seen on the stream, which
  carries over to frames without one, as SSE specifies.
  `TestEventStreamRawValue` now expects that.

Output for events the model describes is byte-identical to the previous
typed rendering in json, yaml, toon, pretty, table, text and `--jq .`,
except integers beyond float64 precision in free-form values, which are
now printed exactly.

Tests: `TestEventStreamEquivalentNumberLexemesPrintCanonically` (json,
yaml, toon). `TARGET=primary make test-cli` and `TARGET=primary make
test-go` pass.
@github-actions github-actions Bot added the go Trigger (90) snapshot tests for go label Oct 16, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 12 files

Shadow auto-approve: would not auto-approve because issues were found.
Click Fix with cubic on a comment, or reply @cubic-dev-ai fix this, and cubic writes the fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread zSDKs/sdk-cli/internal/output/streaming.go
Comment thread templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl Outdated
Comment thread templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl
…events

- Union stream elements: when the event type is a union of envelopes (an
  event-stream schema that is a oneOf), `wireStreamItem` looked the
  union's Go fields up in the frame, found none and printed every event as
  `{}`. It now keeps the envelope fields of the member that decoded, and
  the whole frame when no member did, as the decoded value does. Envelope
  keys come from JSON tags, including unexported const fields such as
  `event`.
- Values: each value the server sent now prints as the decoded model
  renders it wherever the model has one (`typedLeaves`), so numbers (`-0`
  in an integer field, a double with more digits than float64 holds,
  integers beyond 2^53 in free-form values), dates and raw union values
  print exactly as before. Only nulls and values the model lacks
  (undeclared fields, events it cannot encode) come from the frame, with
  numbers in their shortest form. This replaces the previous commit's
  claim that integers beyond float64 precision print exactly: they print
  as the model renders them.

For every event the model can encode, output is byte-identical to the
typed rendering before 897fe27 in json, yaml, toon, pretty, table,
text and `--jq`, except: nulls the server sent stay null, fields absent
from the frame stay absent (including fields the model would fill with a
default or zero value), and undeclared fields are kept.

Tests: `TestEventStreamRichEvents` now checks event content;
`TestEventStreamDeclaredValuesPrintAsTheModelRendersThem` (json, toon;
new `weight` double on the test delta event). `TARGET=primary make
test-cli` and `TARGET=primary make test-go` pass.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 5 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes streamed-event output in generated Go CLIs by rendering from the raw SSE frame, preserving null/absent/undeclared fields and avoiding aborts on missing required unions; adds EventStream.RawValue and tests; dismissed findings match the diff.
Tip: Get a Slack DM when cubic finishes reviewing your PRs. Connect Slack

View guided diff | Turn on auto-fix | Re-trigger cubic

@speakeasy-generator-bot

speakeasy-generator-bot Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Generator Snapshot Testing

Selector(s) Result Summary
snapshot-go ✅ Snapshot run completed

Snapshot results: https://github.com/speakeasy-api/openapi-generation-snapshots/issues/121#issuecomment-6226589066

Snapshot results are summarized above. Additional run context is linked for maintainers with access.

2ynn added 2 commits October 16, 2026 14:01
The CLI's truncated-before-sentinel test exercises the same scenario as
the Go one: the server drops the stream before the declared sentinel.
Record `event-stream-truncated-before-sentinel` from the CLI suite and
retire the CLI-only `event-stream-truncated-before-sentinel-is-connection-error`
ID, so the coverage report tracks one scenario across targets.
Register the streamed-event scenarios this PR covers so every target's
coverage report tracks them: an event that omits a required union, an
event with null and undeclared fields, and number lexemes that differ
from their canonical form. The CLI records all three; other targets
skip them for now. TestEventStreamRawValue tests a Go-only accessor and
stays unrecorded.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 13 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes generated Go CLI streaming to render events from the raw SSE frame, preserving null/absent/undeclared fields and avoiding aborts on missing required unions; adds RawValue plus tests and feature plumbing. Dismissed findings match the diff.

View guided diff | Turn on auto-fix | Re-trigger cubic

Keep only the constraints the code cannot express, drop narrative doc
comments on self-explanatory helpers and tests, and rename
TestEventStreamRendersWireValues to TestEventStreamKeepsNullAndUndeclaredFields.
Review SDKs regenerated.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 8 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes generated Go CLI streaming to render events from the raw SSE frame, preserving null/absent/undeclared payload fields and avoiding aborts on missing required unions; adds RawValue with tests. Dismissed findings match the diff.

View guided diff | Turn on auto-fix | Re-trigger cubic

- Table output renders the decoded model whenever it encodes, so streamed
  tables keep the model's field order, sections and empty-array markers;
  only events the model cannot encode fall back to the wire values.
- envelopeKeys unwraps every selected union layer, so a union of unions
  of envelopes no longer renders `{}`.
- wireStreamItem reuses normalizeForOutput for the typed side and
  decodes the raw frame once.
- New coverage: nested envelope unions and table layout (unit), an error
  event whose required union is absent, null and absent required
  scalars, and text projection over an event the model cannot encode.

Review SDK regenerated.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 9 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.

View guided diff | Turn on auto-fix | Re-trigger cubic

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Preserves streamed SSE values while retaining existing model rendering when possible; focused tests cover missing unions, nulls, undeclared payload fields, and table layout. The behavior is bounded and corrects lossy rendering and stream aborts.

View guided diff | Turn on auto-fix | Re-trigger cubic

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Bounded CLI streaming fix: streamed events are rendered from the raw SSE frame, preventing aborts on absent required unions and preserving null, absent, and undeclared payload fields; the Go SDK gains RawValue, with focused tests across output formats.

View guided diff | Turn on auto-fix | Re-trigger cubic

@2ynn
2ynn marked this pull request as ready for review October 16, 2026 22:21
@2ynn
2ynn requested a review from a team as a code owner October 16, 2026 22:21
@2ynn
2ynn requested a review from AshGodfrey October 16, 2026 22:21

@AshGodfrey AshGodfrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. Verified the raw frame is a fresh json.Marshal per event (no buffer reuse), RawValue is nil before the first Next, the JSONL path is untouched, and the byte-identical claim holds for encodable events since typedLeaves returns the model's rendering for every leaf the model has.

Potential edge cases, none blocking:

  • Envelope schemas with additionalProperties: true. envelopeKeys is built from json tags, and the generated AdditionalProperties field is tagged json:"-". For such an envelope, frame keys the model used to print through its MarshalJSON are now dropped by the key filter.
  • RawValue after a failed Next. es.raw is only set on a successful decode, so after a decode error RawValue still returns the previous event. The doc comment says "the most recent event as handed to the unmarshaller", which is the failing frame in that case.
  • Error-event fallback body shape. For a union-of-envelopes stream, the encodable path renders the whole envelope union value as the error body, while the fallback uses only frame["data"]. The two paths produce different body shapes for the same kind of event.
  • Undeclared number lexemes are rewritten (1.0 → 1, 1e3 → 1000). That is a value the server did not send, in output otherwise meant to print as sent. Documented, just noting the tension.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go Trigger (90) snapshot tests for go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants