Repository navigation
Description
/event SSE stream stops delivering SyncEvent publishes in 1.14.42+
Starting with OpenCode 1.14.42, the GET /event HTTP+SSE stream no longer delivers events whose publishes originate from SyncEvent.run() — specifically message.part.updated, message.updated, session.next.agent.switched, and session.next.model.switched. Plain BusEvent.publish() events (session.status, session.diff, session.idle, message.part.delta) continue to be delivered normally.
Server-side bus logs show the SyncEvent publishes happening (service=bus type=message.part.updated publishing), but they never reach bus.subscribeAll() SSE subscribers. Bisects to the 1.14.41 → 1.14.42 boundary; reproduces on every release through 1.15.1.
This breaks any consumer that relies on /event for live tool-call state — any harness rendering message.part.updated tool parts as they reach state.status === "completed" will see zero tool events on the wire even though the server logs them publishing.
Repro
Two terminals against any version ≥ 1.14.42.
Terminal 1 — start the server:
opencode serve --port 4096 --hostname 127.0.0.1 --log-level DEBUG --print-logsTerminal 2 — subscribe to events and trigger a one-tool session:
// repro.mjs — node repro.mjs
const BASE = 'http://127.0.0.1:4096'
const DIR = process.cwd()
async function rawSubscribe() {
const url = `${BASE}/event?directory=${encodeURIComponent(DIR)}`
const res = await fetch(url)
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
let buf = ''
let count = 0
while (true) {
const {done, value} = await reader.read()
if (done) {
console.log(`stream done after ${count} events`)
return
}
buf += value
const chunks = buf.split('\n\n')
buf = chunks.pop() ?? ''
for (const chunk of chunks) {
const dataLines = []
for (const line of chunk.split('\n')) {
if (line.startsWith('data:')) dataLines.push(line.replace(/^data:\s*/, ''))
}
if (dataLines.length === 0) continue
count++
const payload = JSON.parse(dataLines.join('\n'))
console.log(count, payload.type)
if (count >= 60) return
}
}
}
async function triggerTool() {
const r1 = await fetch(`${BASE}/session?directory=${encodeURIComponent(DIR)}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title: 'sse-repro'}),
})
const sess = await r1.json()
await fetch(`${BASE}/session/${sess.id}/prompt_async?directory=${encodeURIComponent(DIR)}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
parts: [{type: 'text', text: 'Run ls. Just one ls. Then say done.'}],
}),
})
}
const sub = rawSubscribe()
setTimeout(() => triggerTool().catch(console.error), 500)
await subObserved on 1.14.42+
1 server.connected
2 session.updated
3 session.status
4 session.diff
5 session.status
6 session.status
7 session.diff
8 session.status
9 message.part.delta
10 session.status
11 session.status
12 session.idle
13 session.diff
Server log over the same window:
service=bus type=session.created publishing
service=bus type=session.updated publishing
service=bus type=message.updated publishing ← not delivered
service=bus type=message.part.updated publishing ← not delivered
service=bus type=session.next.agent.switched publishing ← not delivered
service=bus type=session.next.model.switched publishing ← not delivered
service=bus type=session.status publishing
service=bus type=session.diff publishing
service=bus type=message.part.updated publishing ← not delivered
... (8 more message.part.updated publishes, none delivered)
service=bus type=message.part.delta publishing
service=bus type=session.idle publishing
The persisted session reflects a fully-completed tool call:
$ curl -s "http://127.0.0.1:4096/session/<id>/message?directory=$(pwd)" | jq '.[1].parts[] | select(.type=="tool")'
{
"type": "tool",
"tool": "bash",
"state": {
"status": "completed",
"input": {"command": "ls"},
"output": "..."
}
}So the tool ran and the snapshot is correct — only the live event delivery is broken.
On 1.14.41 the same script delivers all 10 message.part.updated events along with the rest of the SyncEvent traffic.
Likely area
The divergence between BusEvent and SyncEvent on the SSE wire suggests the issue is in how SyncEvent publishes route through Bus.subscribeAll().
- BusEvents:
bus.publish(def, properties)pushes onto the wildcard pubsub (PubSub.publish(s.wildcard, payload)) — these arrive. - SyncEvents:
sync.run(def, data, {publish:true})→process(def, event, {publish, context})→Database.effect(() => ProjectBus.publish(def, data, {id}))— these publish (log lines confirm) but do not arrive on/eventSSE.
Both paths ultimately call the same Bus.publish function. The break could be in:
- the runtime used to evaluate the SyncEvent publish Effect (
Database.effectcallback context), - a missing/wrong
InstanceStateprovision causing the publish to land on a different Bus instance than/event'ssubscribeAll(), - a fire-and-forget that loses the Effect before it reaches the wildcard pubsub.
The persisted-DB write half of sync.process clearly succeeds — that's why session.messages() returns the completed tool part. Only the in-memory publish side appears affected.
Happy to provide additional repro details.
Activity
github-actions commentedon May 17, 2026
This issue might be a duplicate of existing issues. Please check:
message.part.updatedarrives aftermessage.part.delta— describes the same underlyingSyncEvent.run→Database.effectfire-and-forget publish mechanism causing bus delivery issues; the root cause analysis in that issue directly overlaps with the suspected cause here.prompt_asyncstops publishingmessage.part.deltaevents on second call — also involves SSE events going missing from the/eventstream, though triggered differently (conversation continuation vs. first prompt).kitlangton commentedon May 21, 2026
Thanks for the very thorough repro on this one. I believe this should be fixed on v1.15.5+ by three follow-up patches that landed after the version you tested:
fix(sync): publish events on injected project busfix: preserve bus instance contextfix(bus): acquire PubSub subscription eagerlyandrefactor(sync): publish via EffectBridge.forkI ran your repro shape end to end against current
dev(subprocessopencode serve+ SSE subscribe +prompt_asyncwith a scripted tool call) and all the previously missing SyncEvents arrived:message.updated,message.part.updated,session.next.agent.switched,session.next.model.switched,session.idle.Closing this for now. If you upgrade to v1.15.5 or newer and still see SyncEvent publishes dropped on
/event, please reopen with the new version and we will dig in again.opencode: live events via 1s polling (SSE part stream is broken upstr…