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.
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 sameopenai.itemId.Evidence
rs_*reasoning item IDs in OpenAI message history, including exact replays with:itemIdreasoningEncryptedContent"")fc_*IDs are expected because they link tool calls with tool results; the duplicatedrs_*IDs are the problem.Likely root cause
1. Client transport is not workflow-aware
apps/web/lib/abortable-chat-transport.tsextends AI SDKDefaultChatTransport; 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.tsapps/web/app/api/chat/[chatId]/stream/route.tsBoth currently call
run.getReadable()without astartIndex.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: trueis 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 areasoning-startchunk is replayed, the client can append another blank reasoning part with the sameopenai.itemIdandreasoningEncryptedContent, which matches the broken payloads we observed.Impact
rs_*item IDs appear in the input payload.Proposed fix
Immediate guardrail
Deduplicate exact duplicate assistant
rs_*reasoning parts before:Safest first rule:
rs_*reasoning partrs_*IDs were already seen earlierRoot fix
Implement chunk-index-aware workflow resume:
startIndexin reconnect requestsrun.getReadable({ startIndex })Cleanup
Normalize legacy persisted messages on read and/or run a backfill for already-poisoned histories.
Acceptance criteria
rs_*item IDs to OpenAI.