Skip to content

saveStreamDeltas: final step's save races the throttle buffer → tail of fast generations never persisted as deltas #323

Description

@mimameid

Version: @convex-dev/agent@0.7.0 (same code on main today), ai@7.0.67, saveStreamDeltas: { returnImmediately: true, throttleMs: 100 }.

What happened

We serve resumable HTTP streams from the persisted deltas (streams.list + streams.listDeltas, polled). On short generations the delta log ends mid-generation: trailing text, tool-call parts and the finish chunk never arrive, even though the saved message row is complete. How much is missing scales with how fast the generation finishes relative to the writer's flush cadence. Under convex-test, where the whole action settles before any throttled flush fires, the delta log ends up empty.

Sequence, as far as I can trace it:

  1. The final step's onStepEnd calls streamer.markFinishedExternally() and saves the message atomically with the stream finish (src/vercel/client/streamText.ts:216-218).
  2. DeltaStreamer.addParts early-returns once #finishedExternally is set (src/vercel/client/streaming.ts:292-311, "Late deltas would be silently dropped by streams.addDelta — skip the work"), and the flushing finish() never runs for the buffered remainder.
  3. The component refuses deltas on a finished stream row, so whatever the throttle buffer held at that moment is gone.

Awaiting the drain instead of using returnImmediately: true doesn't help, and lowering throttleMs only shrinks the window: in the awaited mode the flag is set before the consumer drains at all.

Why it matters

For the resumable streaming pattern the delta log is the record of an in-flight generation, and it ends up incomplete. Most consumers never notice because useUIMessages replaces the stream with the saved message the moment the save commits, which also hides the bug in testing. Anything that reads the delta log directly, like an HTTP replay endpoint built on streams.listDeltas, gets the truncated version every time.

Expected

Pending deltas flush before, or atomically with, the finishing save. finishHandler / abortById already take an optional finalDelta that is inserted before the state check; the client never passes one on the external-finish path.

Workaround

At terminal turn status we diff delivered chunks against the persisted message and synthesize the missing suffix (drops are always a suffix), continuing the interrupted text part's id so the tail doesn't render as a separate text block. We'd delete that on a fix.

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