From 893a92a2df41fbecf57aa533303921d53d4d3832 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 12:49:47 -0400 Subject: [PATCH 01/10] fix(go,cli): render streamed events from the frame the server sent ## 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`). --- .changesets/1792168841-0385b1d7.yaml | 11 ++++ .changesets/1792168842-05c0f7ca.yaml | 10 ++++ .../internal/output/streaming.go.stmpl | 57 ++++++++++++++++++ .../primary/eventstreaming_test.go.stmpl | 58 +++++++++++++++++++ .../stream/stream.go.stmpl | 11 ++++ .../primary/eventstreaming_test.go.stmpl | 18 ++++++ tests/overlays/primary/cli/overlay.yaml | 20 +++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/output/streaming.go | 57 ++++++++++++++++++ .../internal/sdk/types/stream/stream.go | 11 ++++ zSDKs/sdk-go/.speakeasy/gen.lock | 2 +- zSDKs/sdk-go/types/stream/stream.go | 11 ++++ 12 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 .changesets/1792168841-0385b1d7.yaml create mode 100644 .changesets/1792168842-05c0f7ca.yaml diff --git a/.changesets/1792168841-0385b1d7.yaml b/.changesets/1792168841-0385b1d7.yaml new file mode 100644 index 000000000..4a179c88d --- /dev/null +++ b/.changesets/1792168841-0385b1d7.yaml @@ -0,0 +1,11 @@ +id: 1792168841-0385b1d7 +features: + - serverEvents +targets: + - go + - cli +type: feat +bump: patch +description: expose the raw event behind each streamed Value through EventStream.RawValue +author: 2ynn +date: "2026-10-16" diff --git a/.changesets/1792168842-05c0f7ca.yaml b/.changesets/1792168842-05c0f7ca.yaml new file mode 100644 index 000000000..141fc1e7c --- /dev/null +++ b/.changesets/1792168842-05c0f7ca.yaml @@ -0,0 +1,10 @@ +id: 1792168842-05c0f7ca +features: + - serverEvents +targets: + - cli +type: fix +bump: patch +description: render streamed events from the frame the server sent, so an absent required union no longer aborts the stream and absent, null or undeclared fields are shown as sent +author: 2ynn +date: "2026-10-16" diff --git a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl index 2268e042c..7e4dc9444 100644 --- a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl @@ -4,6 +4,7 @@ package output import ( + "bytes" "encoding/json" "errors" "reflect" @@ -60,6 +61,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e valueMethod := streamVal.MethodByName("Value") errMethod := streamVal.MethodByName("Err") closeMethod := streamVal.MethodByName("Close") + rawMethod := streamVal.MethodByName("RawValue") if !nextMethod.IsValid() || !valueMethod.IsValid() { // Fallback: not a streaming type — output as regular result return Result(cmd, res) @@ -136,6 +138,10 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e } } + if rawMethod.IsValid() { + item = wireStreamItem(item, rawMethod.Call(nil)[0].Bytes()) + } + if projector != nil { if err := projector.emit(out, item); err != nil { return finishJoin(err) @@ -166,6 +172,57 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e return nil } +// wireEvent is a streamed event kept as JSON. Renderers treat it like any +// model with a custom MarshalJSON. +type wireEvent struct { + data json.RawMessage +} + +func (w wireEvent) MarshalJSON() ([]byte, error) { + return w.data, nil +} + +// wireStreamItem rebuilds a streamed event from the frame the server sent, +// keeping the envelope fields the event type declares. Re-encoding the +// decoded model instead drops fields the model does not declare, turns an +// absent or null required field into its zero value, and fails outright on +// an absent required union. +func wireStreamItem(item interface{}, raw []byte) interface{} { + t := reflect.TypeOf(item) + if len(raw) == 0 || t == nil || derefType(t).Kind() != reflect.Struct { + return item + } + t = derefType(t) + var frame map[string]json.RawMessage + if err := json.Unmarshal(raw, &frame); err != nil { + return item + } + wire := make(map[string]interface{}, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !singleTableField(f) { + continue + } + name := tableFieldName(f) + value, ok := frame[name] + if !ok { + continue + } + dec := json.NewDecoder(bytes.NewReader(value)) + dec.UseNumber() + var parsed interface{} + if err := dec.Decode(&parsed); err != nil { + return item + } + wire[name] = parsed + } + data, err := json.Marshal(wire) + if err != nil { + return item + } + return wireEvent{data: data} +} + func streamErrorEvent(item interface{}) (json.RawMessage, bool) { u, ok := errorUnionValue(reflect.ValueOf(item)) if !ok { diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index b8e26ae27..f5c44d2ef 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -591,6 +591,64 @@ func TestEventStreamNumbersKeepPrecision(t *testing.T) { } } +// A tool call announced before its outcome is known omits the required +// `outcome` union. The decoded event cannot be re-encoded (no union member is +// set), so every renderer works from the frame the server sent: the stream +// continues and the event gets no invented `outcome`. +func TestEventStreamAbsentRequiredUnionStreamsThrough(t *testing.T) { + frames := []string{ + `{"event_type":"delta","text":"before"}`, + `{"event_type":"tool","tool_id":"c1"}`, + `{"event_type":"delta","text":"after"}`, + } + for _, format := range []string{"json", "yaml", "toon", "pretty", "table"} { + t.Run(format, func(t *testing.T) { + server := newSSEServer(t, frames...) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events", "--output-format", format}) + require.NoError(t, err, h.GetStderr()) + + stdout := h.GetStdout() + assert.Contains(t, stdout, "before") + assert.Contains(t, stdout, "c1") + assert.Contains(t, stdout, "after") + assert.NotContains(t, stdout, "outcome", "%s output invented an outcome the server did not send", format) + }) + } + + t.Run("jq", func(t *testing.T) { + server := newSSEServer(t, frames...) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events", "--jq", ".data.event_type"}) + require.NoError(t, err, h.GetStderr()) + assert.Equal(t, "\"delta\"\n\"tool\"\n\"delta\"", strings.TrimSpace(h.GetStdout())) + }) +} + +// Streamed events render the values the server sent, not the decoded +// model's: an explicit null stays null rather than becoming a zero value, +// and fields the event schema does not declare are kept. +func TestEventStreamRendersWireValues(t *testing.T) { + server := newSSEServer(t, + `{"event_type":"tool","tool_id":"c1","outcome":null}`, + `{"event_type":"delta","text":"x","extra":{"k":1}}`, + ) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events"}) + require.NoError(t, err, h.GetStderr()) + + events := parseNDJSON(t, h.GetStdout()) + require.Len(t, events, 2) + assert.Equal(t, map[string]any{"event_type": "tool", "tool_id": "c1", "outcome": nil}, events[0]["data"]) + assert.Equal(t, map[string]any{"event_type": "delta", "text": "x", "extra": map[string]any{"k": float64(1)}}, events[1]["data"]) +} + // A stream event the server did deliver but that fails strict decode (a // numeric error code where the schema declares a string) is a protocol // mismatch, not a connection failure: agent mode classifies it diff --git a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl index acac4487d..572539a77 100644 --- a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl +++ b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl @@ -88,6 +88,7 @@ type EventStream[T any] struct { first bool err error val *T + raw json.RawMessage eventID *string } @@ -319,6 +320,7 @@ func (es *EventStream[T]) Next() bool { } es.val = &parsedEvent + es.raw = e return true } @@ -339,6 +341,15 @@ func (es *EventStream[T]) Value() *T { return es.val } +// RawValue returns the most recent event as it was handed to the +// unmarshaller: a JSON object with the frame's SSE fields (id, event, data, +// retry), where data is the JSON the server sent, or a JSON string for text +// data. Unlike re-encoding Value, it keeps fields the model does not declare +// and never adds values the server did not send. +func (es *EventStream[T]) RawValue() json.RawMessage { + return es.raw +} + // Err returns the first non-EOF error that was encountered func (es *EventStream[T]) Err() error { return es.err diff --git a/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl index 21c917219..c970d1ee1 100644 --- a/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl @@ -894,6 +894,24 @@ func TestEventStreamNextAfterEOFStaysFinished(t *testing.T) { assert.EqualValues(t, 1, releases.Load(), "the owned cancel runs exactly once") } +func TestEventStreamRawValue(t *testing.T) { + src := "event: tick\nid: 7\ndata: {\"b\":1,\"a\":null}\n\ndata: {\"c\":2}\n\n" + es := stream.NewEventStream(context.Background(), strings.NewReader(src), decodeRawEvent, "") + defer es.Close() + + assert.Nil(t, es.RawValue(), "no event has been read yet") + + require.True(t, es.Next()) + assert.JSONEq(t, `{"id":"7","event":"tick","data":{"b":1,"a":null}}`, string(es.RawValue())) + assert.Equal(t, `{"b":1,"a":null}`, string((*es.Value())["data"]), "data keeps the bytes the server sent") + + require.True(t, es.Next()) + assert.JSONEq(t, `{"data":{"c":2}}`, string(es.RawValue())) + + require.False(t, es.Next()) + require.NoError(t, es.Err()) +} + // signalReadCloser closes started on its first Read, before delegating to the // wrapped reader, so a test knows the consumer is inside a blocking Read // rather than merely scheduled. diff --git a/tests/overlays/primary/cli/overlay.yaml b/tests/overlays/primary/cli/overlay.yaml index cd329b551..b91a98b3a 100644 --- a/tests/overlays/primary/cli/overlay.yaml +++ b/tests/overlays/primary/cli/overlay.yaml @@ -375,11 +375,13 @@ actions: oneOf: - $ref: '#/components/schemas/discriminatedDeltaEvent' - $ref: '#/components/schemas/discriminatedErrorEvent' + - $ref: '#/components/schemas/discriminatedToolEvent' discriminator: propertyName: event_type mapping: delta: '#/components/schemas/discriminatedDeltaEvent' error: '#/components/schemas/discriminatedErrorEvent' + tool: '#/components/schemas/discriminatedToolEvent' discriminatedDeltaEvent: type: object required: @@ -417,6 +419,24 @@ actions: const: error error: $ref: '#/components/schemas/discriminatedStreamError' + discriminatedToolEvent: + description: A tool call announced before its outcome is known, so the required outcome may be absent on the wire + type: object + required: + - event_type + - tool_id + - outcome + properties: + event_type: + type: string + const: tool + tool_id: + type: string + outcome: + oneOf: + - type: string + - type: object + additionalProperties: true discriminatedStreamError: type: object required: diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index a057e9790..4c0250310 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -294,7 +294,7 @@ trackedFiles: internal/output/progress.go: last_write_checksum: sha1:f8b839f9394602fe9c30feb3d7155af6235663be internal/output/streaming.go: - last_write_checksum: sha1:65c6783da972ee644d63dfddaf619af4df86bdb6 + last_write_checksum: sha1:a25abe5986c1a2bf3dc015a3b7b8fde434b58b3d internal/output/streamproject.go: last_write_checksum: sha1:19d7dec64b1f78cbcfb5420cc4e968aa7aedadd0 internal/sdk/.gitattributes: @@ -652,7 +652,7 @@ trackedFiles: internal/sdk/types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef internal/sdk/types/stream/stream.go: - last_write_checksum: sha1:08cba0dd035d9a9e3aca5df6ce9f1617796b4872 + last_write_checksum: sha1:da0a3ea2c22e5ef08eb695169bf309bbe172501f internal/testclient/testclient.go: last_write_checksum: sha1:7107e59d336235c44af152c5a61ee3896a7841fa internal/usage/bodyschemas.go: diff --git a/zSDKs/sdk-cli/internal/output/streaming.go b/zSDKs/sdk-cli/internal/output/streaming.go index 4eb9f48c6..9cdf7bbca 100644 --- a/zSDKs/sdk-cli/internal/output/streaming.go +++ b/zSDKs/sdk-cli/internal/output/streaming.go @@ -5,6 +5,7 @@ package output import ( + "bytes" "encoding/json" "errors" "reflect" @@ -61,6 +62,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e valueMethod := streamVal.MethodByName("Value") errMethod := streamVal.MethodByName("Err") closeMethod := streamVal.MethodByName("Close") + rawMethod := streamVal.MethodByName("RawValue") if !nextMethod.IsValid() || !valueMethod.IsValid() { // Fallback: not a streaming type — output as regular result return Result(cmd, res) @@ -137,6 +139,10 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e } } + if rawMethod.IsValid() { + item = wireStreamItem(item, rawMethod.Call(nil)[0].Bytes()) + } + if projector != nil { if err := projector.emit(out, item); err != nil { return finishJoin(err) @@ -167,6 +173,57 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e return nil } +// wireEvent is a streamed event kept as JSON. Renderers treat it like any +// model with a custom MarshalJSON. +type wireEvent struct { + data json.RawMessage +} + +func (w wireEvent) MarshalJSON() ([]byte, error) { + return w.data, nil +} + +// wireStreamItem rebuilds a streamed event from the frame the server sent, +// keeping the envelope fields the event type declares. Re-encoding the +// decoded model instead drops fields the model does not declare, turns an +// absent or null required field into its zero value, and fails outright on +// an absent required union. +func wireStreamItem(item interface{}, raw []byte) interface{} { + t := reflect.TypeOf(item) + if len(raw) == 0 || t == nil || derefType(t).Kind() != reflect.Struct { + return item + } + t = derefType(t) + var frame map[string]json.RawMessage + if err := json.Unmarshal(raw, &frame); err != nil { + return item + } + wire := make(map[string]interface{}, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !singleTableField(f) { + continue + } + name := tableFieldName(f) + value, ok := frame[name] + if !ok { + continue + } + dec := json.NewDecoder(bytes.NewReader(value)) + dec.UseNumber() + var parsed interface{} + if err := dec.Decode(&parsed); err != nil { + return item + } + wire[name] = parsed + } + data, err := json.Marshal(wire) + if err != nil { + return item + } + return wireEvent{data: data} +} + func streamErrorEvent(item interface{}) (json.RawMessage, bool) { u, ok := errorUnionValue(reflect.ValueOf(item)) if !ok { diff --git a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go index e5376543c..fb42fb525 100644 --- a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go +++ b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go @@ -89,6 +89,7 @@ type EventStream[T any] struct { first bool err error val *T + raw json.RawMessage eventID *string } @@ -320,6 +321,7 @@ func (es *EventStream[T]) Next() bool { } es.val = &parsedEvent + es.raw = e return true } @@ -340,6 +342,15 @@ func (es *EventStream[T]) Value() *T { return es.val } +// RawValue returns the most recent event as it was handed to the +// unmarshaller: a JSON object with the frame's SSE fields (id, event, data, +// retry), where data is the JSON the server sent, or a JSON string for text +// data. Unlike re-encoding Value, it keeps fields the model does not declare +// and never adds values the server did not send. +func (es *EventStream[T]) RawValue() json.RawMessage { + return es.raw +} + // Err returns the first non-EOF error that was encountered func (es *EventStream[T]) Err() error { return es.err diff --git a/zSDKs/sdk-go/.speakeasy/gen.lock b/zSDKs/sdk-go/.speakeasy/gen.lock index b2144df8e..6e33ed49f 100644 --- a/zSDKs/sdk-go/.speakeasy/gen.lock +++ b/zSDKs/sdk-go/.speakeasy/gen.lock @@ -1289,7 +1289,7 @@ trackedFiles: types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef types/stream/stream.go: - last_write_checksum: sha1:08cba0dd035d9a9e3aca5df6ce9f1617796b4872 + last_write_checksum: sha1:da0a3ea2c22e5ef08eb695169bf309bbe172501f updateuserop.go: last_write_checksum: sha1:32b193694bf28ab709ef1bdda9ff8a2d633c831f urlvalidationstresstestop.go: diff --git a/zSDKs/sdk-go/types/stream/stream.go b/zSDKs/sdk-go/types/stream/stream.go index e5376543c..fb42fb525 100644 --- a/zSDKs/sdk-go/types/stream/stream.go +++ b/zSDKs/sdk-go/types/stream/stream.go @@ -89,6 +89,7 @@ type EventStream[T any] struct { first bool err error val *T + raw json.RawMessage eventID *string } @@ -320,6 +321,7 @@ func (es *EventStream[T]) Next() bool { } es.val = &parsedEvent + es.raw = e return true } @@ -340,6 +342,15 @@ func (es *EventStream[T]) Value() *T { return es.val } +// RawValue returns the most recent event as it was handed to the +// unmarshaller: a JSON object with the frame's SSE fields (id, event, data, +// retry), where data is the JSON the server sent, or a JSON string for text +// data. Unlike re-encoding Value, it keeps fields the model does not declare +// and never adds values the server did not send. +func (es *EventStream[T]) RawValue() json.RawMessage { + return es.raw +} + // Err returns the first non-EOF error that was encountered func (es *EventStream[T]) Err() error { return es.err From fdbd82cb4f3602cca0507d07ba2973721f162037 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 13:20:46 -0400 Subject: [PATCH 02/10] fix(go,cli): keep streamed numbers canonical and error events failing - 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. --- .../internal/output/streaming.go.stmpl | 50 ++++++++++++++++--- .../primary/eventstreaming_test.go.stmpl | 24 +++++++++ .../stream/stream.go.stmpl | 9 ++-- .../primary/eventstreaming_test.go.stmpl | 2 +- zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/output/streaming.go | 50 ++++++++++++++++--- .../internal/sdk/types/stream/stream.go | 9 ++-- zSDKs/sdk-go/.speakeasy/gen.lock | 2 +- zSDKs/sdk-go/types/stream/stream.go | 9 ++-- 9 files changed, 127 insertions(+), 32 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl index 7e4dc9444..f36ef8fd9 100644 --- a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "errors" + "math/big" "reflect" "strings" @@ -132,14 +133,19 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e } } + var raw []byte + if rawMethod.IsValid() { + raw = rawMethod.Call(nil)[0].Bytes() + } + if len(valueResults) == 1 { - if body, isErr := streamErrorEvent(item); isErr { + if body, isErr := streamErrorEvent(item, raw); isErr { return renderAfterFinish(&StreamEventError{Body: string(body)}) } } - if rawMethod.IsValid() { - item = wireStreamItem(item, rawMethod.Call(nil)[0].Bytes()) + if raw != nil { + item = wireStreamItem(item, raw) } if projector != nil { @@ -214,7 +220,7 @@ func wireStreamItem(item interface{}, raw []byte) interface{} { if err := dec.Decode(&parsed); err != nil { return item } - wire[name] = parsed + wire[name] = convertNumbers(parsed, canonicalNumber) } data, err := json.Marshal(wire) if err != nil { @@ -223,16 +229,44 @@ func wireStreamItem(item interface{}, raw []byte) interface{} { return wireEvent{data: data} } -func streamErrorEvent(item interface{}) (json.RawMessage, bool) { +// canonicalNumber rewrites a lexeme to the form the decoded model would print +// ("1.0" and "1e3" become "1" and "1000") when float64 holds the same value. +// A lexeme float64 would change (bigint, long decimals) is kept as sent. +func canonicalNumber(n json.Number) interface{} { + exact, ok := new(big.Rat).SetString(n.String()) + if !ok { + return n + } + f, err := n.Float64() + if err != nil { + return n + } + shortest, err := json.Marshal(f) + if err != nil { + return n + } + if r, ok := new(big.Rat).SetString(string(shortest)); !ok || r.Cmp(exact) != 0 { + return n + } + return json.Number(shortest) +} + +// streamErrorEvent reports whether item is an in-band error event and returns +// its body. A decoded error that cannot be re-encoded falls back to the data +// the server sent, so the event still fails the stream. +func streamErrorEvent(item interface{}, raw []byte) (json.RawMessage, bool) { u, ok := errorUnionValue(reflect.ValueOf(item)) if !ok { return nil, false } - data, err := marshalJSON(u.Interface()) - if err != nil { + if data, err := marshalJSON(u.Interface()); err == nil { + return data, true + } + var frame map[string]json.RawMessage + if err := json.Unmarshal(raw, &frame); err != nil || len(frame["data"]) == 0 { return nil, false } - return data, true + return frame["data"], true } func errorUnionValue(v reflect.Value) (reflect.Value, bool) { diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index f5c44d2ef..550994d43 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -649,6 +649,30 @@ func TestEventStreamRendersWireValues(t *testing.T) { assert.Equal(t, map[string]any{"event_type": "delta", "text": "x", "extra": map[string]any{"k": float64(1)}}, events[1]["data"]) } +// A number lexeme float64 holds without change ("1.0", "1e3", "1e-05") +// prints as the decoded model would, in every format; toon and yaml keep it a +// native number rather than a quoted string. +func TestEventStreamEquivalentNumberLexemesPrintCanonically(t *testing.T) { + for format, want := range map[string][]string{ + "json": {`"ratio":1,`, `"scale":1000,`, `"tiny":0.00001`}, + "yaml": {"ratio: 1\n", "scale: 1000\n", "tiny: 1e-05\n"}, + "toon": {"ratio: 1\n", "scale: 1000\n", "tiny: 0.00001\n"}, + } { + t.Run(format, func(t *testing.T) { + server := newSSEServer(t, `{"event_type":"delta","text":"n","ratio":1.0,"scale":1e3,"tiny":1e-05}`) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events", "--output-format", format}) + require.NoError(t, err, h.GetStderr()) + + for _, s := range want { + assert.Contains(t, h.GetStdout(), s) + } + }) + } +} + // A stream event the server did deliver but that fails strict decode (a // numeric error code where the schema declares a string) is a protocol // mismatch, not a connection failure: agent mode classifies it diff --git a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl index 572539a77..3b64558ed 100644 --- a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl +++ b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl @@ -342,10 +342,11 @@ func (es *EventStream[T]) Value() *T { } // RawValue returns the most recent event as it was handed to the -// unmarshaller: a JSON object with the frame's SSE fields (id, event, data, -// retry), where data is the JSON the server sent, or a JSON string for text -// data. Unlike re-encoding Value, it keeps fields the model does not declare -// and never adds values the server did not send. +// unmarshaller: a JSON object with the SSE fields event, data and retry of +// the frame, and id, the last event ID seen on the stream. data is the JSON +// the server sent, or a JSON string for text data. Unlike re-encoding Value, +// it keeps fields the model does not declare and never adds values the +// server did not send. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } diff --git a/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl index c970d1ee1..b50c609a4 100644 --- a/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/go/tests/primary/eventstreaming_test.go.stmpl @@ -906,7 +906,7 @@ func TestEventStreamRawValue(t *testing.T) { assert.Equal(t, `{"b":1,"a":null}`, string((*es.Value())["data"]), "data keeps the bytes the server sent") require.True(t, es.Next()) - assert.JSONEq(t, `{"data":{"c":2}}`, string(es.RawValue())) + assert.JSONEq(t, `{"id":"7","data":{"c":2}}`, string(es.RawValue()), "the last event ID carries over, as in the SSE spec") require.False(t, es.Next()) require.NoError(t, es.Err()) diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 4c0250310..e7a771c70 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -294,7 +294,7 @@ trackedFiles: internal/output/progress.go: last_write_checksum: sha1:f8b839f9394602fe9c30feb3d7155af6235663be internal/output/streaming.go: - last_write_checksum: sha1:a25abe5986c1a2bf3dc015a3b7b8fde434b58b3d + last_write_checksum: sha1:fd94a3a0f39a96cca0b81b3eea9002600b6a09aa internal/output/streamproject.go: last_write_checksum: sha1:19d7dec64b1f78cbcfb5420cc4e968aa7aedadd0 internal/sdk/.gitattributes: @@ -652,7 +652,7 @@ trackedFiles: internal/sdk/types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef internal/sdk/types/stream/stream.go: - last_write_checksum: sha1:da0a3ea2c22e5ef08eb695169bf309bbe172501f + last_write_checksum: sha1:6271a5222961fe20d8e0f9a984a395786421f438 internal/testclient/testclient.go: last_write_checksum: sha1:7107e59d336235c44af152c5a61ee3896a7841fa internal/usage/bodyschemas.go: diff --git a/zSDKs/sdk-cli/internal/output/streaming.go b/zSDKs/sdk-cli/internal/output/streaming.go index 9cdf7bbca..284974d55 100644 --- a/zSDKs/sdk-cli/internal/output/streaming.go +++ b/zSDKs/sdk-cli/internal/output/streaming.go @@ -8,6 +8,7 @@ import ( "bytes" "encoding/json" "errors" + "math/big" "reflect" "strings" @@ -133,14 +134,19 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e } } + var raw []byte + if rawMethod.IsValid() { + raw = rawMethod.Call(nil)[0].Bytes() + } + if len(valueResults) == 1 { - if body, isErr := streamErrorEvent(item); isErr { + if body, isErr := streamErrorEvent(item, raw); isErr { return renderAfterFinish(&StreamEventError{Body: string(body)}) } } - if rawMethod.IsValid() { - item = wireStreamItem(item, rawMethod.Call(nil)[0].Bytes()) + if raw != nil { + item = wireStreamItem(item, raw) } if projector != nil { @@ -215,7 +221,7 @@ func wireStreamItem(item interface{}, raw []byte) interface{} { if err := dec.Decode(&parsed); err != nil { return item } - wire[name] = parsed + wire[name] = convertNumbers(parsed, canonicalNumber) } data, err := json.Marshal(wire) if err != nil { @@ -224,16 +230,44 @@ func wireStreamItem(item interface{}, raw []byte) interface{} { return wireEvent{data: data} } -func streamErrorEvent(item interface{}) (json.RawMessage, bool) { +// canonicalNumber rewrites a lexeme to the form the decoded model would print +// ("1.0" and "1e3" become "1" and "1000") when float64 holds the same value. +// A lexeme float64 would change (bigint, long decimals) is kept as sent. +func canonicalNumber(n json.Number) interface{} { + exact, ok := new(big.Rat).SetString(n.String()) + if !ok { + return n + } + f, err := n.Float64() + if err != nil { + return n + } + shortest, err := json.Marshal(f) + if err != nil { + return n + } + if r, ok := new(big.Rat).SetString(string(shortest)); !ok || r.Cmp(exact) != 0 { + return n + } + return json.Number(shortest) +} + +// streamErrorEvent reports whether item is an in-band error event and returns +// its body. A decoded error that cannot be re-encoded falls back to the data +// the server sent, so the event still fails the stream. +func streamErrorEvent(item interface{}, raw []byte) (json.RawMessage, bool) { u, ok := errorUnionValue(reflect.ValueOf(item)) if !ok { return nil, false } - data, err := marshalJSON(u.Interface()) - if err != nil { + if data, err := marshalJSON(u.Interface()); err == nil { + return data, true + } + var frame map[string]json.RawMessage + if err := json.Unmarshal(raw, &frame); err != nil || len(frame["data"]) == 0 { return nil, false } - return data, true + return frame["data"], true } func errorUnionValue(v reflect.Value) (reflect.Value, bool) { diff --git a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go index fb42fb525..092f52ec2 100644 --- a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go +++ b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go @@ -343,10 +343,11 @@ func (es *EventStream[T]) Value() *T { } // RawValue returns the most recent event as it was handed to the -// unmarshaller: a JSON object with the frame's SSE fields (id, event, data, -// retry), where data is the JSON the server sent, or a JSON string for text -// data. Unlike re-encoding Value, it keeps fields the model does not declare -// and never adds values the server did not send. +// unmarshaller: a JSON object with the SSE fields event, data and retry of +// the frame, and id, the last event ID seen on the stream. data is the JSON +// the server sent, or a JSON string for text data. Unlike re-encoding Value, +// it keeps fields the model does not declare and never adds values the +// server did not send. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } diff --git a/zSDKs/sdk-go/.speakeasy/gen.lock b/zSDKs/sdk-go/.speakeasy/gen.lock index 6e33ed49f..dffc903fc 100644 --- a/zSDKs/sdk-go/.speakeasy/gen.lock +++ b/zSDKs/sdk-go/.speakeasy/gen.lock @@ -1289,7 +1289,7 @@ trackedFiles: types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef types/stream/stream.go: - last_write_checksum: sha1:da0a3ea2c22e5ef08eb695169bf309bbe172501f + last_write_checksum: sha1:6271a5222961fe20d8e0f9a984a395786421f438 updateuserop.go: last_write_checksum: sha1:32b193694bf28ab709ef1bdda9ff8a2d633c831f urlvalidationstresstestop.go: diff --git a/zSDKs/sdk-go/types/stream/stream.go b/zSDKs/sdk-go/types/stream/stream.go index fb42fb525..092f52ec2 100644 --- a/zSDKs/sdk-go/types/stream/stream.go +++ b/zSDKs/sdk-go/types/stream/stream.go @@ -343,10 +343,11 @@ func (es *EventStream[T]) Value() *T { } // RawValue returns the most recent event as it was handed to the -// unmarshaller: a JSON object with the frame's SSE fields (id, event, data, -// retry), where data is the JSON the server sent, or a JSON string for text -// data. Unlike re-encoding Value, it keeps fields the model does not declare -// and never adds values the server did not send. +// unmarshaller: a JSON object with the SSE fields event, data and retry of +// the frame, and id, the last event ID seen on the stream. data is the JSON +// the server sent, or a JSON string for text data. Unlike re-encoding Value, +// it keeps fields the model does not declare and never adds values the +// server did not send. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } From 90156ab938f2be09210a2094b55728c540c54fde Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 13:50:18 -0400 Subject: [PATCH 03/10] fix(cli): keep union envelopes and model-rendered values in streamed 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 897fe27a4a 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. --- .../internal/output/streaming.go.stmpl | 126 ++++++++++++++---- .../primary/eventstreaming_test.go.stmpl | 33 ++++- tests/overlays/primary/cli/overlay.yaml | 3 + zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/internal/output/streaming.go | 126 ++++++++++++++---- 5 files changed, 242 insertions(+), 48 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl index f36ef8fd9..a6e36ac56 100644 --- a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl @@ -190,48 +190,128 @@ func (w wireEvent) MarshalJSON() ([]byte, error) { // wireStreamItem rebuilds a streamed event from the frame the server sent, // keeping the envelope fields the event type declares. Re-encoding the -// decoded model instead drops fields the model does not declare, turns an +// decoded model alone drops fields the model does not declare, turns an // absent or null required field into its zero value, and fails outright on -// an absent required union. +// an absent required union. Each value the server sent still prints as the +// decoded model renders it wherever the model has one (see typedLeaves). func wireStreamItem(item interface{}, raw []byte) interface{} { - t := reflect.TypeOf(item) - if len(raw) == 0 || t == nil || derefType(t).Kind() != reflect.Struct { + keys, ok := envelopeKeys(reflect.ValueOf(item)) + if len(raw) == 0 || !ok { return item } - t = derefType(t) var frame map[string]json.RawMessage if err := json.Unmarshal(raw, &frame); err != nil { return item } - wire := make(map[string]interface{}, t.NumField()) - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if !singleTableField(f) { - continue - } - name := tableFieldName(f) - value, ok := frame[name] - if !ok { + wire := make(map[string]interface{}, len(frame)) + for name, value := range frame { + if keys != nil && !keys[name] { continue } - dec := json.NewDecoder(bytes.NewReader(value)) - dec.UseNumber() - var parsed interface{} - if err := dec.Decode(&parsed); err != nil { + parsed, err := decodeWithNumbers(value) + if err != nil { return item } - wire[name] = convertNumbers(parsed, canonicalNumber) + wire[name] = parsed } - data, err := json.Marshal(wire) + var typed interface{} + if data, err := marshalJSON(item); err == nil { + typed, _ = decodeWithNumbers(data) + } + data, err := json.Marshal(typedLeaves(wire, typed)) if err != nil { return item } return wireEvent{data: data} } -// canonicalNumber rewrites a lexeme to the form the decoded model would print -// ("1.0" and "1e3" become "1" and "1000") when float64 holds the same value. -// A lexeme float64 would change (bigint, long decimals) is kept as sent. +// envelopeKeys returns the JSON names of the envelope fields the event v +// declares, or nil when every frame field belongs to it: a union event whose +// frame matched no member keeps the whole frame, as its decoded form does. +// For a union event the envelope is the member that decoded. ok is false +// when v is not a struct event. +func envelopeKeys(v reflect.Value) (map[string]bool, bool) { + v = derefValue(v) + if !v.IsValid() || v.Kind() != reflect.Struct { + return nil, false + } + t := v.Type() + if isUnionStruct(t) { + member := reflect.Value{} + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Tag.Get("union") == "member" && !v.Field(i).IsZero() { + member = derefValue(v.Field(i)) + break + } + } + if !member.IsValid() || member.Kind() != reflect.Struct { + return nil, true + } + t = member.Type() + } + keys := map[string]bool{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name := strings.Split(f.Tag.Get("json"), ",")[0] + if name == "-" || (name == "" && !f.IsExported()) { + continue + } + if name == "" { + name = f.Name + } + keys[name] = true + } + return keys, true +} + +func decodeWithNumbers(data []byte) (interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var parsed interface{} + err := dec.Decode(&parsed) + return parsed, err +} + +// typedLeaves walks the values the server sent (wire) alongside the decoded +// model's rendering of the same event (typed, nil when the model cannot be +// encoded) and returns wire with each scalar replaced by the typed scalar at +// the same place, so numbers, dates and other formatted values print as +// before. Nulls and values the model lacks (undeclared fields, events it +// cannot encode) keep the server's value, numbers in their shortest form. +func typedLeaves(wire, typed interface{}) interface{} { + switch w := wire.(type) { + case nil: + return nil + case map[string]interface{}: + t, _ := typed.(map[string]interface{}) + for k, v := range w { + w[k] = typedLeaves(v, t[k]) + } + return w + case []interface{}: + t, _ := typed.([]interface{}) + for i, v := range w { + var tv interface{} + if i < len(t) { + tv = t[i] + } + w[i] = typedLeaves(v, tv) + } + return w + } + switch typed.(type) { + case nil, map[string]interface{}, []interface{}: + if n, ok := wire.(json.Number); ok { + return canonicalNumber(n) + } + return wire + } + return typed +} + +// canonicalNumber rewrites a lexeme the model does not render ("1.0", "1e3") +// to its shortest form ("1", "1000") when float64 holds the same value. A +// lexeme float64 would change (bigint, long decimals) is kept as sent. func canonicalNumber(n json.Number) interface{} { exact, ok := new(big.Rat).SetString(n.String()) if !ok { diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index 550994d43..300e69cd8 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -104,7 +104,15 @@ func TestEventStreamRichEvents(t *testing.T) { require.NoError(t, err) events := parseNDJSON(t, h.GetStdout()) - assert.Equal(t, 3, len(events), "expected 3 rich events") + require.Equal(t, 3, len(events), "expected 3 rich events") + // The stream element is a union of envelopes: each event keeps the + // envelope fields of the member it decoded to. + assert.Equal(t, map[string]interface{}{ + "id": "job-1", + "event": "completion", + "data": map[string]interface{}{"completion": "Hello", "stop_reason": nil, "model": "jeeves-1"}, + }, events[0]) + assert.Equal(t, map[string]interface{}{"event": "heartbeat", "data": "ping", "retry": float64(3000)}, events[1]) } func TestEventStreamChatSentinel(t *testing.T) { @@ -673,6 +681,29 @@ func TestEventStreamEquivalentNumberLexemesPrintCanonically(t *testing.T) { } } +// A value the model declares prints as the model renders it, whatever the +// server's lexeme: an int field sent as -0 prints 0, a double sent with +// more digits than float64 holds prints its float64 value, natively in toon. +func TestEventStreamDeclaredValuesPrintAsTheModelRendersThem(t *testing.T) { + for format, want := range map[string][]string{ + "json": {`"seq":0,`, `"weight":0.1}`}, + "toon": {"seq: 0\n", "weight: 0.1\n"}, + } { + t.Run(format, func(t *testing.T) { + server := newSSEServer(t, `{"event_type":"delta","text":"n","seq":-0,"weight":0.10000000000000001}`) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events", "--output-format", format}) + require.NoError(t, err, h.GetStderr()) + + for _, s := range want { + assert.Contains(t, h.GetStdout(), s) + } + }) + } +} + // A stream event the server did deliver but that fails strict decode (a // numeric error code where the schema declares a string) is a protocol // mismatch, not a connection failure: agent mode classifies it diff --git a/tests/overlays/primary/cli/overlay.yaml b/tests/overlays/primary/cli/overlay.yaml index b91a98b3a..ce63d0113 100644 --- a/tests/overlays/primary/cli/overlay.yaml +++ b/tests/overlays/primary/cli/overlay.yaml @@ -408,6 +408,9 @@ actions: type: string format: date description: a date, so a malformed value fails decode without an encoding/json error type + weight: + type: number + format: double discriminatedErrorEvent: type: object required: diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index e7a771c70..1c72cfdda 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -294,7 +294,7 @@ trackedFiles: internal/output/progress.go: last_write_checksum: sha1:f8b839f9394602fe9c30feb3d7155af6235663be internal/output/streaming.go: - last_write_checksum: sha1:fd94a3a0f39a96cca0b81b3eea9002600b6a09aa + last_write_checksum: sha1:ab67ecb23c2fc97c7920379bc48a6f2512bccfc8 internal/output/streamproject.go: last_write_checksum: sha1:19d7dec64b1f78cbcfb5420cc4e968aa7aedadd0 internal/sdk/.gitattributes: diff --git a/zSDKs/sdk-cli/internal/output/streaming.go b/zSDKs/sdk-cli/internal/output/streaming.go index 284974d55..fc15767db 100644 --- a/zSDKs/sdk-cli/internal/output/streaming.go +++ b/zSDKs/sdk-cli/internal/output/streaming.go @@ -191,48 +191,128 @@ func (w wireEvent) MarshalJSON() ([]byte, error) { // wireStreamItem rebuilds a streamed event from the frame the server sent, // keeping the envelope fields the event type declares. Re-encoding the -// decoded model instead drops fields the model does not declare, turns an +// decoded model alone drops fields the model does not declare, turns an // absent or null required field into its zero value, and fails outright on -// an absent required union. +// an absent required union. Each value the server sent still prints as the +// decoded model renders it wherever the model has one (see typedLeaves). func wireStreamItem(item interface{}, raw []byte) interface{} { - t := reflect.TypeOf(item) - if len(raw) == 0 || t == nil || derefType(t).Kind() != reflect.Struct { + keys, ok := envelopeKeys(reflect.ValueOf(item)) + if len(raw) == 0 || !ok { return item } - t = derefType(t) var frame map[string]json.RawMessage if err := json.Unmarshal(raw, &frame); err != nil { return item } - wire := make(map[string]interface{}, t.NumField()) - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if !singleTableField(f) { - continue - } - name := tableFieldName(f) - value, ok := frame[name] - if !ok { + wire := make(map[string]interface{}, len(frame)) + for name, value := range frame { + if keys != nil && !keys[name] { continue } - dec := json.NewDecoder(bytes.NewReader(value)) - dec.UseNumber() - var parsed interface{} - if err := dec.Decode(&parsed); err != nil { + parsed, err := decodeWithNumbers(value) + if err != nil { return item } - wire[name] = convertNumbers(parsed, canonicalNumber) + wire[name] = parsed } - data, err := json.Marshal(wire) + var typed interface{} + if data, err := marshalJSON(item); err == nil { + typed, _ = decodeWithNumbers(data) + } + data, err := json.Marshal(typedLeaves(wire, typed)) if err != nil { return item } return wireEvent{data: data} } -// canonicalNumber rewrites a lexeme to the form the decoded model would print -// ("1.0" and "1e3" become "1" and "1000") when float64 holds the same value. -// A lexeme float64 would change (bigint, long decimals) is kept as sent. +// envelopeKeys returns the JSON names of the envelope fields the event v +// declares, or nil when every frame field belongs to it: a union event whose +// frame matched no member keeps the whole frame, as its decoded form does. +// For a union event the envelope is the member that decoded. ok is false +// when v is not a struct event. +func envelopeKeys(v reflect.Value) (map[string]bool, bool) { + v = derefValue(v) + if !v.IsValid() || v.Kind() != reflect.Struct { + return nil, false + } + t := v.Type() + if isUnionStruct(t) { + member := reflect.Value{} + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Tag.Get("union") == "member" && !v.Field(i).IsZero() { + member = derefValue(v.Field(i)) + break + } + } + if !member.IsValid() || member.Kind() != reflect.Struct { + return nil, true + } + t = member.Type() + } + keys := map[string]bool{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name := strings.Split(f.Tag.Get("json"), ",")[0] + if name == "-" || (name == "" && !f.IsExported()) { + continue + } + if name == "" { + name = f.Name + } + keys[name] = true + } + return keys, true +} + +func decodeWithNumbers(data []byte) (interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var parsed interface{} + err := dec.Decode(&parsed) + return parsed, err +} + +// typedLeaves walks the values the server sent (wire) alongside the decoded +// model's rendering of the same event (typed, nil when the model cannot be +// encoded) and returns wire with each scalar replaced by the typed scalar at +// the same place, so numbers, dates and other formatted values print as +// before. Nulls and values the model lacks (undeclared fields, events it +// cannot encode) keep the server's value, numbers in their shortest form. +func typedLeaves(wire, typed interface{}) interface{} { + switch w := wire.(type) { + case nil: + return nil + case map[string]interface{}: + t, _ := typed.(map[string]interface{}) + for k, v := range w { + w[k] = typedLeaves(v, t[k]) + } + return w + case []interface{}: + t, _ := typed.([]interface{}) + for i, v := range w { + var tv interface{} + if i < len(t) { + tv = t[i] + } + w[i] = typedLeaves(v, tv) + } + return w + } + switch typed.(type) { + case nil, map[string]interface{}, []interface{}: + if n, ok := wire.(json.Number); ok { + return canonicalNumber(n) + } + return wire + } + return typed +} + +// canonicalNumber rewrites a lexeme the model does not render ("1.0", "1e3") +// to its shortest form ("1", "1000") when float64 holds the same value. A +// lexeme float64 would change (bigint, long decimals) is kept as sent. func canonicalNumber(n json.Number) interface{} { exact, ok := new(big.Rat).SetString(n.String()) if !ok { From e26d6a44bc06c66ce81edf20b20659d22261b9d4 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 13:58:49 -0400 Subject: [PATCH 04/10] test(cli): record the shared truncated-stream test ID 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. --- internal/features/tests.go | 1 - internal/features/tests_generated.go | 5 ----- templates/templates/cli/features.ts | 1 - .../templates/cli/tests/primary/eventstreaming_test.go.stmpl | 2 +- templates/templates/csharp/features.ts | 1 - templates/templates/go/features.ts | 1 - templates/templates/javav2/features.ts | 1 - templates/templates/mcp-typescript/features.ts | 1 - templates/templates/php/features.ts | 1 - templates/templates/pythonv2/features.ts | 1 - templates/templates/ruby/features.ts | 1 - templates/templates/typescriptv2/features.ts | 1 - templates/templates/unity/features.ts | 1 - 13 files changed, 1 insertion(+), 17 deletions(-) diff --git a/internal/features/tests.go b/internal/features/tests.go index af25efa0f..dac8f0818 100644 --- a/internal/features/tests.go +++ b/internal/features/tests.go @@ -695,7 +695,6 @@ const ( TestErrorTaxonomyPrettyResidualDetails TestErrorTaxonomyReservedKeysAndRenderedClassification TestEventStreamConnectionRefusedStaysConnectionError - TestEventStreamTruncatedBeforeSentinelIsConnectionError TestEventStreamDecodeFailureIsProtocolError TestEventStreamDecodeFailureWithoutJsonErrorType TestEventStreamErrorEventAgentMode diff --git a/internal/features/tests_generated.go b/internal/features/tests_generated.go index 2b9dda137..4d813dcbb 100644 --- a/internal/features/tests_generated.go +++ b/internal/features/tests_generated.go @@ -322,8 +322,6 @@ func (t Test) String() string { return "event-stream-timeout-still-bounds-slow-stream" case TestEventStreamTruncatedBeforeSentinel: return "event-stream-truncated-before-sentinel" - case TestEventStreamTruncatedBeforeSentinelIsConnectionError: - return "event-stream-truncated-before-sentinel-is-connection-error" case TestEventStreamUndiscriminatedErrorShapedVariantIsData: return "event-stream-undiscriminated-error-shaped-variant-is-data" case TestEventStreamUnionEventsRenderWireShape: @@ -1838,8 +1836,6 @@ func TestFromString(s string) Test { return TestEventStreamTimeoutStillBoundsSlowStream case "event-stream-truncated-before-sentinel": return TestEventStreamTruncatedBeforeSentinel - case "event-stream-truncated-before-sentinel-is-connection-error": - return TestEventStreamTruncatedBeforeSentinelIsConnectionError case "event-stream-undiscriminated-error-shaped-variant-is-data": return TestEventStreamUndiscriminatedErrorShapedVariantIsData case "event-stream-union-events-render-wire-shape": @@ -3194,7 +3190,6 @@ var testList = []Test{ TestEventStreamTextData, TestEventStreamTimeoutStillBoundsSlowStream, TestEventStreamTruncatedBeforeSentinel, - TestEventStreamTruncatedBeforeSentinelIsConnectionError, TestEventStreamUndiscriminatedErrorShapedVariantIsData, TestEventStreamUnionEventsRenderWireShape, TestEventStreamUnionWithStandaloneComments, diff --git a/templates/templates/cli/features.ts b/templates/templates/cli/features.ts index e81712213..8c4e31c36 100644 --- a/templates/templates/cli/features.ts +++ b/templates/templates/cli/features.ts @@ -358,7 +358,6 @@ function isTestSkipped(test: string): boolean { "parameters-path-parameter-format-union", "parameters-path-parameter-formats", "shared-enum-constructor-default", - "event-stream-truncated-before-sentinel", "open-union-invalid-json-is-an-error", ].includes(test); } diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index 300e69cd8..8f95154e3 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -744,7 +744,7 @@ func TestEventStreamDecodeFailureIsProtocolError(t *testing.T) { // closes before sending it: the events received are still printed, and the // failure is a retryable connection error rather than a clean exit. func TestEventStreamTruncatedBeforeSentinelIsConnectionError(t *testing.T) { - recordTest("event-stream-truncated-before-sentinel-is-connection-error") + recordTest("event-stream-truncated-before-sentinel") server := newSSEServer(t, `{"data":{"content":"Hello"}}`, `{"data":{"content":" world"}}`) defer server.Close() diff --git a/templates/templates/csharp/features.ts b/templates/templates/csharp/features.ts index 30c74ebab..e9593f875 100644 --- a/templates/templates/csharp/features.ts +++ b/templates/templates/csharp/features.ts @@ -423,7 +423,6 @@ function isTestSkipped(test: string): boolean { "shared-enum-constructor-default", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", diff --git a/templates/templates/go/features.ts b/templates/templates/go/features.ts index 44b418463..a9f1ab6df 100644 --- a/templates/templates/go/features.ts +++ b/templates/templates/go/features.ts @@ -421,7 +421,6 @@ function isTestSkipped(test: string): boolean { "clierrors-unknown-flag-suggestion", "shared-enum-constructor-default", "cli-forbidden-without-credentials", - "event-stream-truncated-before-sentinel-is-connection-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", diff --git a/templates/templates/javav2/features.ts b/templates/templates/javav2/features.ts index 7110b2f56..716fb53b1 100644 --- a/templates/templates/javav2/features.ts +++ b/templates/templates/javav2/features.ts @@ -471,7 +471,6 @@ function isTestSkipped(test: string): boolean { "shared-enum-constructor-default", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", diff --git a/templates/templates/mcp-typescript/features.ts b/templates/templates/mcp-typescript/features.ts index 19b454bd8..063e78b66 100644 --- a/templates/templates/mcp-typescript/features.ts +++ b/templates/templates/mcp-typescript/features.ts @@ -680,7 +680,6 @@ function isTestSkipped(test: string): boolean { "shared-enum-constructor-default", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", diff --git a/templates/templates/php/features.ts b/templates/templates/php/features.ts index bdc784db5..96acfa19f 100644 --- a/templates/templates/php/features.ts +++ b/templates/templates/php/features.ts @@ -565,7 +565,6 @@ function isTestSkipped(test: string): boolean { "clierrors-unknown-flag-suggestion", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", diff --git a/templates/templates/pythonv2/features.ts b/templates/templates/pythonv2/features.ts index b2311576b..b78a31cd7 100644 --- a/templates/templates/pythonv2/features.ts +++ b/templates/templates/pythonv2/features.ts @@ -426,7 +426,6 @@ function isTestSkipped(test: string): boolean { "shared-enum-constructor-default", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", diff --git a/templates/templates/ruby/features.ts b/templates/templates/ruby/features.ts index 786dfd288..2c58fa093 100644 --- a/templates/templates/ruby/features.ts +++ b/templates/templates/ruby/features.ts @@ -468,7 +468,6 @@ function isTestSkipped(test: string): boolean { "shared-enum-constructor-default", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", diff --git a/templates/templates/typescriptv2/features.ts b/templates/templates/typescriptv2/features.ts index c164704a9..8f2987da6 100644 --- a/templates/templates/typescriptv2/features.ts +++ b/templates/templates/typescriptv2/features.ts @@ -416,7 +416,6 @@ function isTestSkipped(test: string): boolean { "shared-enum-constructor-default", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", diff --git a/templates/templates/unity/features.ts b/templates/templates/unity/features.ts index 1aaa6988c..cc82fdd3d 100644 --- a/templates/templates/unity/features.ts +++ b/templates/templates/unity/features.ts @@ -589,7 +589,6 @@ function isTestSkipped(test: string): boolean { "shared-enum-constructor-default", "cli-forbidden-without-credentials", "event-stream-truncated-before-sentinel", - "event-stream-truncated-before-sentinel-is-connection-error", "open-union-invalid-json-is-an-error", "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", From 97836edb1676a7a407dbac356220425e9b4f3d4d Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 14:01:45 -0400 Subject: [PATCH 05/10] test(cli): register test IDs for streamed-event wire rendering 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. --- internal/features/tests.go | 3 +++ internal/features/tests_generated.go | 15 +++++++++++++++ .../tests/primary/eventstreaming_test.go.stmpl | 8 ++++++++ templates/templates/csharp/features.ts | 3 +++ templates/templates/go/features.ts | 3 +++ templates/templates/javav2/features.ts | 3 +++ templates/templates/mcp-typescript/features.ts | 3 +++ templates/templates/php/features.ts | 3 +++ templates/templates/pythonv2/features.ts | 3 +++ templates/templates/ruby/features.ts | 3 +++ templates/templates/typescriptv2/features.ts | 3 +++ templates/templates/unity/features.ts | 3 +++ 12 files changed, 53 insertions(+) diff --git a/internal/features/tests.go b/internal/features/tests.go index dac8f0818..a4031fcb5 100644 --- a/internal/features/tests.go +++ b/internal/features/tests.go @@ -499,6 +499,9 @@ const ( TestEventStreamMalformedFrameStrict TestEventStreamMalformedFrameLenient TestEventStreamTruncatedBeforeSentinel + TestEventStreamAbsentRequiredUnion + TestEventStreamNullAndUndeclaredFields + TestEventStreamEquivalentNumberLexemes TestTestHooks TestTestHooksError TestTestHooksBeforeCreateRequest diff --git a/internal/features/tests_generated.go b/internal/features/tests_generated.go index 4d813dcbb..b6dd807de 100644 --- a/internal/features/tests_generated.go +++ b/internal/features/tests_generated.go @@ -258,6 +258,8 @@ func (t Test) String() string { return "errors-union-of-errors" case TestErrorsUnionOfErrorsDiscriminated: return "errors-union-of-errors-discriminated" + case TestEventStreamAbsentRequiredUnion: + return "event-stream-absent-required-union" case TestEventStreamChatHeartbeatSkipsDataless: return "event-stream-chat-heartbeat-skips-dataless" case TestEventStreamChatJSON: @@ -274,6 +276,8 @@ func (t Test) String() string { return "event-stream-decode-failure-without-json-error-type" case TestEventStreamDifferentDataSchemas: return "event-stream-different-data-schemas" + case TestEventStreamEquivalentNumberLexemes: + return "event-stream-equivalent-number-lexemes" case TestEventStreamErrorEventAgentMode: return "event-stream-error-event-agent-mode" case TestEventStreamErrorEventAgentModeFallback: @@ -296,6 +300,8 @@ func (t Test) String() string { return "event-stream-mixed-data" case TestEventStreamMultilineData: return "event-stream-multiline-data" + case TestEventStreamNullAndUndeclaredFields: + return "event-stream-null-and-undeclared-fields" case TestEventStreamNumbersKeepPrecision: return "event-stream-numbers-keep-precision" case TestEventStreamOptionalDataField: @@ -1772,6 +1778,8 @@ func TestFromString(s string) Test { return TestErrorsUnionOfErrors case "errors-union-of-errors-discriminated": return TestErrorsUnionOfErrorsDiscriminated + case "event-stream-absent-required-union": + return TestEventStreamAbsentRequiredUnion case "event-stream-chat-heartbeat-skips-dataless": return TestEventStreamChatHeartbeatSkipsDataless case "event-stream-chat-json": @@ -1788,6 +1796,8 @@ func TestFromString(s string) Test { return TestEventStreamDecodeFailureWithoutJsonErrorType case "event-stream-different-data-schemas": return TestEventStreamDifferentDataSchemas + case "event-stream-equivalent-number-lexemes": + return TestEventStreamEquivalentNumberLexemes case "event-stream-error-event-agent-mode": return TestEventStreamErrorEventAgentMode case "event-stream-error-event-agent-mode-fallback": @@ -1810,6 +1820,8 @@ func TestFromString(s string) Test { return TestEventStreamMixedData case "event-stream-multiline-data": return TestEventStreamMultilineData + case "event-stream-null-and-undeclared-fields": + return TestEventStreamNullAndUndeclaredFields case "event-stream-numbers-keep-precision": return TestEventStreamNumbersKeepPrecision case "event-stream-optional-data-field": @@ -3158,6 +3170,7 @@ var testList = []Test{ TestErrorsStatusGetErrorXSpeakeasyErrorsNone, TestErrorsUnionOfErrors, TestErrorsUnionOfErrorsDiscriminated, + TestEventStreamAbsentRequiredUnion, TestEventStreamChatHeartbeatSkipsDataless, TestEventStreamChatJSON, TestEventStreamChatSentinelEvent, @@ -3166,6 +3179,7 @@ var testList = []Test{ TestEventStreamDecodeFailureIsProtocolError, TestEventStreamDecodeFailureWithoutJsonErrorType, TestEventStreamDifferentDataSchemas, + TestEventStreamEquivalentNumberLexemes, TestEventStreamErrorEventAgentMode, TestEventStreamErrorEventAgentModeFallback, TestEventStreamErrorEventExitsNonZero, @@ -3177,6 +3191,7 @@ var testList = []Test{ TestEventStreamMalformedFrameStrict, TestEventStreamMixedData, TestEventStreamMultilineData, + TestEventStreamNullAndUndeclaredFields, TestEventStreamNumbersKeepPrecision, TestEventStreamOptionalDataField, TestEventStreamPartialWithComments, diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index 8f95154e3..956c08b81 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -604,6 +604,8 @@ func TestEventStreamNumbersKeepPrecision(t *testing.T) { // set), so every renderer works from the frame the server sent: the stream // continues and the event gets no invented `outcome`. func TestEventStreamAbsentRequiredUnionStreamsThrough(t *testing.T) { + recordTest("event-stream-absent-required-union") + frames := []string{ `{"event_type":"delta","text":"before"}`, `{"event_type":"tool","tool_id":"c1"}`, @@ -641,6 +643,8 @@ func TestEventStreamAbsentRequiredUnionStreamsThrough(t *testing.T) { // model's: an explicit null stays null rather than becoming a zero value, // and fields the event schema does not declare are kept. func TestEventStreamRendersWireValues(t *testing.T) { + recordTest("event-stream-null-and-undeclared-fields") + server := newSSEServer(t, `{"event_type":"tool","tool_id":"c1","outcome":null}`, `{"event_type":"delta","text":"x","extra":{"k":1}}`, @@ -661,6 +665,8 @@ func TestEventStreamRendersWireValues(t *testing.T) { // prints as the decoded model would, in every format; toon and yaml keep it a // native number rather than a quoted string. func TestEventStreamEquivalentNumberLexemesPrintCanonically(t *testing.T) { + recordTest("event-stream-equivalent-number-lexemes") + for format, want := range map[string][]string{ "json": {`"ratio":1,`, `"scale":1000,`, `"tiny":0.00001`}, "yaml": {"ratio: 1\n", "scale: 1000\n", "tiny: 1e-05\n"}, @@ -685,6 +691,8 @@ func TestEventStreamEquivalentNumberLexemesPrintCanonically(t *testing.T) { // server's lexeme: an int field sent as -0 prints 0, a double sent with // more digits than float64 holds prints its float64 value, natively in toon. func TestEventStreamDeclaredValuesPrintAsTheModelRendersThem(t *testing.T) { + recordTest("event-stream-equivalent-number-lexemes") + for format, want := range map[string][]string{ "json": {`"seq":0,`, `"weight":0.1}`}, "toon": {"seq: 0\n", "weight: 0.1\n"}, diff --git a/templates/templates/csharp/features.ts b/templates/templates/csharp/features.ts index e9593f875..8c3bd71eb 100644 --- a/templates/templates/csharp/features.ts +++ b/templates/templates/csharp/features.ts @@ -427,6 +427,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/go/features.ts b/templates/templates/go/features.ts index a9f1ab6df..bde8c068f 100644 --- a/templates/templates/go/features.ts +++ b/templates/templates/go/features.ts @@ -424,6 +424,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/javav2/features.ts b/templates/templates/javav2/features.ts index 716fb53b1..f9d5e49b3 100644 --- a/templates/templates/javav2/features.ts +++ b/templates/templates/javav2/features.ts @@ -475,6 +475,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/mcp-typescript/features.ts b/templates/templates/mcp-typescript/features.ts index 063e78b66..70a1d2124 100644 --- a/templates/templates/mcp-typescript/features.ts +++ b/templates/templates/mcp-typescript/features.ts @@ -684,6 +684,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/php/features.ts b/templates/templates/php/features.ts index 96acfa19f..0254cdade 100644 --- a/templates/templates/php/features.ts +++ b/templates/templates/php/features.ts @@ -569,6 +569,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/pythonv2/features.ts b/templates/templates/pythonv2/features.ts index b78a31cd7..921723aae 100644 --- a/templates/templates/pythonv2/features.ts +++ b/templates/templates/pythonv2/features.ts @@ -430,6 +430,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/ruby/features.ts b/templates/templates/ruby/features.ts index 2c58fa093..3435fe0de 100644 --- a/templates/templates/ruby/features.ts +++ b/templates/templates/ruby/features.ts @@ -472,6 +472,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/typescriptv2/features.ts b/templates/templates/typescriptv2/features.ts index 8f2987da6..b052b86a0 100644 --- a/templates/templates/typescriptv2/features.ts +++ b/templates/templates/typescriptv2/features.ts @@ -420,6 +420,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } diff --git a/templates/templates/unity/features.ts b/templates/templates/unity/features.ts index cc82fdd3d..e69b828ac 100644 --- a/templates/templates/unity/features.ts +++ b/templates/templates/unity/features.ts @@ -593,6 +593,9 @@ function isTestSkipped(test: string): boolean { "cli-exit-codes-invalid-jq-before-request", "clierrors-dry-run-hint-server-validation-only", "clierrors-unknown-command-suggestion", + "event-stream-absent-required-union", + "event-stream-equivalent-number-lexemes", + "event-stream-null-and-undeclared-fields", ].includes(test); } From e85d226844f9b7fd4bcb192a8ea4d5604a4f7ab8 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 14:12:50 -0400 Subject: [PATCH 06/10] chore(go,cli): trim narrative comments in streamed-event rendering 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. --- .../internal/output/streaming.go.stmpl | 28 ++----------------- .../primary/eventstreaming_test.go.stmpl | 17 +---------- .../stream/stream.go.stmpl | 8 ++---- zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +-- zSDKs/sdk-cli/internal/output/streaming.go | 28 ++----------------- .../internal/sdk/types/stream/stream.go | 8 ++---- zSDKs/sdk-go/.speakeasy/gen.lock | 2 +- zSDKs/sdk-go/types/stream/stream.go | 8 ++---- 8 files changed, 16 insertions(+), 87 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl index a6e36ac56..fe6c471a5 100644 --- a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl @@ -178,8 +178,6 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e return nil } -// wireEvent is a streamed event kept as JSON. Renderers treat it like any -// model with a custom MarshalJSON. type wireEvent struct { data json.RawMessage } @@ -188,12 +186,7 @@ func (w wireEvent) MarshalJSON() ([]byte, error) { return w.data, nil } -// wireStreamItem rebuilds a streamed event from the frame the server sent, -// keeping the envelope fields the event type declares. Re-encoding the -// decoded model alone drops fields the model does not declare, turns an -// absent or null required field into its zero value, and fails outright on -// an absent required union. Each value the server sent still prints as the -// decoded model renders it wherever the model has one (see typedLeaves). +// Re-encoding the decoded model drops undeclared fields and zero-fills absent ones. func wireStreamItem(item interface{}, raw []byte) interface{} { keys, ok := envelopeKeys(reflect.ValueOf(item)) if len(raw) == 0 || !ok { @@ -225,11 +218,7 @@ func wireStreamItem(item interface{}, raw []byte) interface{} { return wireEvent{data: data} } -// envelopeKeys returns the JSON names of the envelope fields the event v -// declares, or nil when every frame field belongs to it: a union event whose -// frame matched no member keeps the whole frame, as its decoded form does. -// For a union event the envelope is the member that decoded. ok is false -// when v is not a struct event. +// nil keys keep the whole frame: a union event that matched no member. func envelopeKeys(v reflect.Value) (map[string]bool, bool) { v = derefValue(v) if !v.IsValid() || v.Kind() != reflect.Struct { @@ -272,12 +261,6 @@ func decodeWithNumbers(data []byte) (interface{}, error) { return parsed, err } -// typedLeaves walks the values the server sent (wire) alongside the decoded -// model's rendering of the same event (typed, nil when the model cannot be -// encoded) and returns wire with each scalar replaced by the typed scalar at -// the same place, so numbers, dates and other formatted values print as -// before. Nulls and values the model lacks (undeclared fields, events it -// cannot encode) keep the server's value, numbers in their shortest form. func typedLeaves(wire, typed interface{}) interface{} { switch w := wire.(type) { case nil: @@ -309,9 +292,7 @@ func typedLeaves(wire, typed interface{}) interface{} { return typed } -// canonicalNumber rewrites a lexeme the model does not render ("1.0", "1e3") -// to its shortest form ("1", "1000") when float64 holds the same value. A -// lexeme float64 would change (bigint, long decimals) is kept as sent. +// Bigint and long-decimal lexemes are kept: float64 would change their value. func canonicalNumber(n json.Number) interface{} { exact, ok := new(big.Rat).SetString(n.String()) if !ok { @@ -331,9 +312,6 @@ func canonicalNumber(n json.Number) interface{} { return json.Number(shortest) } -// streamErrorEvent reports whether item is an in-band error event and returns -// its body. A decoded error that cannot be re-encoded falls back to the data -// the server sent, so the event still fails the stream. func streamErrorEvent(item interface{}, raw []byte) (json.RawMessage, bool) { u, ok := errorUnionValue(reflect.ValueOf(item)) if !ok { diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index 956c08b81..e5078085d 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -105,8 +105,6 @@ func TestEventStreamRichEvents(t *testing.T) { events := parseNDJSON(t, h.GetStdout()) require.Equal(t, 3, len(events), "expected 3 rich events") - // The stream element is a union of envelopes: each event keeps the - // envelope fields of the member it decoded to. assert.Equal(t, map[string]interface{}{ "id": "job-1", "event": "completion", @@ -599,10 +597,6 @@ func TestEventStreamNumbersKeepPrecision(t *testing.T) { } } -// A tool call announced before its outcome is known omits the required -// `outcome` union. The decoded event cannot be re-encoded (no union member is -// set), so every renderer works from the frame the server sent: the stream -// continues and the event gets no invented `outcome`. func TestEventStreamAbsentRequiredUnionStreamsThrough(t *testing.T) { recordTest("event-stream-absent-required-union") @@ -639,10 +633,7 @@ func TestEventStreamAbsentRequiredUnionStreamsThrough(t *testing.T) { }) } -// Streamed events render the values the server sent, not the decoded -// model's: an explicit null stays null rather than becoming a zero value, -// and fields the event schema does not declare are kept. -func TestEventStreamRendersWireValues(t *testing.T) { +func TestEventStreamKeepsNullAndUndeclaredFields(t *testing.T) { recordTest("event-stream-null-and-undeclared-fields") server := newSSEServer(t, @@ -661,9 +652,6 @@ func TestEventStreamRendersWireValues(t *testing.T) { assert.Equal(t, map[string]any{"event_type": "delta", "text": "x", "extra": map[string]any{"k": float64(1)}}, events[1]["data"]) } -// A number lexeme float64 holds without change ("1.0", "1e3", "1e-05") -// prints as the decoded model would, in every format; toon and yaml keep it a -// native number rather than a quoted string. func TestEventStreamEquivalentNumberLexemesPrintCanonically(t *testing.T) { recordTest("event-stream-equivalent-number-lexemes") @@ -687,9 +675,6 @@ func TestEventStreamEquivalentNumberLexemesPrintCanonically(t *testing.T) { } } -// A value the model declares prints as the model renders it, whatever the -// server's lexeme: an int field sent as -0 prints 0, a double sent with -// more digits than float64 holds prints its float64 value, natively in toon. func TestEventStreamDeclaredValuesPrintAsTheModelRendersThem(t *testing.T) { recordTest("event-stream-equivalent-number-lexemes") diff --git a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl index 3b64558ed..932155536 100644 --- a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl +++ b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl @@ -341,12 +341,8 @@ func (es *EventStream[T]) Value() *T { return es.val } -// RawValue returns the most recent event as it was handed to the -// unmarshaller: a JSON object with the SSE fields event, data and retry of -// the frame, and id, the last event ID seen on the stream. data is the JSON -// the server sent, or a JSON string for text data. Unlike re-encoding Value, -// it keeps fields the model does not declare and never adds values the -// server did not send. +// RawValue returns the most recent event as handed to the unmarshaller: the +// frame's event, data and retry, plus the last event id seen, as JSON. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 1c72cfdda..3e8f916d5 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -294,7 +294,7 @@ trackedFiles: internal/output/progress.go: last_write_checksum: sha1:f8b839f9394602fe9c30feb3d7155af6235663be internal/output/streaming.go: - last_write_checksum: sha1:ab67ecb23c2fc97c7920379bc48a6f2512bccfc8 + last_write_checksum: sha1:84f5b7c32e7a20132f47eef1134cb55d3bcadb8e internal/output/streamproject.go: last_write_checksum: sha1:19d7dec64b1f78cbcfb5420cc4e968aa7aedadd0 internal/sdk/.gitattributes: @@ -652,7 +652,7 @@ trackedFiles: internal/sdk/types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef internal/sdk/types/stream/stream.go: - last_write_checksum: sha1:6271a5222961fe20d8e0f9a984a395786421f438 + last_write_checksum: sha1:c6b15858af3b912233c0c942d8a38cfd14a608ef internal/testclient/testclient.go: last_write_checksum: sha1:7107e59d336235c44af152c5a61ee3896a7841fa internal/usage/bodyschemas.go: diff --git a/zSDKs/sdk-cli/internal/output/streaming.go b/zSDKs/sdk-cli/internal/output/streaming.go index fc15767db..617117460 100644 --- a/zSDKs/sdk-cli/internal/output/streaming.go +++ b/zSDKs/sdk-cli/internal/output/streaming.go @@ -179,8 +179,6 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e return nil } -// wireEvent is a streamed event kept as JSON. Renderers treat it like any -// model with a custom MarshalJSON. type wireEvent struct { data json.RawMessage } @@ -189,12 +187,7 @@ func (w wireEvent) MarshalJSON() ([]byte, error) { return w.data, nil } -// wireStreamItem rebuilds a streamed event from the frame the server sent, -// keeping the envelope fields the event type declares. Re-encoding the -// decoded model alone drops fields the model does not declare, turns an -// absent or null required field into its zero value, and fails outright on -// an absent required union. Each value the server sent still prints as the -// decoded model renders it wherever the model has one (see typedLeaves). +// Re-encoding the decoded model drops undeclared fields and zero-fills absent ones. func wireStreamItem(item interface{}, raw []byte) interface{} { keys, ok := envelopeKeys(reflect.ValueOf(item)) if len(raw) == 0 || !ok { @@ -226,11 +219,7 @@ func wireStreamItem(item interface{}, raw []byte) interface{} { return wireEvent{data: data} } -// envelopeKeys returns the JSON names of the envelope fields the event v -// declares, or nil when every frame field belongs to it: a union event whose -// frame matched no member keeps the whole frame, as its decoded form does. -// For a union event the envelope is the member that decoded. ok is false -// when v is not a struct event. +// nil keys keep the whole frame: a union event that matched no member. func envelopeKeys(v reflect.Value) (map[string]bool, bool) { v = derefValue(v) if !v.IsValid() || v.Kind() != reflect.Struct { @@ -273,12 +262,6 @@ func decodeWithNumbers(data []byte) (interface{}, error) { return parsed, err } -// typedLeaves walks the values the server sent (wire) alongside the decoded -// model's rendering of the same event (typed, nil when the model cannot be -// encoded) and returns wire with each scalar replaced by the typed scalar at -// the same place, so numbers, dates and other formatted values print as -// before. Nulls and values the model lacks (undeclared fields, events it -// cannot encode) keep the server's value, numbers in their shortest form. func typedLeaves(wire, typed interface{}) interface{} { switch w := wire.(type) { case nil: @@ -310,9 +293,7 @@ func typedLeaves(wire, typed interface{}) interface{} { return typed } -// canonicalNumber rewrites a lexeme the model does not render ("1.0", "1e3") -// to its shortest form ("1", "1000") when float64 holds the same value. A -// lexeme float64 would change (bigint, long decimals) is kept as sent. +// Bigint and long-decimal lexemes are kept: float64 would change their value. func canonicalNumber(n json.Number) interface{} { exact, ok := new(big.Rat).SetString(n.String()) if !ok { @@ -332,9 +313,6 @@ func canonicalNumber(n json.Number) interface{} { return json.Number(shortest) } -// streamErrorEvent reports whether item is an in-band error event and returns -// its body. A decoded error that cannot be re-encoded falls back to the data -// the server sent, so the event still fails the stream. func streamErrorEvent(item interface{}, raw []byte) (json.RawMessage, bool) { u, ok := errorUnionValue(reflect.ValueOf(item)) if !ok { diff --git a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go index 092f52ec2..c996f13ca 100644 --- a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go +++ b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go @@ -342,12 +342,8 @@ func (es *EventStream[T]) Value() *T { return es.val } -// RawValue returns the most recent event as it was handed to the -// unmarshaller: a JSON object with the SSE fields event, data and retry of -// the frame, and id, the last event ID seen on the stream. data is the JSON -// the server sent, or a JSON string for text data. Unlike re-encoding Value, -// it keeps fields the model does not declare and never adds values the -// server did not send. +// RawValue returns the most recent event as handed to the unmarshaller: the +// frame's event, data and retry, plus the last event id seen, as JSON. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } diff --git a/zSDKs/sdk-go/.speakeasy/gen.lock b/zSDKs/sdk-go/.speakeasy/gen.lock index dffc903fc..4de49fb10 100644 --- a/zSDKs/sdk-go/.speakeasy/gen.lock +++ b/zSDKs/sdk-go/.speakeasy/gen.lock @@ -1289,7 +1289,7 @@ trackedFiles: types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef types/stream/stream.go: - last_write_checksum: sha1:6271a5222961fe20d8e0f9a984a395786421f438 + last_write_checksum: sha1:c6b15858af3b912233c0c942d8a38cfd14a608ef updateuserop.go: last_write_checksum: sha1:32b193694bf28ab709ef1bdda9ff8a2d633c831f urlvalidationstresstestop.go: diff --git a/zSDKs/sdk-go/types/stream/stream.go b/zSDKs/sdk-go/types/stream/stream.go index 092f52ec2..c996f13ca 100644 --- a/zSDKs/sdk-go/types/stream/stream.go +++ b/zSDKs/sdk-go/types/stream/stream.go @@ -342,12 +342,8 @@ func (es *EventStream[T]) Value() *T { return es.val } -// RawValue returns the most recent event as it was handed to the -// unmarshaller: a JSON object with the SSE fields event, data and retry of -// the frame, and id, the last event ID seen on the stream. data is the JSON -// the server sent, or a JSON string for text data. Unlike re-encoding Value, -// it keeps fields the model does not declare and never adds values the -// server did not send. +// RawValue returns the most recent event as handed to the unmarshaller: the +// frame's event, data and retry, plus the last event id seen, as JSON. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } From af51f673ba4cca8a58d3bd924c19530b8aa7eb25 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 14:50:34 -0400 Subject: [PATCH 07/10] fix(cli): keep table layout and unwrap nested envelope unions in streams - 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. --- .../auxiliary/internal/output/output.go.stmpl | 14 ++- .../internal/output/streaming.go.stmpl | 47 ++++------ .../internal/output/streaming_test.go.stmpl | 88 +++++++++++++++++++ .../primary/eventstreaming_test.go.stmpl | 38 +++++++- tests/overlays/primary/cli/overlay.yaml | 41 +++++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 6 +- zSDKs/sdk-cli/internal/output/output.go | 14 ++- zSDKs/sdk-cli/internal/output/streaming.go | 47 ++++------ .../sdk-cli/internal/output/streaming_test.go | 88 +++++++++++++++++++ 9 files changed, 312 insertions(+), 71 deletions(-) create mode 100644 templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl create mode 100644 zSDKs/sdk-cli/internal/output/streaming_test.go diff --git a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl index fb8380040..8cc7561be 100644 --- a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl @@ -159,15 +159,21 @@ func normalizeForOutput(content interface{}) (interface{}, error) { if err != nil { return nil, err } - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - var parsed interface{} - if err := dec.Decode(&parsed); err != nil { + parsed, err := decodeWithNumbers(data) + if err != nil { return nil, fmt.Errorf("failed to decode marshalled response: %w", err) } return parsed, nil } +func decodeWithNumbers(data []byte) (interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var parsed interface{} + err := dec.Decode(&parsed) + return parsed, err +} + func convertNumbers(v interface{}, conv func(json.Number) interface{}) interface{} { switch val := v.(type) { case json.Number: diff --git a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl index fe6c471a5..8c730bd0a 100644 --- a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl @@ -4,7 +4,6 @@ package output import ( - "bytes" "encoding/json" "errors" "math/big" @@ -145,7 +144,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e } if raw != nil { - item = wireStreamItem(item, raw) + item = wireStreamItem(item, raw, format == "table" && jqExpr == "") } if projector != nil { @@ -187,29 +186,25 @@ func (w wireEvent) MarshalJSON() ([]byte, error) { } // Re-encoding the decoded model drops undeclared fields and zero-fills absent ones. -func wireStreamItem(item interface{}, raw []byte) interface{} { +func wireStreamItem(item interface{}, raw []byte, table bool) interface{} { keys, ok := envelopeKeys(reflect.ValueOf(item)) if len(raw) == 0 || !ok { return item } - var frame map[string]json.RawMessage - if err := json.Unmarshal(raw, &frame); err != nil { + typed, err := normalizeForOutput(item) + // Tables lay out the model's fields and sections, which the wire map loses. + if err == nil && table { + return item + } + frame, err := decodeWithNumbers(raw) + wire, isObject := frame.(map[string]interface{}) + if err != nil || !isObject { return item } - wire := make(map[string]interface{}, len(frame)) - for name, value := range frame { + for name := range wire { if keys != nil && !keys[name] { - continue - } - parsed, err := decodeWithNumbers(value) - if err != nil { - return item + delete(wire, name) } - wire[name] = parsed - } - var typed interface{} - if data, err := marshalJSON(item); err == nil { - typed, _ = decodeWithNumbers(data) } data, err := json.Marshal(typedLeaves(wire, typed)) if err != nil { @@ -224,11 +219,10 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { if !v.IsValid() || v.Kind() != reflect.Struct { return nil, false } - t := v.Type() - if isUnionStruct(t) { + for isUnionStruct(v.Type()) { member := reflect.Value{} - for i := 0; i < t.NumField(); i++ { - if t.Field(i).Tag.Get("union") == "member" && !v.Field(i).IsZero() { + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).Tag.Get("union") == "member" && !v.Field(i).IsZero() { member = derefValue(v.Field(i)) break } @@ -236,8 +230,9 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { if !member.IsValid() || member.Kind() != reflect.Struct { return nil, true } - t = member.Type() + v = member } + t := v.Type() keys := map[string]bool{} for i := 0; i < t.NumField(); i++ { f := t.Field(i) @@ -253,14 +248,6 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { return keys, true } -func decodeWithNumbers(data []byte) (interface{}, error) { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - var parsed interface{} - err := dec.Decode(&parsed) - return parsed, err -} - func typedLeaves(wire, typed interface{}) interface{} { switch w := wire.(type) { case nil: diff --git a/templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl new file mode 100644 index 000000000..ac08f9401 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl @@ -0,0 +1,88 @@ +{{- if anyOperationHasStreaming}} +package output + +import ( + "bytes" + "encoding/json" + "errors" + "testing" +) + +type nestedEnvelope struct { + Data string `json:"data"` + Event string `json:"event"` +} + +type innerEnvelopeUnion struct { + Envelope *nestedEnvelope `union:"member"` + Type string +} + +func (u innerEnvelopeUnion) MarshalJSON() ([]byte, error) { + return json.Marshal(u.Envelope) +} + +type outerEnvelopeUnion struct { + Inner *innerEnvelopeUnion `union:"member"` + Type string +} + +func (u outerEnvelopeUnion) MarshalJSON() ([]byte, error) { + return json.Marshal(u.Inner) +} + +func TestWireStreamItemUnwrapsNestedEnvelopeUnions(t *testing.T) { + raw := []byte(`{"data":"hello","event":"tick","id":"7"}`) + for name, tc := range map[string]struct { + item interface{} + want string + }{ + "selected envelope": {outerEnvelopeUnion{Inner: &innerEnvelopeUnion{Envelope: &nestedEnvelope{Data: "hello", Event: "tick"}}}, `{"data":"hello","event":"tick"}`}, + "inner member unknown": {outerEnvelopeUnion{Inner: &innerEnvelopeUnion{}}, `{"data":"hello","event":"tick","id":"7"}`}, + } { + t.Run(name, func(t *testing.T) { + got, err := json.Marshal(wireStreamItem(tc.item, raw, false)) + if err != nil || string(got) != tc.want { + t.Fatalf("got %s, error %v; want %s", got, err, tc.want) + } + }) + } +} + +type tableEnvelopeRow struct { + Name string `json:"name"` + Age int `json:"age"` +} + +type tableEnvelope struct { + ID string `json:"id"` + Data []tableEnvelopeRow `json:"data"` +} + +type unencodableEnvelope struct { + ID string `json:"id"` +} + +func (unencodableEnvelope) MarshalJSON() ([]byte, error) { + return nil, errors.New("required union has no member") +} + +func TestWireStreamItemTableKeepsModelLayout(t *testing.T) { + for name, tc := range map[string]struct { + item interface{} + raw string + want string + }{ + "rows": {tableEnvelope{ID: "a", Data: []tableEnvelopeRow{ {Name: "x", Age: 3} }}, `{"id":"a","data":[{"name":"x","age":3}]}`, "FIELD VALUE\nid a\n\nDATA\nNAME AGE\nx 3\n"}, + "empty rows": {tableEnvelope{ID: "a", Data: []tableEnvelopeRow{}}, `{"id":"a","data":[]}`, "FIELD VALUE\nid a\n\nDATA\n(empty)\n"}, + "unencodable": {unencodableEnvelope{ID: "a"}, `{"id":"a","extra":1}`, "FIELD VALUE\nid a\n"}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + if err := printTable(&buf, wireStreamItem(tc.item, []byte(tc.raw), true)); err != nil || buf.String() != tc.want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, tc.want) + } + }) + } +} +{{- end}} diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index e5078085d..447806b14 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -639,6 +639,8 @@ func TestEventStreamKeepsNullAndUndeclaredFields(t *testing.T) { server := newSSEServer(t, `{"event_type":"tool","tool_id":"c1","outcome":null}`, `{"event_type":"delta","text":"x","extra":{"k":1}}`, + `{"event_type":"delta","text":null}`, + `{"event_type":"delta"}`, ) defer server.Close() @@ -647,9 +649,43 @@ func TestEventStreamKeepsNullAndUndeclaredFields(t *testing.T) { require.NoError(t, err, h.GetStderr()) events := parseNDJSON(t, h.GetStdout()) - require.Len(t, events, 2) + require.Len(t, events, 4) assert.Equal(t, map[string]any{"event_type": "tool", "tool_id": "c1", "outcome": nil}, events[0]["data"]) assert.Equal(t, map[string]any{"event_type": "delta", "text": "x", "extra": map[string]any{"k": float64(1)}}, events[1]["data"]) + assert.Equal(t, map[string]any{"event_type": "delta", "text": nil}, events[2]["data"]) + assert.Equal(t, map[string]any{"event_type": "delta"}, events[3]["data"]) +} + +func TestEventStreamErrorEventWithoutRequiredUnionFails(t *testing.T) { + server := newSSEServer(t, + `{"event_type":"delta","text":"before"}`, + `{"event_type":"tool_error","tool_id":"c1","error":{"code":"TOOL_FAILED","message":"tool crashed"}}`, + `{"event_type":"delta","text":"after"}`, + ) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events"}) + require.Error(t, err) + + events := parseNDJSON(t, h.GetStdout()) + require.Len(t, events, 1, h.GetStdout()) + assert.Equal(t, map[string]any{"event_type": "delta", "text": "before"}, events[0]["data"]) + assert.Contains(t, h.GetStderr(), "tool crashed") +} + +func TestEventStreamProjectionStreamsThroughAbsentRequiredUnion(t *testing.T) { + server := newSSEServer(t, + `{"event_type":"delta","text":"Hello, "}`, + `{"event_type":"tool","tool_id":"c1"}`, + `{"event_type":"delta","text":"world"}`, + ) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunRaw([]string{"eventstreams", "discriminated-projected-events", "--server-url", server.URL}) + require.NoError(t, err, h.GetStderr()) + assert.Equal(t, "Hello, world\n", h.GetStdout()) } func TestEventStreamEquivalentNumberLexemesPrintCanonically(t *testing.T) { diff --git a/tests/overlays/primary/cli/overlay.yaml b/tests/overlays/primary/cli/overlay.yaml index ce63d0113..33b664455 100644 --- a/tests/overlays/primary/cli/overlay.yaml +++ b/tests/overlays/primary/cli/overlay.yaml @@ -88,6 +88,10 @@ actions: categories: - Create operations: + discriminatedProjectedEvents: + output: + stream: + select: $.data.text streamMetadata: output: stream: @@ -320,6 +324,21 @@ actions: text/event-stream: schema: $ref: '#/components/schemas/discriminatedStreamEvent' + /eventstreams/discriminated-projected: + post: + tags: + - eventstreams + operationId: discriminatedProjectedEvents + description: SSE stream of discriminated events projected to their text. + servers: + - url: http://localhost:35456 + responses: + '200': + description: Ok + content: + text/event-stream: + schema: + $ref: '#/components/schemas/discriminatedStreamEvent' /eventstreams/undiscriminated: post: tags: @@ -376,12 +395,14 @@ actions: - $ref: '#/components/schemas/discriminatedDeltaEvent' - $ref: '#/components/schemas/discriminatedErrorEvent' - $ref: '#/components/schemas/discriminatedToolEvent' + - $ref: '#/components/schemas/discriminatedToolErrorEvent' discriminator: propertyName: event_type mapping: delta: '#/components/schemas/discriminatedDeltaEvent' error: '#/components/schemas/discriminatedErrorEvent' tool: '#/components/schemas/discriminatedToolEvent' + tool_error: '#/components/schemas/discriminatedToolErrorEvent' discriminatedDeltaEvent: type: object required: @@ -440,6 +461,26 @@ actions: - type: string - type: object additionalProperties: true + discriminatedToolErrorEvent: + description: A failed tool call whose required outcome is absent on the wire, so the decoded event cannot be re-encoded + type: object + required: + - event_type + - tool_id + - outcome + properties: + event_type: + type: string + const: tool_error + tool_id: + type: string + outcome: + oneOf: + - type: string + - type: object + additionalProperties: true + error: + $ref: '#/components/schemas/discriminatedStreamError' discriminatedStreamError: type: object required: diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 3e8f916d5..c42f548f2 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -282,7 +282,7 @@ trackedFiles: internal/output/jq.go: last_write_checksum: sha1:9e59e92439a8c6269a76be1fa44e85cbbf2dee59 internal/output/output.go: - last_write_checksum: sha1:da32879cb0348808a9caa693777d9089e3ccb9bb + last_write_checksum: sha1:b75c9ee7521310aeb70a561d5cfc98bd6d3e9da6 internal/output/output_test.go: last_write_checksum: sha1:fc3d380351e2c9e5ffb2bc663e948ab37755e167 internal/output/outputitems.go: @@ -294,7 +294,9 @@ trackedFiles: internal/output/progress.go: last_write_checksum: sha1:f8b839f9394602fe9c30feb3d7155af6235663be internal/output/streaming.go: - last_write_checksum: sha1:84f5b7c32e7a20132f47eef1134cb55d3bcadb8e + last_write_checksum: sha1:0854ccc844fc9e2b41c637fc738e6506791c5010 + internal/output/streaming_test.go: + last_write_checksum: sha1:713732201548ae10379a4baf6b21436bf7419385 internal/output/streamproject.go: last_write_checksum: sha1:19d7dec64b1f78cbcfb5420cc4e968aa7aedadd0 internal/sdk/.gitattributes: diff --git a/zSDKs/sdk-cli/internal/output/output.go b/zSDKs/sdk-cli/internal/output/output.go index 3b75b2a5d..678c611f3 100644 --- a/zSDKs/sdk-cli/internal/output/output.go +++ b/zSDKs/sdk-cli/internal/output/output.go @@ -159,15 +159,21 @@ func normalizeForOutput(content interface{}) (interface{}, error) { if err != nil { return nil, err } - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - var parsed interface{} - if err := dec.Decode(&parsed); err != nil { + parsed, err := decodeWithNumbers(data) + if err != nil { return nil, fmt.Errorf("failed to decode marshalled response: %w", err) } return parsed, nil } +func decodeWithNumbers(data []byte) (interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var parsed interface{} + err := dec.Decode(&parsed) + return parsed, err +} + func convertNumbers(v interface{}, conv func(json.Number) interface{}) interface{} { switch val := v.(type) { case json.Number: diff --git a/zSDKs/sdk-cli/internal/output/streaming.go b/zSDKs/sdk-cli/internal/output/streaming.go index 617117460..36ee0b148 100644 --- a/zSDKs/sdk-cli/internal/output/streaming.go +++ b/zSDKs/sdk-cli/internal/output/streaming.go @@ -5,7 +5,6 @@ package output import ( - "bytes" "encoding/json" "errors" "math/big" @@ -146,7 +145,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e } if raw != nil { - item = wireStreamItem(item, raw) + item = wireStreamItem(item, raw, format == "table" && jqExpr == "") } if projector != nil { @@ -188,29 +187,25 @@ func (w wireEvent) MarshalJSON() ([]byte, error) { } // Re-encoding the decoded model drops undeclared fields and zero-fills absent ones. -func wireStreamItem(item interface{}, raw []byte) interface{} { +func wireStreamItem(item interface{}, raw []byte, table bool) interface{} { keys, ok := envelopeKeys(reflect.ValueOf(item)) if len(raw) == 0 || !ok { return item } - var frame map[string]json.RawMessage - if err := json.Unmarshal(raw, &frame); err != nil { + typed, err := normalizeForOutput(item) + // Tables lay out the model's fields and sections, which the wire map loses. + if err == nil && table { + return item + } + frame, err := decodeWithNumbers(raw) + wire, isObject := frame.(map[string]interface{}) + if err != nil || !isObject { return item } - wire := make(map[string]interface{}, len(frame)) - for name, value := range frame { + for name := range wire { if keys != nil && !keys[name] { - continue - } - parsed, err := decodeWithNumbers(value) - if err != nil { - return item + delete(wire, name) } - wire[name] = parsed - } - var typed interface{} - if data, err := marshalJSON(item); err == nil { - typed, _ = decodeWithNumbers(data) } data, err := json.Marshal(typedLeaves(wire, typed)) if err != nil { @@ -225,11 +220,10 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { if !v.IsValid() || v.Kind() != reflect.Struct { return nil, false } - t := v.Type() - if isUnionStruct(t) { + for isUnionStruct(v.Type()) { member := reflect.Value{} - for i := 0; i < t.NumField(); i++ { - if t.Field(i).Tag.Get("union") == "member" && !v.Field(i).IsZero() { + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).Tag.Get("union") == "member" && !v.Field(i).IsZero() { member = derefValue(v.Field(i)) break } @@ -237,8 +231,9 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { if !member.IsValid() || member.Kind() != reflect.Struct { return nil, true } - t = member.Type() + v = member } + t := v.Type() keys := map[string]bool{} for i := 0; i < t.NumField(); i++ { f := t.Field(i) @@ -254,14 +249,6 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { return keys, true } -func decodeWithNumbers(data []byte) (interface{}, error) { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - var parsed interface{} - err := dec.Decode(&parsed) - return parsed, err -} - func typedLeaves(wire, typed interface{}) interface{} { switch w := wire.(type) { case nil: diff --git a/zSDKs/sdk-cli/internal/output/streaming_test.go b/zSDKs/sdk-cli/internal/output/streaming_test.go new file mode 100644 index 000000000..d997e095e --- /dev/null +++ b/zSDKs/sdk-cli/internal/output/streaming_test.go @@ -0,0 +1,88 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package output + +import ( + "bytes" + "encoding/json" + "errors" + "testing" +) + +type nestedEnvelope struct { + Data string `json:"data"` + Event string `json:"event"` +} + +type innerEnvelopeUnion struct { + Envelope *nestedEnvelope `union:"member"` + Type string +} + +func (u innerEnvelopeUnion) MarshalJSON() ([]byte, error) { + return json.Marshal(u.Envelope) +} + +type outerEnvelopeUnion struct { + Inner *innerEnvelopeUnion `union:"member"` + Type string +} + +func (u outerEnvelopeUnion) MarshalJSON() ([]byte, error) { + return json.Marshal(u.Inner) +} + +func TestWireStreamItemUnwrapsNestedEnvelopeUnions(t *testing.T) { + raw := []byte(`{"data":"hello","event":"tick","id":"7"}`) + for name, tc := range map[string]struct { + item interface{} + want string + }{ + "selected envelope": {outerEnvelopeUnion{Inner: &innerEnvelopeUnion{Envelope: &nestedEnvelope{Data: "hello", Event: "tick"}}}, `{"data":"hello","event":"tick"}`}, + "inner member unknown": {outerEnvelopeUnion{Inner: &innerEnvelopeUnion{}}, `{"data":"hello","event":"tick","id":"7"}`}, + } { + t.Run(name, func(t *testing.T) { + got, err := json.Marshal(wireStreamItem(tc.item, raw, false)) + if err != nil || string(got) != tc.want { + t.Fatalf("got %s, error %v; want %s", got, err, tc.want) + } + }) + } +} + +type tableEnvelopeRow struct { + Name string `json:"name"` + Age int `json:"age"` +} + +type tableEnvelope struct { + ID string `json:"id"` + Data []tableEnvelopeRow `json:"data"` +} + +type unencodableEnvelope struct { + ID string `json:"id"` +} + +func (unencodableEnvelope) MarshalJSON() ([]byte, error) { + return nil, errors.New("required union has no member") +} + +func TestWireStreamItemTableKeepsModelLayout(t *testing.T) { + for name, tc := range map[string]struct { + item interface{} + raw string + want string + }{ + "rows": {tableEnvelope{ID: "a", Data: []tableEnvelopeRow{{Name: "x", Age: 3}}}, `{"id":"a","data":[{"name":"x","age":3}]}`, "FIELD VALUE\nid a\n\nDATA\nNAME AGE\nx 3\n"}, + "empty rows": {tableEnvelope{ID: "a", Data: []tableEnvelopeRow{}}, `{"id":"a","data":[]}`, "FIELD VALUE\nid a\n\nDATA\n(empty)\n"}, + "unencodable": {unencodableEnvelope{ID: "a"}, `{"id":"a","extra":1}`, "FIELD VALUE\nid a\n"}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + if err := printTable(&buf, wireStreamItem(tc.item, []byte(tc.raw), true)); err != nil || buf.String() != tc.want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, tc.want) + } + }) + } +} From 5765af73020371177378c595f8d635d0738f1cb2 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 18:01:53 -0400 Subject: [PATCH 08/10] chore(cli): register streaming_test.go.stmpl in the template permissions table --- templates/perms.go | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/perms.go b/templates/perms.go index 1de310b35..47a7cc493 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -65,6 +65,7 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/auxiliary/internal/output/pretty.go.stmpl": 0o644, "templates/cli/auxiliary/internal/output/progress.go.stmpl": 0o644, "templates/cli/auxiliary/internal/output/streaming.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/streaming_test.go.stmpl": 0o644, "templates/cli/auxiliary/internal/output/streamproject.go.stmpl": 0o644, "templates/cli/auxiliary/internal/testclient": 0o20000000755, "templates/cli/auxiliary/internal/testclient/testclient.go.stmpl": 0o644, From 344059ee2bd3ced76ae7c105bfb7a3485aa39c1f Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 16 Oct 2026 18:16:03 -0400 Subject: [PATCH 09/10] test(cli): check the third rich event's content --- .../templates/cli/tests/primary/eventstreaming_test.go.stmpl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl index 447806b14..57505c299 100644 --- a/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl +++ b/templates/templates/cli/tests/primary/eventstreaming_test.go.stmpl @@ -111,6 +111,11 @@ func TestEventStreamRichEvents(t *testing.T) { "data": map[string]interface{}{"completion": "Hello", "stop_reason": nil, "model": "jeeves-1"}, }, events[0]) assert.Equal(t, map[string]interface{}{"event": "heartbeat", "data": "ping", "retry": float64(3000)}, events[1]) + assert.Equal(t, map[string]interface{}{ + "id": "job-1", + "event": "completion", + "data": map[string]interface{}{"completion": "world!", "stop_reason": "stop_sequence", "model": "jeeves-1"}, + }, events[2]) } func TestEventStreamChatSentinel(t *testing.T) { From e1d792dc8fd07bff5ab2eb68cd51876f55f9df1f Mon Sep 17 00:00:00 2001 From: 2ynn Date: Mon, 19 Oct 2026 10:34:46 -0400 Subject: [PATCH 10/10] fix(cli): keep open envelopes' frames and match error-event body shapes in streams - An envelope with additionalProperties keeps the whole frame: its undeclared keys were printed through AdditionalProperties. - An error event that can't be encoded takes its body from the filtered frame when the stream element is a union of envelopes, matching the encodable path; Data-field unions still use the frame's data. - RawValue's doc says it returns the JSON Value was decoded from. --- .../internal/output/streaming.go.stmpl | 7 +++ .../internal/output/streaming_test.go.stmpl | 59 +++++++++++++++++++ .../stream/stream.go.stmpl | 4 +- zSDKs/sdk-cli/.speakeasy/gen.lock | 6 +- zSDKs/sdk-cli/internal/output/streaming.go | 7 +++ .../sdk-cli/internal/output/streaming_test.go | 59 +++++++++++++++++++ .../internal/sdk/types/stream/stream.go | 4 +- zSDKs/sdk-go/.speakeasy/gen.lock | 2 +- zSDKs/sdk-go/types/stream/stream.go | 4 +- 9 files changed, 142 insertions(+), 10 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl index 8c730bd0a..24b351243 100644 --- a/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl @@ -236,6 +236,9 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { keys := map[string]bool{} for i := 0; i < t.NumField(); i++ { f := t.Field(i) + if f.Tag.Get("additionalProperties") == "true" { + return nil, true + } name := strings.Split(f.Tag.Get("json"), ",")[0] if name == "-" || (name == "" && !f.IsExported()) { continue @@ -307,6 +310,10 @@ func streamErrorEvent(item interface{}, raw []byte) (json.RawMessage, bool) { if data, err := marshalJSON(u.Interface()); err == nil { return data, true } + if isUnionStruct(derefValue(reflect.ValueOf(item)).Type()) { + data, err := json.Marshal(wireStreamItem(item, raw, false)) + return data, err == nil + } var frame map[string]json.RawMessage if err := json.Unmarshal(raw, &frame); err != nil || len(frame["data"]) == 0 { return nil, false diff --git a/templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl b/templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl index ac08f9401..3743ed3bb 100644 --- a/templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/streaming_test.go.stmpl @@ -49,6 +49,65 @@ func TestWireStreamItemUnwrapsNestedEnvelopeUnions(t *testing.T) { } } +type openEnvelope struct { + Data string `json:"data"` + AdditionalProperties map[string]interface{} `additionalProperties:"true" json:"-"` +} + +func TestWireStreamItemKeepsFrameOfEnvelopeWithAdditionalProperties(t *testing.T) { + raw := []byte(`{"data":"hello","event":"tick","id":"7"}`) + want := `{"data":"hello","event":"tick","id":"7"}` + got, err := json.Marshal(wireStreamItem(openEnvelope{Data: "hello"}, raw, false)) + if err != nil || string(got) != want { + t.Fatalf("got %s, error %v; want %s", got, err, want) + } +} + +type errorEnvelopeUnion struct { + Envelope *nestedEnvelope `union:"member"` + Type string +} + +func (errorEnvelopeUnion) MarshalJSON() ([]byte, error) { + return nil, errors.New("required union has no member") +} + +type errorData struct { + Message string `json:"message"` +} + +type errorDataUnion struct { + Error *errorData `union:"member"` + Type string +} + +func (errorDataUnion) MarshalJSON() ([]byte, error) { + return nil, errors.New("required union has no member") +} + +type dataErrorEnvelope struct { + Data *errorDataUnion `json:"data"` + Event string `json:"event"` +} + +func TestStreamErrorEventUnencodableBodyMatchesEncodableShape(t *testing.T) { + for name, tc := range map[string]struct { + item interface{} + raw string + want string + }{ + "union of envelopes": {errorEnvelopeUnion{Envelope: &nestedEnvelope{Data: "boom", Event: "error"}, Type: "error"}, `{"data":"boom","event":"error","id":"7"}`, `{"data":"boom","event":"error"}`}, + "data union": {dataErrorEnvelope{Data: &errorDataUnion{Error: &errorData{Message: "boom"}, Type: "error"}, Event: "error"}, `{"data":{"message":"boom"},"event":"error"}`, `{"message":"boom"}`}, + } { + t.Run(name, func(t *testing.T) { + body, isErr := streamErrorEvent(tc.item, []byte(tc.raw)) + if !isErr || string(body) != tc.want { + t.Fatalf("got %s, error event %v; want %s", body, isErr, tc.want) + } + }) + } +} + type tableEnvelopeRow struct { Name string `json:"name"` Age int `json:"age"` diff --git a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl index 932155536..21c2b7691 100644 --- a/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl +++ b/templates/templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl @@ -341,8 +341,8 @@ func (es *EventStream[T]) Value() *T { return es.val } -// RawValue returns the most recent event as handed to the unmarshaller: the -// frame's event, data and retry, plus the last event id seen, as JSON. +// RawValue returns the JSON that Value was decoded from: the frame's event, +// data and retry, plus the last event id seen. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index c42f548f2..3319a1731 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -294,9 +294,9 @@ trackedFiles: internal/output/progress.go: last_write_checksum: sha1:f8b839f9394602fe9c30feb3d7155af6235663be internal/output/streaming.go: - last_write_checksum: sha1:0854ccc844fc9e2b41c637fc738e6506791c5010 + last_write_checksum: sha1:bafe2e29958d59888553e30527ce340600b94c94 internal/output/streaming_test.go: - last_write_checksum: sha1:713732201548ae10379a4baf6b21436bf7419385 + last_write_checksum: sha1:9bb53612aa2a3e6d3da96f0c8f8e2ce4ba99b509 internal/output/streamproject.go: last_write_checksum: sha1:19d7dec64b1f78cbcfb5420cc4e968aa7aedadd0 internal/sdk/.gitattributes: @@ -654,7 +654,7 @@ trackedFiles: internal/sdk/types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef internal/sdk/types/stream/stream.go: - last_write_checksum: sha1:c6b15858af3b912233c0c942d8a38cfd14a608ef + last_write_checksum: sha1:c2c34f5f7678390f0342c034e11cf321ddcea044 internal/testclient/testclient.go: last_write_checksum: sha1:7107e59d336235c44af152c5a61ee3896a7841fa internal/usage/bodyschemas.go: diff --git a/zSDKs/sdk-cli/internal/output/streaming.go b/zSDKs/sdk-cli/internal/output/streaming.go index 36ee0b148..a24105b4f 100644 --- a/zSDKs/sdk-cli/internal/output/streaming.go +++ b/zSDKs/sdk-cli/internal/output/streaming.go @@ -237,6 +237,9 @@ func envelopeKeys(v reflect.Value) (map[string]bool, bool) { keys := map[string]bool{} for i := 0; i < t.NumField(); i++ { f := t.Field(i) + if f.Tag.Get("additionalProperties") == "true" { + return nil, true + } name := strings.Split(f.Tag.Get("json"), ",")[0] if name == "-" || (name == "" && !f.IsExported()) { continue @@ -308,6 +311,10 @@ func streamErrorEvent(item interface{}, raw []byte) (json.RawMessage, bool) { if data, err := marshalJSON(u.Interface()); err == nil { return data, true } + if isUnionStruct(derefValue(reflect.ValueOf(item)).Type()) { + data, err := json.Marshal(wireStreamItem(item, raw, false)) + return data, err == nil + } var frame map[string]json.RawMessage if err := json.Unmarshal(raw, &frame); err != nil || len(frame["data"]) == 0 { return nil, false diff --git a/zSDKs/sdk-cli/internal/output/streaming_test.go b/zSDKs/sdk-cli/internal/output/streaming_test.go index d997e095e..31a436535 100644 --- a/zSDKs/sdk-cli/internal/output/streaming_test.go +++ b/zSDKs/sdk-cli/internal/output/streaming_test.go @@ -50,6 +50,65 @@ func TestWireStreamItemUnwrapsNestedEnvelopeUnions(t *testing.T) { } } +type openEnvelope struct { + Data string `json:"data"` + AdditionalProperties map[string]interface{} `additionalProperties:"true" json:"-"` +} + +func TestWireStreamItemKeepsFrameOfEnvelopeWithAdditionalProperties(t *testing.T) { + raw := []byte(`{"data":"hello","event":"tick","id":"7"}`) + want := `{"data":"hello","event":"tick","id":"7"}` + got, err := json.Marshal(wireStreamItem(openEnvelope{Data: "hello"}, raw, false)) + if err != nil || string(got) != want { + t.Fatalf("got %s, error %v; want %s", got, err, want) + } +} + +type errorEnvelopeUnion struct { + Envelope *nestedEnvelope `union:"member"` + Type string +} + +func (errorEnvelopeUnion) MarshalJSON() ([]byte, error) { + return nil, errors.New("required union has no member") +} + +type errorData struct { + Message string `json:"message"` +} + +type errorDataUnion struct { + Error *errorData `union:"member"` + Type string +} + +func (errorDataUnion) MarshalJSON() ([]byte, error) { + return nil, errors.New("required union has no member") +} + +type dataErrorEnvelope struct { + Data *errorDataUnion `json:"data"` + Event string `json:"event"` +} + +func TestStreamErrorEventUnencodableBodyMatchesEncodableShape(t *testing.T) { + for name, tc := range map[string]struct { + item interface{} + raw string + want string + }{ + "union of envelopes": {errorEnvelopeUnion{Envelope: &nestedEnvelope{Data: "boom", Event: "error"}, Type: "error"}, `{"data":"boom","event":"error","id":"7"}`, `{"data":"boom","event":"error"}`}, + "data union": {dataErrorEnvelope{Data: &errorDataUnion{Error: &errorData{Message: "boom"}, Type: "error"}, Event: "error"}, `{"data":{"message":"boom"},"event":"error"}`, `{"message":"boom"}`}, + } { + t.Run(name, func(t *testing.T) { + body, isErr := streamErrorEvent(tc.item, []byte(tc.raw)) + if !isErr || string(body) != tc.want { + t.Fatalf("got %s, error event %v; want %s", body, isErr, tc.want) + } + }) + } +} + type tableEnvelopeRow struct { Name string `json:"name"` Age int `json:"age"` diff --git a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go index c996f13ca..3d0f9f9b3 100644 --- a/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go +++ b/zSDKs/sdk-cli/internal/sdk/types/stream/stream.go @@ -342,8 +342,8 @@ func (es *EventStream[T]) Value() *T { return es.val } -// RawValue returns the most recent event as handed to the unmarshaller: the -// frame's event, data and retry, plus the last event id seen, as JSON. +// RawValue returns the JSON that Value was decoded from: the frame's event, +// data and retry, plus the last event id seen. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw } diff --git a/zSDKs/sdk-go/.speakeasy/gen.lock b/zSDKs/sdk-go/.speakeasy/gen.lock index 4de49fb10..dd7d0dc31 100644 --- a/zSDKs/sdk-go/.speakeasy/gen.lock +++ b/zSDKs/sdk-go/.speakeasy/gen.lock @@ -1289,7 +1289,7 @@ trackedFiles: types/pointers.go: last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef types/stream/stream.go: - last_write_checksum: sha1:c6b15858af3b912233c0c942d8a38cfd14a608ef + last_write_checksum: sha1:c2c34f5f7678390f0342c034e11cf321ddcea044 updateuserop.go: last_write_checksum: sha1:32b193694bf28ab709ef1bdda9ff8a2d633c831f urlvalidationstresstestop.go: diff --git a/zSDKs/sdk-go/types/stream/stream.go b/zSDKs/sdk-go/types/stream/stream.go index c996f13ca..3d0f9f9b3 100644 --- a/zSDKs/sdk-go/types/stream/stream.go +++ b/zSDKs/sdk-go/types/stream/stream.go @@ -342,8 +342,8 @@ func (es *EventStream[T]) Value() *T { return es.val } -// RawValue returns the most recent event as handed to the unmarshaller: the -// frame's event, data and retry, plus the last event id seen, as JSON. +// RawValue returns the JSON that Value was decoded from: the frame's event, +// data and retry, plus the last event id seen. func (es *EventStream[T]) RawValue() json.RawMessage { return es.raw }