Repository navigation
Description
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/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:
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.
Activity
github-actions commentedon Jul 22, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.