Skip to content

Workflow stream resume can replay OpenAI reasoning rs_* items and poison chat history #545

Description

@nicoalbanese

Summary

OpenAI reasoning models can fail on follow-up turns because we persist and resend duplicate rs_* reasoning items. Investigation points to our workflow stream resumption path replaying chunks from the beginning, which appends duplicate blank reasoning parts with the same openai.itemId.

Evidence

  • We found duplicate rs_* reasoning item IDs in OpenAI message history, including exact replays with:
    • the same itemId
    • the same reasoningEncryptedContent
    • blank text ("")
  • These later copies are standalone assistant reasoning items that add no value and violate OpenAI's requirement that item IDs are unique across the input history.
  • Duplicate fc_* IDs are expected because they link tool calls with tool results; the duplicated rs_* IDs are the problem.

Likely root cause

1. Client transport is not workflow-aware

apps/web/lib/abortable-chat-transport.ts extends AI SDK DefaultChatTransport; it does not use workflow's transport or track chunk positions.

2. Reconnect endpoints replay from the beginning

We reconnect active workflow streams via:

  • apps/web/app/api/chat/route.ts
  • apps/web/app/api/chat/[chatId]/stream/route.ts

Both currently call run.getReadable() without a startIndex.

Workflow's resumable-stream docs require run.getReadable({ startIndex }) so reconnects continue from the last delivered chunk instead of replaying old chunks.

3. AI SDK resume + abort is a bad combination

AI SDK docs explicitly call out that resume: true is incompatible with abort/stop behavior. Our chat flow mixes resume/retry with explicit abort/stop paths, which increases the likelihood of partial replay / duplicate chunk application.

4. Replayed reasoning starts become duplicate blank parts

In @ai-sdk/openai, reasoning parts begin as blank reasoning entries and get their text via later deltas. If a reasoning-start chunk is replayed, the client can append another blank reasoning part with the same openai.itemId and reasoningEncryptedContent, which matches the broken payloads we observed.

Impact

  • Follow-up turns can fail with OpenAI Responses errors because duplicate rs_* item IDs appear in the input payload.
  • Bad history can become durable once polluted assistant messages are persisted and later resent.

Proposed fix

Immediate guardrail

Deduplicate exact duplicate assistant rs_* reasoning parts before:

  • sending messages to OpenAI
  • persisting assistant snapshots

Safest first rule:

  • keep the first occurrence of each exact duplicate rs_* reasoning part
  • drop later exact duplicates
  • drop standalone blank reasoning-only assistant messages if their rs_* IDs were already seen earlier

Root fix

Implement chunk-index-aware workflow resume:

  • track the last received chunk index on the client
  • include startIndex in reconnect requests
  • update workflow stream routes to call run.getReadable({ startIndex })

Cleanup

Normalize legacy persisted messages on read and/or run a backfill for already-poisoned histories.

Acceptance criteria

  • Reconnect/retry/refresh flows do not append duplicate reasoning parts.
  • Follow-up turns no longer send duplicate rs_* item IDs to OpenAI.
  • Stop + retry + resume paths are validated against OpenAI reasoning models.

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