Skip to content

fix(sdk): preserve partial assistant message on chat stream failure#4348

Open
matt-aitken wants to merge 13 commits into
mainfrom
fix/chat-preserve-partial-on-stream-failure
Open

fix(sdk): preserve partial assistant message on chat stream failure#4348
matt-aitken wants to merge 13 commits into
mainfrom
fix/chat-preserve-partial-on-stream-failure

Conversation

@matt-aitken

@matt-aitken matt-aitken commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

When a chat.agent (or chat.createSession) turn's model stream fails mid-response (e.g. a transport timeout like UND_ERR_BODY_TIMEOUT), the assistant output that already streamed was dropped: onTurnComplete fired with responseMessage: undefined, and the manual loop's turn.complete() rethrew without keeping the partial. Apps that register hydrateMessages are 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 a TransformStream, so pass-through backpressure and cancel are preserved) to buffer chunks, and on a source-stream failure reconstructs the partial (preferring the onFinish message). It's surfaced on the error-path onTurnComplete (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 (so turn.uiMessages reflects it and the caller can persist after catching) before turn.complete() rethrows.

onBeforeTurnComplete stays 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:

  • The recovered partial reaches onTurnComplete and the next turn's accumulated messages.
  • An already-committed (possibly enriched) response is not overwritten if a post-response hook then throws.
  • Incomplete tool parts are cleaned from the recovered partial (text kept), so the UI and model views agree and the next turn isn't poisoned.
  • A prior turn's model-only compaction survives an errored turn (append only the new tail, don't reconvert the full history).
  • A reconstructed fragment that reuses an existing message id does not clobber the complete message.
  • Queued chat.response data parts are folded into the recovered partial, matching the success path.
  • newMessages (model delta) stays symmetric with newUIMessages.

Tests

New chat-agent-source-stream-error.test.ts covers the cases above. The full @trigger.dev/sdk unit suite passes and the package build is green across all supported runtimes (Node 20 to 26, Bun, Deno, Cloudflare Workers).

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-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0316b05

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 26 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/python Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/build Patch
@trigger.dev/core Patch
@trigger.dev/react-hooks Patch
@trigger.dev/redis-worker Patch
@trigger.dev/rsc Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/rbac Patch
@trigger.dev/sso Patch
trigger.dev Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch

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

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The SDK buffers streamed chunks and captures partial assistant responses during chat turns. When a source stream fails, chat.agent reconstructs or reuses the partial response and includes it in completion data while reporting an error. chat.createSession accumulates the partial assistant message before rethrowing. Tests cover managed-loop and session behavior, and a patch changeset documents the update.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main SDK change: preserving partial assistant messages on stream failure.
Description check ✅ Passed The description covers summary, scope, implementation details, and testing; only some optional template sections are omitted.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/chat-preserve-partial-on-stream-failure

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

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).
@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@0316b05

trigger.dev

npm i https://pkg.pr.new/trigger.dev@0316b05

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@0316b05

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@0316b05

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@0316b05

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@0316b05

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@0316b05

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@0316b05

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@0316b05

commit: 0316b05

…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.
@matt-aitken

Copy link
Copy Markdown
Member Author

Additional hardening from an adversarial self-review of the error path:

Fixed (real): the chat.agent try spans the whole turn, so a throw from a post-response step (a customer onBeforeTurnComplete/onTurnComplete hook, or a late toModelMessages) lands in the error handler after the response was already committed. The error path would then re-recover a raw partial and overwrite the committed (possibly enriched) message, dropping queued chat.response data parts and re-inflating a prior turn's compaction. Now a per-turn responseCommitted flag skips re-recovery in that case and capturedPartialResponse tracks the enriched committed message. Regression test added (fails without the fix).

Considered, no change needed:

  • Committing an incomplete-tool-call partial can't poison the next turn: toModelMessages uses ignoreIncompleteToolCalls: true, so dangling tool calls are dropped from the model messages. Incomplete parts are intentionally kept in the UI message (the point is to preserve what streamed), matching the success path which only runs cleanupAbortedParts on a user stop.
  • Per-turn chunk buffering retains chunk refs for the turn's duration; this mirrors the existing chat.pipeAndCapture buffering and is freed when the turn ends.

319 SDK tests pass, 0 typecheck errors.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant