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

2Y
1d ago
+806-40

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.