Skip to content

message.part.updated arrives after message.part.delta #26924

@Nifury

Description

@Nifury

Description

Bug Description

In opencode, message.part.updated can arrive after message.part.delta for the same
partID on the /event SSE stream. Subscribers that use message.part.updated to register
a part before processing its deltas will encounter a race where a delta arrives first.

Environment

  • OpenCode version: tested on v1.14.48
  • Affected path: SyncEvent.runSession.updatePart → bus publish
  • Trigger: any streaming response that emits text or reasoning parts

Steps to Reproduce

Connect to the SSE event stream and filter for message.part.* events while a prompt is
being processed. Observe that message.part.delta events for a given partID can appear
before the first message.part.updated for that same partID.

Expected Behavior

For each partID, message.part.updated arrives before any message.part.delta with that
same ID:

data: {"type":"message.part.updated","properties":{"part":{"id":"prt_abc","type":"text",...}}}
data: {"type":"message.part.delta","properties":{"partID":"prt_abc","field":"text","delta":"..."}}
data: {"type":"message.part.delta","properties":{"partID":"prt_abc","field":"text","delta":"..."}}

Actual Behavior

message.part.delta can arrive before message.part.updated for the same partID:

data: {"type":"message.part.delta","properties":{"partID":"prt_abc","field":"text","delta":"..."}}
data: {"type":"message.part.updated","properties":{"part":{"id":"prt_abc","type":"text",...}}}

Root Cause

Session.updatePart and Session.updatePartDelta publish to the bus through different
mechanisms:

  • updatePart routes through SyncEvent.run, which defers the bus publish via
    Database.effect as a fire-and-forget runPromise call — the publish runs on a
    separate fiber and may not complete before updatePart returns.
  • updatePartDelta calls yield* bus.publish(...) directly on the caller's fiber.

Even though processor.ts correctly calls updatePart before updatePartDelta, the delta's
publish can reach the PubSub first because it runs on the current fiber while the update is
still pending on its orphaned fiber.

The relevant code in sync/index.ts:

Database.effect(() => {
  const result = convertEvent(def.type, event.data)
  const publish = (data) => ProjectBus.publish(def, data, { id: event.id })
  if (result instanceof Promise) void result.then(publish)
  else void publish(result)  // ← fire-and-forget, no caller awaits this
})

Impact

Any subscriber that uses message.part.updated to learn a part's type before processing its
deltas will mishandle or drop the early deltas. In our integration, it causes:

AI_UIMessageStreamError: Received text-delta for missing text part with ID "prt_...".
Ensure a "text-start" chunk is sent before any "text-delta" chunks.

Plugins

No response

OpenCode version

No response

Steps to reproduce

No response

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
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions