Skip to content

Duplicate assistant message broadcast: _wait_and_finalize and event bridge both broadcast MessageUpdatedEvent #263

Description

@Leoyzen

Problem

The OpenCode server broadcasts MessageUpdatedEvent for the same assistant message three times via SSE, causing duplicate content rendering in the TUI:

  1. C3 registration (opencode_event_bridge.py:559) — on first agent event, creates and broadcasts the assistant message
  2. _finalize_assistant_time (opencode_event_bridge.py:217, called from :468 on StreamCompleteEvent) — sets time.completed and broadcasts
  3. _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:

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    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