Skip to content

SSE stream closes mid-turn (not persistent as documented) #38458

Description

@admntmkgit

Description

I tried to implement some sse for the opencode serve to monitor and orchestrate some sessions but it doesn't seem to be working.
Lets face it, I'll be honest I do not know how the webevents work, and my claude couldn't get it to work, says the SSE stream is not running properly, and its instead set up for a long poll instead of persistent event stream
Maybe we set it up wrong, but he coudln't figure it out how to get it running - we did get a proper sse once, at version 1.17.13 - now its dead he doesn't how how to reproduce that anymore - I was super green with agentic coding back then and did not document anything

opencode serve: /api/event SSE stream closes mid-turn (not persistent as documented); most bus event types never arrive; durable event/wait layer unreachable from HTTP-created sessions

TL;DR

On opencode serve1.18.4 there is no event-stream endpoint that both stays open and delivers
session/turn events
:

  • v1 GET /event (stable, documented as persistent) stays open for the whole turn but delivers
    onlyserver.connected + server.heartbeatnosession.* / message.* / turn events. This
    looks like a regression (community reports say SyncEvent publishes stopped reaching /event
    since ~1.14.42).
  • v2 GET /api/event (the /api/* surface) does deliver session.updated / message.updated /
    message.part.updated, but the server closes the connection ~0.1–1.4 s after each flush during an
    active turn
    — not persistent, contrary to the docs. A normal single-connection client gets the first
    burst and then goes silent.
  • GET /global/event stays open but its frames carry no parseable type.

So following a turn forces the /api/event stream plus constant reconnects, which is lossy (no
Last-Event-ID replay). Additionally, most declared V2Event types never emit on /api/event, and the
durable/wait layer that would make this reliable is unreachable for HTTP-created sessions (details in
Issues 2–3).

All findings below are reproduced with plain curl (neutral client), so they are not specific to any SDK.

Plugins

no plugins - only self made that's gate the agent from reading .env files, reading stuff outside the wsl (windows is set on ro) and docker access to -v

OpenCode version

1.18.4

Steps to reproduce

Environment

opencode1.18.4
launchopencode serve --port <P> --hostname 127.0.0.1 (headless)
OSLinux (WSL2)
clientcurl 8.5.0 and a raw httpx reader — both reproduce it
modelsfree opencode/deepseek-v4-flash-free, paid opencode-go/deepseek-v4-flash (both behave identically)
authbasic auth via OPENCODE_SERVER_PASSWORD (-u <user>:<pass>)

Issue 1 (primary bug): no stream both stays open AND delivers turn events (v1 /event regressed to empty; v2 /api/event closes mid-turn)

The three event streams, measured over ONE live multi-step turn (single connection, no reconnect)

EndpointSurfaceStays open for the turn?Delivers session.*/message.* turn events?
GET /eventv1 (stable)YESserver.connected + server.heartbeat every ~10 sNO — zero turn events
GET /api/eventv2 (/api/*)NO — server closes ~0.1–1.4 s after each flushYESsession.updated, message.updated, message.part.updated
GET /global/eventYESframes carry no parseable type

MEASURED 1.18.4 (free + paid deepseek-v4-flash), one ~12 s multi-step turn, one connection each:

/event [STILL OPEN] types = { server.connected@0.04s, server.heartbeat@10.05s } # nothing else
/api/event [CLOSED @1.42s] types = { server.connected@0.05s, session.updated@1.06s,
message.updated@1.41s, message.part.updated@1.41s }
/global/event [STILL OPEN] frames present but type = null (untyped firehose)

So the only stream that carries turn events is /api/event, and it closes mid-turn. The stable v1
/event stays open but no longer carries session.* / message.* events — consistent with community
reports that SyncEvent publishes stopped reaching /event since ~1.14.42. Net: there is currently no
way to follow a turn over a single persistent connection.
The /api/event mid-turn-close detail follows.

Documented behavior (https://opencode.ai/docs/server): /event is a "Server-sent events stream.
First event is server.connected, then bus events" — i.e. a persistent SSE stream.

Actual behavior: the stream is persistent only while idle. As soon as a turn is producing events,
the server sends a burst and then cleanly closes the response (HTTP 200, clean EOF), repeatedly.

Reproduction

# 1. Start a serve (choose any password).
OPENCODE_SERVER_PASSWORD=pw opencode serve --port 4096 --hostname 127.0.0.1 &# 2. BASELINE — idle stream stays open for the full 20s (correct):
curl -N -s -u opencode:pw -o /dev/null \
-w 'idle: closed_after=%{time_total}s http=%{http_code} exitcode=%{exitcode}\n' \
--max-time 20 http://127.0.0.1:4096/api/event
# -> idle: closed_after=20.00s http=200 exitcode=28 (28 = curl hit --max-time; stayed OPEN)# 3. Create a session and start a multi-step turn (adjust body per GET /doc if needed):
SID=$(curl -s -u opencode:pw -X POST http://127.0.0.1:4096/session \ -H 'content-type: application/json' -d '{"title":"repro"}'| grep -o '"id":"[^"]*"'| head -1 | cut -d'"' -f4)
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/session/$SID/prompt_async?directory=$PWD" \
-H 'content-type: application/json' \
-d '{"parts":[{"type":"text","text":"Write notes.txt containing BANANA, read it back, list the directory, then reply one sentence."}], "model":{"providerID":"opencode","modelID":"deepseek-v4-flash-free"}}'# 4. WHILE the turn runs, subscribe — the stream closes ~0.3s in, instead of staying open:
curl -N -s -u opencode:pw -o /dev/null \
-w 'active: closed_after=%{time_total}s http=%{http_code} size=%{size_download} exitcode=%{exitcode}\n' \
--max-time 30 http://127.0.0.1:4096/api/event
# -> active: closed_after=0.33s http=200 size=2904 exitcode=0 (0 = server CLOSED before max-time)

Observed vs expected

Idle streamActive-turn stream
Expectedstays openstays open, streams all bus events
Actualstays open (curl hit --max-time, exit 28)server closes cleanly at ~0.1–0.4 s after flushing a burst (exit 0, HTTP 200)

A reconnecting reader (new connection on every close) does eventually capture the turn's events, but only
via dozens of reconnects in a single 10 s turn (measured: STREAM-ENDED after {1,2,6,50} events,
each reconnect replays server.connected). A standard single-connection SSE consumer captures only the
first ~0.3 s
and then receives nothing for the remainder of the turn.

Impact

Any client that opens /api/event once and reads it as a normal SSE stream silently stops receiving
events mid-turn
. The server keeps working; the client never catches up. This is invisible (no error, a
clean 200 close), so it presents as a stalled UI / lost progress.


Issue 2 (related): only 4 of 88 declared V2Event types are ever observed on /api/event

With a reconnecting reader (so Issue 1 is not the cause), across a healthy multi-step turn, a permission
ask, and a question, the only event types seen on the session-filtered /api/event are:

session.created, session.updated, message.updated, message.part.updated

Never observed, though all are declared in V2Event (GET /doc):

  • session.idle (turn completed — no idle event in 25 s of observation)
  • session.status (as an event)
  • the entire session.next.* family (step.started|ended|failed, tool.called|success|failed,
    text.*, reasoning.*, prompt.admitted, model.switched, agent.switched, retried, …)
  • permission.asked / permission.v2.asked (a permission ask was pending — confirmed via
    GET /permission at ~3 s — but no event fired)
  • question.asked / question.v2.asked (a question was pending — confirmed via GET /question at
    ~2.5 s — but no event fired)

Question for maintainers

Are these event types supposed to publish on the session-filtered /api/event stream? Consumers currently
have no way to learn about turn completion, rate-limit/backoff, permission asks, or
questions from the event bus, and must poll GET /session/status, GET /permission, and
GET /question instead. (This may be related to a SyncEvent-publish path not reaching the HTTP SSE
stream.)


Issue 3 (question): the durable/resumable event + wait layer is unreachable for HTTP-created sessions

GET /doc exposes a durable layer that would solve Issue 1's gap-loss and Issue 2's missing signals:

  • GET /api/session/{id}/event?after=<seq> — "Replay durable events after an aggregate sequence, then
    continue with new durable events" (resumable — the reconnect-safe stream)
  • POST /api/session/{id}/wait — "Wait for a session agent loop to become idle" (completion without polling)
  • POST /sync/{history,replay,start,steal} — the durable SyncEvent store

But for a session created via POST /session + POST /session/{id}/prompt_async, all of it is inert:

curl -s -u opencode:pw "http://127.0.0.1:4096/api/session/active?directory=$PWD"# -> {"data":{}} # process owns NO session drain
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/sync/start?directory=$PWD"# -> true # loop starts, but confers no ownership
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/sync/steal?directory=$PWD" \
-H 'content-type: application/json' -d "{\"sessionID\":\"$SID\"}"# -> 400 {"_tag":"BadRequest"}
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/api/session/$SID/wait?directory=$PWD"# -> 503 {"_tag":"ServiceUnavailableError","message":"Session wait is not available yet"}
curl -N -s -u opencode:pw "http://127.0.0.1:4096/api/session/$SID/event?after=0&directory=$PWD"# -> (no events; times out)

Question for maintainers

How does a plain HTTP client (not the TUI / opencode attach) take ownership of a session it created
via the API, so that durable event replay (?after=<seq>), POST /api/session/{id}/wait, and the
/sync/* history become available? Is there a supported handshake, or are these endpoints intended only
for attach-owned foreground sessions? If the latter, is there a recommended reliable-events path for
headless serve integrations?


Why this matters

Headless integrations against opencode serve need a reliable way to observe a turn — progress,
completion, permission/question prompts. Today the only dependable signals are the polls
(GET /session/status, GET /session/{id}/message, GET /permission, GET /question); the event bus
(/api/event) drops out mid-turn and omits most event types, and the durable layer that would fix this is
unreachable from the HTTP API. A fix to any one of the three (persistent /api/event, full event
publishing, or a documented ownership handshake for the durable layer) would make event-driven integration
viable.

What we would consider a concrete answer

  1. Which endpoint is the intended, stable way for a headless HTTP client to follow a turn's events?
    Did v1 GET /event stop delivering session.* / message.* events by design or by regression
    (community reports point to ~1.14.42)? If /api/event is the intended one, is it stable or beta?
  2. Is the /api/event mid-turn close (Issue 1) a bug, and is a persistent stream (or documented
    auto-reconnect with Last-Event-ID replay) the intended contract?
  3. Should session.idle / session.next.* / permission.asked / question.asked (Issue 2) publish on
    /api/event? If not, what is the intended discovery mechanism?
  4. Is there a supported way for an HTTP-created session to reach the durable/wait layer (Issue 3)?

Reproduction scripts and raw output logs (Python httpx + curl) are available on request.

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions