Problem
The OpenCode server broadcasts MessageUpdatedEvent for the same assistant message three times via SSE, causing duplicate content rendering in the TUI:
- C3 registration (
opencode_event_bridge.py:559) — on first agent event, creates and broadcasts the assistant message
_finalize_assistant_time (opencode_event_bridge.py:217, called from :468 on StreamCompleteEvent) — sets time.completed and broadcasts
_wait_and_finalize (message_routes.py:831, 845, 866, 889) — after wait_for_completion() returns, broadcasts MessageUpdatedEvent again with tokens/cost/time
The TUI receives 3 message.updated SSE events for the same assistant message (same message_id). Depending on TUI rendering logic, this can cause the assistant response content to appear twice in the conversation transcript.
Evidence
Server logs (session ses_0019f84eac043001YLZ0Rd8ZZEb2V6):
13:43:44.774951 SSE: message.updated ← _finalize_assistant_time (StreamCompleteEvent)
13:43:44.776551 SSE: message.updated ← _wait_and_finalize (after wait_for_completion)
TUI shows the assistant response content duplicated:
✅ 后台任务委派功能测试完成! ... (full response)
---
✅ 后台任务委派功能测试完成! ... (same full response again)
Root Cause
_wait_and_finalize in message_routes.py broadcasts MessageUpdatedEvent after wait_for_completion() returns (lines 831, 845, 866, 889). But the event bridge already broadcasts the same message via _finalize_assistant_time on StreamCompleteEvent. This is a dual-broadcast — the REST handler and the event bridge both finalize and broadcast the same assistant message.
The C3 registration comment at message_routes.py:541-551 already says:
"C3: Do NOT broadcast the assistant message here. The event bridge (_handle_event) is the sole broadcast point."
But _wait_and_finalize violates this principle by broadcasting again after completion.
Related PRs
Proposed Fix
Per Oracle architecture review:
-
Remove the 4 broadcast_event(MessageUpdatedEvent) calls in _wait_and_finalize (message_routes.py:831, 845, 866, 889). Keep _ensure_assistant_in_state + persist_message_to_storage — the HTTP response and storage still need them.
-
Add error-before-any-event fallback: If the agent crashes before emitting any event, C3 never fires (_message_registered stays False), so the assistant message is never broadcast via SSE. Add a fallback in the event bridge RunErrorEvent handler (opencode_event_bridge.py:469): if _message_registered is False, broadcast the assistant message first before calling _finalize_assistant_time.
-
Verify _finalize_assistant_time (opencode_event_bridge.py:188-218) is the sole finalization broadcaster. It already sets time.completed and broadcasts. ✅
Impact
- No breaking changes: The HTTP response from
POST /message still returns the complete MessageWithParts. Only the SSE broadcast is deduplicated.
prompt_async path: Already returns 204 and relies on the event bridge. No impact.
- TUI: Receives one
message.updated instead of three. Content no longer duplicated.
- Storage:
persist_message_to_storage in _wait_and_finalize is unaffected — still persists after completion.
Scope
Minimal: ~4 lines removed + ~5 lines added (fallback guard). No new event types, no architecture changes.
Problem
The OpenCode server broadcasts
MessageUpdatedEventfor the same assistant message three times via SSE, causing duplicate content rendering in the TUI:opencode_event_bridge.py:559) — on first agent event, creates and broadcasts the assistant message_finalize_assistant_time(opencode_event_bridge.py:217, called from:468onStreamCompleteEvent) — setstime.completedand broadcasts_wait_and_finalize(message_routes.py:831, 845, 866, 889) — afterwait_for_completion()returns, broadcastsMessageUpdatedEventagain with tokens/cost/timeThe TUI receives 3
message.updatedSSE events for the same assistant message (samemessage_id). Depending on TUI rendering logic, this can cause the assistant response content to appear twice in the conversation transcript.Evidence
Server logs (session
ses_0019f84eac043001YLZ0Rd8ZZEb2V6):TUI shows the assistant response content duplicated:
Root Cause
_wait_and_finalizeinmessage_routes.pybroadcastsMessageUpdatedEventafterwait_for_completion()returns (lines 831, 845, 866, 889). But the event bridge already broadcasts the same message via_finalize_assistant_timeonStreamCompleteEvent. This is a dual-broadcast — the REST handler and the event bridge both finalize and broadcast the same assistant message.The C3 registration comment at
message_routes.py:541-551already says:But
_wait_and_finalizeviolates this principle by broadcasting again after completion.Related PRs
fix/duplicate-finalization-concurrent-syncbranch, commita423e7ace): Was supposed to fix this, but only the followupreturn Nonefix was merged into main via fix(opencode): implement session revert stage/clear/commit model #256. The duplicate finalization fix for initial messages was not merged.234556d18): Merged thereturn Nonefor followup (fixes followup duplicate), but did NOT fix the initial message duplicate.feat/steer-followup-user-message): Our feature branch discovered this pre-existing bug during testing.Proposed Fix
Per Oracle architecture review:
Remove the 4
broadcast_event(MessageUpdatedEvent)calls in_wait_and_finalize(message_routes.py:831, 845, 866, 889). Keep_ensure_assistant_in_state+persist_message_to_storage— the HTTP response and storage still need them.Add error-before-any-event fallback: If the agent crashes before emitting any event, C3 never fires (
_message_registeredstays False), so the assistant message is never broadcast via SSE. Add a fallback in the event bridgeRunErrorEventhandler (opencode_event_bridge.py:469): if_message_registeredis False, broadcast the assistant message first before calling_finalize_assistant_time.Verify
_finalize_assistant_time(opencode_event_bridge.py:188-218) is the sole finalization broadcaster. It already setstime.completedand broadcasts. ✅Impact
POST /messagestill returns the completeMessageWithParts. Only the SSE broadcast is deduplicated.prompt_asyncpath: Already returns 204 and relies on the event bridge. No impact.message.updatedinstead of three. Content no longer duplicated.persist_message_to_storagein_wait_and_finalizeis unaffected — still persists after completion.Scope
Minimal: ~4 lines removed + ~5 lines added (fallback guard). No new event types, no architecture changes.