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.
Summary
When OpenCode connects to a provider that sends named SSE events alongside standard
chat.completion.chunkevents, 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
/v1/chat/completionsSSE 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:
Standard
chat.completion.chunkframes are sent as unnamed (default) events:OpenCode receives the named event and attempts to validate its
datapayload as anOpenAIChatEvent. The payload has nochoicesarray orerrorobject, so validation fails: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,sseFraminguses Effect-TS'sSse.decode()— which correctly preserves theevent:field — but then immediately discards it:Every
data:payload — regardless of itsevent:type — is then passed toProtocol.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:Alternatively, individual protocol handlers could declare the event name(s) they care about and
sseFramingcould 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.