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:
- The final step's
onStepEnd calls streamer.markFinishedExternally() and saves the message atomically with the stream finish (src/vercel/client/streamText.ts:216-218).
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.
- 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.
Version:
@convex-dev/agent@0.7.0(same code onmaintoday),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. Underconvex-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:
onStepEndcallsstreamer.markFinishedExternally()and saves the message atomically with the stream finish (src/vercel/client/streamText.ts:216-218).DeltaStreamer.addPartsearly-returns once#finishedExternallyis set (src/vercel/client/streaming.ts:292-311, "Late deltas would be silently dropped bystreams.addDelta— skip the work"), and the flushingfinish()never runs for the buffered remainder.finishedstream row, so whatever the throttle buffer held at that moment is gone.Awaiting the drain instead of using
returnImmediately: truedoesn't help, and loweringthrottleMsonly 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
useUIMessagesreplaces 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 onstreams.listDeltas, gets the truncated version every time.Expected
Pending deltas flush before, or atomically with, the finishing save.
finishHandler/abortByIdalready take an optionalfinalDeltathat 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.