Skip to content

SSE named events from custom providers cause type validation failure and break streaming #28833

@jhjaggars

Description

@jhjaggars

Summary

When OpenCode connects to a provider that sends named SSE events alongside standard chat.completion.chunk events, the streaming handler throws a type validation error that interrupts the agent turn entirely. The model never receives tool results and cannot recover or ask the user for help.

Environment

  • OpenCode version: 1.15.7
  • Provider: self-hosted hermes-agent gateway (OpenAI-compatible)
  • Transport: OpenAI-compatible (/v1/chat/completions SSE stream)

What happens

The hermes gateway multiplexes tool progress notifications onto the SSE stream using a named event type, which is valid per the WHATWG SSE spec:

event: hermes.tool.progress
data: {"tool":"terminal","emoji":"💻","label":"kubectl get sa","toolCallId":"call_9gxqkjk8","status":"running"}

event: hermes.tool.progress
data: {"tool":"terminal","toolCallId":"call_9gxqkjk8","status":"completed"}

Standard chat.completion.chunk frames are sent as unnamed (default) events:

data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"content":"Hello"},"index":0}]}

OpenCode receives the named event and attempts to validate its data payload as an OpenAIChatEvent. The payload has no choices array or error object, so validation fails:

Type validation failed: Value: {"tool":"terminal","emoji":"💻","label":"kubectl get sa","toolCallId":"call_9gxqkjk8","status":"running"}.
Error message: [
  {
    "code": "invalid_union",
    "errors": [
      [{"expected": "array", "code": "invalid_type", "path": ["choices"], "message": "Invalid input: expected array, received undefined"}],
      [{"expected": "object", "code": "invalid_type", "path": ["error"],   "message": "Invalid input: expected object, received undefined"}]
    ],
    "message": "Invalid input"
  }
]

The exception propagates synchronously through the streaming handler, interrupting the turn. The model never sees the tool result and cannot respond, recover, or ask for clarification.

Root cause

In packages/llm/src/protocols/shared.ts, sseFraming uses Effect-TS's Sse.decode() — which correctly preserves the event: field — but then immediately discards it:

export const sseFraming = (bytes) =>
  bytes.pipe(
    Stream.decodeText(),
    Stream.pipeThroughChannel(Sse.decode()),        // parses SSE, event.event is set correctly
    Stream.catchTag("Retry", () => Stream.empty),
    Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
    Stream.map((event) => event.data),              // ← drops event.event, keeps only data
  )

Every data: payload — regardless of its event: type — is then passed to Protocol.jsonEvent(OpenAIChatEvent) for validation.

Per the SSE spec, a client consuming the default (message) event type should ignore events with a different named type. Named events are intended for listeners specifically registered for that type.

Suggested fix

Filter to only process default/unnamed events before mapping to .data:

Stream.filter((event) =>
  event.data.length > 0 &&
  event.data !== "[DONE]" &&
  (event.event === "" || event.event === "message")   // ignore named events
),
Stream.map((event) => event.data),

Alternatively, individual protocol handlers could declare the event name(s) they care about and sseFraming could accept that as a parameter.

Why this matters

Using named SSE events to multiplex metadata (tool progress, heartbeats, approvals) on the same connection without a separate WebSocket or HTTP/2 stream is a legitimate, spec-compliant pattern. Other self-hosted or custom providers may do the same. Silently dropping unrecognised named events is the correct behaviour; throwing on them breaks the entire conversation turn.

Workaround

None on the client side without patching sseFraming. The server can work around it by suppressing the named events, but that removes useful progress information for clients that do handle them correctly.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions