fix(sdk): preserve partial assistant message on chat stream failure#4348
fix(sdk): preserve partial assistant message on chat stream failure#4348matt-aitken wants to merge 13 commits into
Conversation
When a chat turn's model stream fails mid-response (e.g. a transport timeout), the streamed-so-far output is no longer dropped. chat.agent passes the recovered partial to onTurnComplete, and chat.createSession accumulates it before turn.complete() rethrows, so it survives for persistence even when hydrateMessages disables boot-time replay recovery. The turn is still reported as errored.
🦋 Changeset detectedLatest commit: 0316b05 The changes in this PR will be included in the next version bump. This PR includes changesets to release 26 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe SDK buffers streamed chunks and captures partial assistant responses during chat turns. When a source stream fails, 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Commit the recovered partial to the canonical accumulator on the chat.agent error path so the next turn and the reboot snapshot both carry it, matching the success path. Fold queued response parts into the manual-loop error partial too. Add a continuation regression test.
Replace a same-id continuation partial in place instead of dropping it as a dup, commit the errored user message unconditionally so it reaches the next live turn, and push only the appended partial's model messages to preserve a prior turn's compaction (reconvert only when replacing or folding).
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/sdk
commit: |
…tep throws The chat.agent try block spans the whole turn, so a throw from a post-response step (a customer onBeforeTurnComplete/onTurnComplete hook, a late conversion) lands in the error handler after the response was already committed. Track a per-turn responseCommitted flag and keep capturedPartialResponse pointed at the enriched committed message, so the error path reports it without re-recovering a raw partial and clobbering the committed message, its queued data parts, or a prior turn's compaction. Also use truthy id checks to match the success path.
|
Additional hardening from an adversarial self-review of the error path: Fixed (real): the Considered, no change needed:
319 SDK tests pass, 0 typecheck errors. |
The error-path onTurnComplete added the partial to newUIMessages/uiMessages but left newMessages (the model-message delta) empty, so apps persisting the model delta missed the partial. Populate it from the same conversion.
… and desync Drop a reconstructed-from-chunks partial that reuses an existing message id (a fragment can't be safely merged into a complete message the way an onFinish message can), and make the error-path accumulator update atomic so a failed model-message conversion can't leave the UI and model accumulators out of sync.
Tap the response stream with a TransformStream instead of an async generator. The generator forced ensureReadableStream down its eager-drain wrapper, which enqueues without honoring desiredSize (dropping backpressure to the model stream) and never propagates consumer cancel to the source. piping through a TransformStream keeps the ReadableStream pass-through, so native backpressure and stop/cancel propagation are retained. Also stamp an id on a recovered partial that lacks one, matching the success path.
Apply cleanupAbortedParts to the partial recovered on a source-stream failure, in both the managed and manual loops. A transport error leaves the same incomplete-part state as a user stop (streaming text not finalized, dangling tool calls), so the partial is cleaned the same way: text is kept and unfinished tool parts are dropped, keeping the persisted UI history and the model view consistent. Also reset the error event's newMessages delta when the model-message conversion fails, so it can't advertise messages that were rolled back.
On a successful turn the buffered chunks are dead weight (the recovered-partial fallback prefers the onFinish message), yet they were retained through the end-of-turn idle wait. Clear the buffer once the response is committed so a completed turn no longer holds a full copy of its streamed output while idle.
When committing the errored turn's state to the accumulator, append only the new tail's model messages (errored user message and/or recovered partial) instead of reconverting the whole UI history, so a prior turn's model-only compaction survives an errored turn. Skip re-adding the partial once the response was already committed (a later compaction may have folded it into a summary), and roll the event/snapshot views back if the model conversion fails so they never advertise a partial the accumulator didn't take.
…ssages The error-path onTurnComplete reported the errored user message in newUIMessages but not in newMessages (the model-message delta), so a customer persisting the model delta lost it. Derive newMessages from the same set as newUIMessages so the two stay symmetric.
The managed error path now folds data parts queued via chat.response / writer.write() into the recovered partial before persisting, matching the success path and the manual loop, so a data part queued just before a source-stream failure isn't dropped.
…error partial The error-path UI-message array construction tripped the tshy build's stricter check (partialResponse not narrowed by the includePartial flag). Assert non-null where includePartial already guarantees it.
Summary
When a
chat.agent(orchat.createSession) turn's model stream fails mid-response (e.g. a transport timeout likeUND_ERR_BODY_TIMEOUT), the assistant output that already streamed was dropped:onTurnCompletefired withresponseMessage: undefined, and the manual loop'sturn.complete()rethrew without keeping the partial. Apps that registerhydrateMessagesare hit hardest, since boot-time tail-replay recovery is off by design.This preserves the streamed-so-far assistant output while still reporting the turn as errored, so persistence and recovery keep it.
Scope of behavior change
Only the error path changes. Successful turns are unaffected: the same chunks stream to the client in the same order, and backpressure/cancel behave as before. Everything here is a correctness improvement on a turn that hit a source-stream failure.
What it does
Follow-up to #4304 (
chat.pipeAndCapture), extending the same partial-recovery to the two loops that lacked it:chat.agent: taps the response stream (via aTransformStream, so pass-through backpressure and cancel are preserved) to buffer chunks, and on a source-stream failure reconstructs the partial (preferring theonFinishmessage). It's surfaced on the error-pathonTurnComplete(responseMessage,rawResponseMessage,uiMessages,newUIMessages,newMessages) and committed to the accumulator so the next turn and the reboot snapshot keep it.chat.createSession/turn.complete(): the reconstructed partial is accumulated (soturn.uiMessagesreflects it and the caller can persist after catching) beforeturn.complete()rethrows.onBeforeTurnCompletestays skipped on the error path (it hands out a writer for a stream that has already broken).Correctness properties (each covered by a regression test)
Each test below was confirmed to fail without its fix:
onTurnCompleteand the next turn's accumulated messages.chat.responsedata parts are folded into the recovered partial, matching the success path.newMessages(model delta) stays symmetric withnewUIMessages.Tests
New
chat-agent-source-stream-error.test.tscovers the cases above. The full@trigger.dev/sdkunit suite passes and the package build is green across all supported runtimes (Node 20 to 26, Bun, Deno, Cloudflare Workers).