microsoft / microsoft/typespec

[Bug]: @terminalEvent on a model-payload variant emits an unconstrained oneOf branch in OpenAPI 3.2 SSE output

Open Beginner friendly
#11,747 1 comment 1 reaction 0 assignees View on GitHub
bug emitter:openapi3 triaged:core
Dominant language
Java
Stars
5.9k
Forks
394
Avg merge
1d 23h
Merged PRs (30d)
104

Description

### Describe the bug

With `openapi-versions: ["3.2.0"]`, a `@terminalEvent` variant that is a **named variant carrying a model payload** is emitted as a `oneOf` branch with neither its `event` const nor its `data.contentSchema`:

```yaml
- properties:
data:
contentMediaType: application/json
```

That branch constrains nothing, so it matches every frame. Since the branches are combined with `oneOf`, every real event matches at least two branches — the schema is unsatisfiable and a conforming validator rejects all traffic against an otherwise correct API.

**The sentinel form is handled correctly**, which is what makes this look like an oversight rather than a design choice. Given the same union shape, `@terminalEvent "[DONE]"` emits a properly pinned branch:

```yaml
- properties:
data:
contentMediaType: application/json
const: '[DONE]'
```

So the emitter clearly knows how to constrain a terminal branch. Only the named-variant-with-model form comes out empty.

### Expected

The same treatment its siblings get:

```yaml
- properties:
data:
contentMediaType: application/json
contentSchema:
$ref: '#/components/schemas/StreamDone'
event:
const: done
```

This is not a guess — it is exactly what the emitter produces when `@terminalEvent` is removed from that variant and nothing else changes, which also isolates the decorator as the trigger.

Whether the terminal branch should *additionally* carry some marker of terminality is the open design question. OpenAPI 3.2 has no standard keyword for "this event ends the stream", so the options look like emitting it identically to its siblings (correct schema, terminality not represented) or that plus an `x-` extension. Either is better than a branch that voids the `oneOf`.

Context: #8312 was closed on 2026-08-06 noting the openapi3 emitter now emits SSE under OpenAPI 3.2. The example in that thread and in the [SSE docs](https://typespec.io/docs/libraries/sse/reference/data-types/) both use the sentinel form — which works — so the model-payload terminal event may simply never have been exercised.

### Impact

Measured with Schemathesis 4.25.0 against a live server implementing this contract:

| Spec | Result |
|---|---|
| OpenAPI 3.1 (SSE degrades to `type: string`) | fails — *"is not of type string"* |
| OpenAPI 3.2, `@terminalEvent` present | fails — *"is valid under more than one of the schemas listed in the 'oneOf' keyword"* |
| OpenAPI 3.2, `@terminalEvent` removed | **134 cases generated, 134 passed** |

Our server sends `event: done` on the wire alongside `chunk` and `error`, so the emitted document actively misdescribes correct behaviour rather than merely under-specifying it. The only workaround is dropping `@terminalEvent`, which forfeits the lifecycle signal it exists to carry.

### Cause and proposed fix

`packages/openapi3/src/sse-module.ts` (L89-106 on `main`) handles only the string-literal case inside the terminal branch, so a model payload matches no arm and keeps the bare `{ contentMediaType }` the variant schema was initialised with:

```ts
if (isTerminal) {
// If the variant type is a string literal, use it as const
if (variant.type.kind === "String") {
(variantSchema.properties!.data as OpenAPISchema3_2).const = variant.type.value;
(variantSchema.properties!.data as OpenAPISchema3_2).contentMediaType = payloadContentType;
}
// <- a terminal variant whose payload is a Model falls through here with nothing
} else {
if (eventType) {
variantSchema.properties!.event = { const: eventType };
}
(variantSchema.properties!.data as OpenAPISchema3_2).contentSchema = getSchemaForType(payloadType);
}
```

The narrowest fix is to move the string-literal test into the condition, so only a *sentinel* takes the terminal path and everything else gets the ordinary treatment:

```ts
if (isTerminal && variant.type.kind === "String") {
(variantSchema.properties!.data as OpenAPISchema3_2).const = variant.type.value;
} else {
if (eventType) {
variantSchema.properties!.event = { const: eventType };
}
(variantSchema.properties!.data as OpenAPISchema3_2).contentSchema = getSchemaForType(payloadType);
}
```

That yields the expected output above and leaves the working sentinel case byte-identical. (`contentMediaType` is already set when `variantSchema` is initialised a few lines earlier, so the re-assignment inside the old branch can go.)

Happy to send this as a PR if the direction is right — the only question is whether terminality should additionally be represented somehow, per the note above.

Related: the `polymorphism-discriminator` lint flags the generated union for lacking a discriminator — a second symptom of the same gap.

Possibly related, though not the same defect: #11090 reports `@@opExample` response examples being silently dropped from `text/event-stream` on 3.2.0. Different symptom and likely a different code path — noting it only because it is the same emitter, version and media type, so the two may share a neighbourhood.

### Reproduction

[Playground repro](https://typespec.io/playground/?tspconfig=ZW1pdDoKICAtICJAdHlwZXNwZWMvb3BlbmFwaTMiCm9wdGlvbnPEIiDVIsYaICDHES12ZXJzyjLEAS0gIjMuMi4wIg%3D%3D&c=aW1wb3J0ICJAdHlwZXNwZWMvaHR0cCI7IArSGnNzZdYZZXZlbnRzIjsKCnVzaW5nIFR5cGVTcGVjLkh0dHA70BVTU0XRFEXFQjsKCkBzZXJ2aWNlKCN7IHRpdGxlOiAidGVybWluYWzFJSByZXBybyIgfSkKbmFtZXNwYWNlIFLEFFPGOjsKCm1vZGVsIFN0cmVhbUNodW5rIHsgY29udGVudDogc3RyaW5nOyB9zSdEb25lIHsgcmVzdWx0P8wmCi8vIEEg4oCUIHRoZSBkb2N1bWVudGVkIHNlbnRpbmVsIGZvcm0KQOYBBQogIHVuaW9uIFPHHlTnAL3kAIPkAIs67ACYLCBA7gDdIltET05FXSIsCsZ5QsV5YSDkAOtkIHZhcmlhbnQgd2l0aCBhIOYAwHBheWxvYWTxAIFNxB7ffs9%2BZG9uZcgi5AER5QCGQHJvdXRlKCIv6ADwIikgQHBvc3Qgb3DpAQQoKTogU1NFxjs88AD8PjvKRuUAtcxDxRFQ5gDFzkftAMI%2BOw%3D%3D&vs=%7B%7D)

Under each route's `responses.200.content.text/event-stream.itemSchema.oneOf`:

```
/sentinel branch 0: {data: {contentMediaType, contentSchema: StreamChunk}, event: {const: chunk}}
branch 1: {data: {contentMediaType, const: "[DONE]"}} <- constrained, fine
/model branch 0: {data: {contentMediaType, contentSchema: StreamChunk}, event: {const: chunk}}
branch 1: {data: {contentMediaType}} <- unconstrained, the bug
```

### Versions

```
@typespec/compiler 1.15.0
@typespec/openapi3 1.15.0
@typespec/events 0.85.0
@typespec/sse 0.85.0
```

Node 22, Linux x64. Also observed in a production document where the same union backs two SSE endpoints.

### Checklist

- [x] Follow our [Code of Conduct](https://github.com/microsoft/typespec/blob/main/CODE_OF_CONDUCT.md)
- [x] Check that there isn't already an issue that request the same bug to avoid creating a duplicate.
- [x] Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/Microsoft/typespec/discussions).
- [x] The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug.

Contributor guide

Open the contributing guide

Research direction

Start in packages/openapi3/src/sse-module.ts around lines 89-106 and run the linked Playground reproduction with OpenAPI 3.2. Compare the sentinel and model-payload branches in the generated text/event-stream itemSchema.oneOf. Done means the model branch includes its contentSchema and event const, while the working sentinel output remains unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
82/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.