You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part 3 of 4 deferred upstream ports (epic #119). Port of upstream PR #883
(commit 712e1f49, "Update Workflow beta stream handling"). Decision: GO-WITH-CONDITIONS per docs/plans/upstream-883-reflection.md. Protocol
questions are closed by the POC (POC/upstream-883-workflow-v5/FINDINGS.md,
10/10 PASS); the residual risk is at the integration seam (real auth on resume
GET, real-device iOS recovery, double-resume, and the workflowruntime
major bump). This is part 3 of 4 deferred upstream ports.
No migration: this port adds no database schema changes; latest migration
is 0047_cheerful_thunderbolt_ross.sql.
Why this matters
Mid-stream stream drops (Vercel Function timeout, mobile network drop, tab
backgrounding) are a real user-facing failure: today the user can lose assistant
output or see duplicated/replayed content, and the fork papers over this with a
bespoke stall-timer probe loop. Upstream #883 moves stream recovery into the @workflow/aiWorkflowChatTransport, which counts UI chunks and resumes from
the exact next chunk via an indexed GET (startIndex). The user-visible outcome:
on a mid-stream POST drop the client deterministically resumes with zero
duplicate chunks and zero gaps instead of relying on a 4s wall-clock heuristic.
For operators it replaces a fragile timer with a transport-managed protocol and a
correlatable x-workflow-stream-tail-index header.
Beyond user value, this is a prerequisite for upstream #884 (parallel work in chat.ts), which soft-depends on the v5 transport contract (the "single
totally-ordered chunk log" invariant the v5 startIndex protocol indexes).
User/operator path protected
User path: an active assistant stream whose underlying POST connection
drops mid-stream (function timeout / network drop) resumes automatically and
the rendered conversation contains no duplicated or missing content.
User path (mobile/iOS): the "tap stop" action stops the stream on the
first tap (the previously-shipped "need to tap stop 3 times on iOS" bug must
not regress); backgrounding/foregrounding mid-stream resumes cleanly.
User path: page refresh during a live stream resumes and de-dupes (parity
with today — replays from index 0).
Operator path: stream lifecycle is correlatable across POST and resume GET
via chatId, x-workflow-run-id, and x-workflow-stream-tail-index.
Behavior contract
Mid-stream POST drop → seamless resume. Given a chat streaming an
assistant reply, when the POST stream drops without a finish chunk, then the
transport auto-issues GET /api/chat/{chatId}/stream?startIndex=<chunkIndex>
and resumes from the next chunk with zero duplicate chunks and zero gaps.
Refresh resume → parity. Given a live stream, when the user refreshes the
page, then resumeStream() replays from index 0 and the client de-dupes by
message id (identical to today; this is parity, not a regression, and not an
improvement unless a negative initialStartIndex is opted into later).
204 is swallowed. Given the GET resume route returns 204 (no active
stream), when the transport reconnects, then the override returns null and no error is surfaced to the UI.
iOS stop still aborts. Given a user taps "stop" mid-stream, when stop
fires, then the stream aborts on the first tap and auto-recovery does not
immediately re-attach to the still-running server stream (userStoppedRef
suppression intact).
Server/client chunk index alignment. Given the durable readable is a
single append-only chunk log, when the route is called with startIndex=K,
then run.getReadable({ startIndex: K }) seeks the same absolute chunk the
transport's global chunkIndex counted (no re-emitted start/text-start
preamble).
Product and design spec
Entry point: transparent to the user — no new UI control. Recovery happens
inside AbortableChatTransport and GET /api/chat/[chatId]/stream.
Primary flow: stream renders incrementally; on a drop, content continues
appending after a brief gap with no visible duplication or "Load failed"
banner for the recoverable (POST-drop) case.
States:
streaming — chunks render via experimental_throttle (75ms).
recovering (POST drop) — transport-internal reconnect; no user-visible
error.
recovering (refresh/visibility) — resumeStream() from index 0; de-dupes
by id.
no active stream — GET returns 204; resume is a cheap no-op (returns null).
error (non-204, e.g. 401/403/expired session) — surfaces as a clean error
that routes through the surviving retry-error branch; must NOT wedge into
an infinite probe loop.
Accessibility: no change to focus order or ARIA; streamed text continues to
append to the existing assistant message region.
Copy: no new copy. Existing error/retry copy unchanged.
Permissions: resume GET stays behind requireAuthenticatedUser + requireOwnedChatById; ownership and auth are unchanged.
Error/empty/loading: empty (no active stream) → 204/no-op; loading →
normal streaming; error (non-204) → existing error banner + retry path.
Contains iOS userStoppedRef mitigation (lines 155–249) and speculative mount-resume probe (lines 296–354); upstream has no counterpart; deleting either would regress iOS stop behavior
Keeps the surviving visibility-probe branch post-#883; the stall-timer effect goes inert but is NOT deleted (follow-up cleanup scope)
Key fork-specific overrides that must survive the transport re-base
prepareReconnectToStreamRequest: ({ id }) => ({ api: /api/chat/${id}/stream }) — this is what makes our DB-chatId reconnect model work (overrides v5 default runId-derived URL). POC Scenario 1 verified it routes the resume GET to /api/chat/{chatId}/stream, not a runId URL.
reconnectToStream 204-swallow override — v5 throws Failed to fetch chat: 204 on no-active-stream; the override returns null (POC Sub-goal 4 confirmed this is still required under v5).
AbortableChatTransport.abort() — still needed because WorkflowChatTransport.reconnectToStream does not thread an abort signal through its internal fetch.
Workflow producer surface (chat.ts) — unchanged by #883
chat.ts uses getWritable<UIMessageChunk>() (line 1009), getRun (line 2187), claimActiveStream (line 1043), getWorkflowMetadata. The #883 diff changes no
producer code. However, the workflowruntime major bump (v4.2.4 → v5 beta)
is a broader surface and must be validated end-to-end by a full CI run and
managed-runtime smoke.
AI-SDK version skew — confirmed no forced bump
@workflow/ai peers ai: "^6". Fork resolves ai@6.0.168 via catalog ^6.0.165. @workflow/ai@5.0.0-beta.4 also peers @ai-sdk/provider: "^3.0.0";
fork satisfies this via @ai-sdk/provider@3.0.8 in bun.lock. No ai or @ai-sdk/* upgrade is forced. The ~32 other from "ai" import sites in apps/web (generateText, streamText, InferUIMessageChunk, UIMessage types, etc.)
are unaffected. Confirmed by reflection doc §4 and POC "Dependency / peer
surprises."
Dependency pinning — drop carets, pin exact
Per reflection doc §5 and the dependency-pinning hazard:
// apps/web/package.json — exact pins, no carets"@workflow/ai": "5.0.0-beta.4",
"workflow": "5.0.0-beta.5"
Rationale: @workflow/ai@5.0.0-beta.6 peers workflow ^5.0.0-beta.8; with
caret ranges on both, a fresh bun install can float to mismatched beta minors
of API-unstable packages. The transport .d.ts is byte-identical beta.4→beta.6
(POC verified), but this is not guaranteed for future betas. Pin exact; let the
lockfile be the single source of truth.
WorkflowChatTransportOptions API surface (POC-proven)
The flat interface (does NOT extend HttpChatTransportInitOptions):
Option is named fetch (NOT customFetch — any stale docs saying otherwise
are wrong for this beta line).
headers, credentials, body are NOT native options — the fork adds body
via intersection and folds it through prepareSendMessagesRequest.
prepareReconnectToStreamRequest and prepareSendMessagesRequest are the AI
SDK 6 types re-exported — same callback shape the fork already uses.
In scope
Bump workflow → "5.0.0-beta.5" (exact) and add @workflow/ai → "5.0.0-beta.4" (exact) in apps/web/package.json; regenerate bun.lock.
Re-base AbortableChatTransport onto WorkflowChatTransport; keep the abort
wrapper, the body intersection + prepareSendMessagesRequest fold, the
204-swallow reconnectToStream override, and the prepareReconnectToStreamRequest
chatId override.
Add startIndex parsing (+ 400 on invalid) and the x-workflow-stream-tail-index response header to the GET stream route.
Collapse stream-recovery-policy.ts stall branch to return "none" and shouldScheduleStallRecovery → always false (upstream as-is); update stream-recovery-policy.test.ts and stream/route.test.ts tests-first.
Prove integration: resume GET under real auth (expired session → graceful,
non-wedging error), real-device iOS stop/background/refresh, and a
managed-runtime smoke against the v5 runtime.
Out of scope
Dead-code cleanup of the now-vestigial STREAM_RECOVERY_STALL_MS, getStreamRecoveryDelayMs, and the inert stall-timer effect / inFlightStartedAt
plumbing in use-stream-recovery.ts. These stay (still exported/referenced)
and should be removed in a separate, clearly-scoped follow-up so the port diff
stays reviewable.
Negative initialStartIndex opt-in for cheap refresh-resume (skipping
already-rendered chunks on refresh). The capability exists (proven in POC
Scenario 3) but is optional and not required for parity.
docs/plans/upstream-883-reflection.md — deep reflection;
GO-WITH-CONDITIONS verdict; the 11-item conditions checklist folded into
Definition of done below; identifies the real risk (fork-only client recovery
the patch does not touch) and the double-resume hazard.
POC/upstream-883-workflow-v5/FINDINGS.md — 10/10 PASS. Definitive
v5 transport surface (fetch not customFetch; flat options interface;
defaults), abort/resume proof, chunk-index semantics, and DB-chatId
compatibility.
Vercel Workflow v5 docs — transport options, run.getReadable({ startIndex })
/ getTailIndex() semantics, and the runId+localStorage reconnect model the
fork overrides. Fetch current docs via ctx7 / Context7 (Vercel Workflow)
before relying on training data, since the betas are fast-moving.
Fork sources read during scoping: apps/web/lib/abortable-chat-transport.ts, apps/web/app/api/chat/[chatId]/stream/route.ts, apps/web/app/sessions/[sessionId]/chats/[chatId]/stream-recovery-policy.ts, apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts.
Agent todo checklist
Re-run the POC harness (POC/upstream-883-workflow-v5) against the
versions that actually resolve to confirm the protocol surface still holds.
Pin exact betas in apps/web/package.json: workflow: "5.0.0-beta.5", @workflow/ai: "5.0.0-beta.4" (no carets). Regenerate bun.lock
deliberately; confirm ai stays 6.0.168 and no @ai-sdk/* skew is
forced.
Add failing tests first: update stream-recovery-policy.test.ts for the
collapsed stall branch (expects "probe" → should be "none"; expects true → should be false); add startIndex + x-workflow-stream-tail-index
+ invalid-startIndex→400 coverage to stream/route.test.ts. Confirm
RED.
Mid-stream POST drop, zero-duplicate resume (transport/integration): POST
delivers chunks 0..N, drops without finish; assert the transport reconnects
with startIndex=N+1 and the delivered chunk set has no duplicates and no
gaps. (Mirrors POC Scenario 1.)
Refresh resume replays from index 0 (parity): resumeStream() sends no startIndex; assert replay-from-0 + de-dupe by id (matches today).
204 swallowed: GET resume route returns 204; assert the reconnectToStream override returns null and no error surfaces.
iOS stop mitigation: explicit stop sets userStoppedRef; assert auto retryChatStream({ auto: true }) clears the error and does NOT re-attach.
User-visible status: recoverable POST-drop resumes with no error banner and
no duplicated text; non-204 resume errors surface the existing retry banner.
Responsible surfaces:GET /api/chat/[chatId]/stream (route) and AbortableChatTransport (client transport) own stream-lifecycle signals.
Correlation IDs: every stream lifecycle event correlates by chatId, x-workflow-run-id (POST response header → chat.activeStreamId), and x-workflow-stream-tail-index (GET resume response header). Resume requests
hit GET /api/chat/{chatId}/stream?startIndex=<int>.
Events/fields: route logs the resume entry with { chatId, workflowRunId (activeStreamId), startIndex, tailIndex, status }; transport-level abort and
reconnect are attributable to chatId. Typed error kinds: errorKind: "resume-204" (swallowed → no-op), resume-auth (401/403), resume-failed
(other non-OK). The single ordered getWritable<UIMessageChunk>() chunk log is
the index the resume seeks — keep it one totally-ordered sequence.
Redaction: never log message/prompt content, session cookies, or provider
tokens; log only ids, indexes, status, and error kind.
Debug recipe:grep '"chatId":"<id>"' logs | grep 'stream' to follow a
POST→drop→resume sequence; confirm startIndex on the resume GET advances and x-workflow-stream-tail-index is present.
Caveat for metrics: the v5 resume-skip means the client may legitimately
NOT see chunks 0..K on a reconnect — confirm no client-side metric counts on
full-stream delivery.
Managed-runtime proof: because this is a workflow-runtime major bump
(broader surface than the transport-only POC), acceptance requires a full bun --bun run ci against the v5 runtime + a managed-runtime smoke per docs/process/managed-runtime-proof-standard.md (target Level 2: local-or-live
sandbox proof exercising start/getRun/getReadable/getWritable/cancel
end-to-end, with provenance, command evidence, verification, and limitations
recorded).
Regression harness plan
Route contract test (stream/route.test.ts): startIndex passthrough, x-workflow-stream-tail-index header, invalid-startIndex→400. Fail-before:
no startIndex/header handling. Pass-after: covered.
Transport behavior test: mid-stream-drop reconnect delivers each chunk
exactly once (no dup, no gap); 204 → null.
Managed-runtime smoke against the v5 runtime (manual proof captured in the
PR if no automated harness reaches the real runtime) — required because the
POC ran against a Node http harness, not our real stack.
Manual real-device iOS smoke (no continuous harness practical): stop /
background-foreground / refresh, captured as PR evidence.
TDD audit trail
RED commit: failing stream/route.test.ts (+startIndex/header/400) and
updated stream-recovery-policy.test.ts (collapsed branch), committed
test-only on the work branch; record the RED command + expected failure reason.
GREEN commit: the three-file patch + dependency pins + regenerated bun.lock that turns the targeted tests green; record the GREEN command and
the adjacent-suite + bun --bun run ci results.
REGRESSION commit: confirm the full suite (all touched suites) still passes
after any follow-up integration-proof adjustments.
Regression risks and concerns
Double-resume (highest fork-specific risk): v5's internal reconnectToStreamIterator AND our visibility-probe / speculative
mount-resume loops can both fire. The SDK should reconcile by message id, but
this is UNPROVEN on a real device. Watch for visible jank / chunk replay on top
of a live stream.
iOS "tap stop 3 times": the userStoppedRef mitigation guards a
previously-shipped bug upstream never exercised (upstream has no use-session-chat-runtime.ts). Removing it to "match upstream" would regress
iOS stop. KEEP it; prove one-tap-stop on a real device.
Resume GET under real auth: the POC harness had no auth. An expired-session
reconnect throws a non-204 (e.g. Failed to fetch chat: 401) that the 204-only
override does NOT swallow — must degrade to a clean error via the surviving retry-error branch, not an infinite probe loop. Add an explicit test.
workflow runtime major bump:feat: unblock authenticated production canary and wire journey harnesses (#866) #883's diff changes no producer code, but
v4.2.4 → v5 beta is a broader surface (start, getRun, getReadable, getWritable, getWorkflowMetadata, claimActiveStream durability, cancel). The POC only validated the transport in isolation — a full CI run +
managed-runtime smoke is required, not optional.
Beta peer drift:@workflow/ai@5.0.0-beta.4 peers workflow ^5.0.0-beta.5, but @workflow/ai@5.0.0-beta.6 peers ^5.0.0-beta.8. Caret
ranges on both can float to mismatched beta minors on a fresh install — hence
exact pins.
Test churn: both touched test files exist in the fork and may have
diverged; land the updates tests-first.
Dependency bump only (workflow major + new @workflow/ai); no database
migration. Latest migration is 0047_cheerful_thunderbolt_ross.sql.
Regenerate bun.lock (note: upstream lockfile drops configVersion: 1 —
reconcile deliberately during regeneration).
Migrations still run on bun run build, but this change adds none.
Risk profile: the v5 runtime is a broader surface than the transport.
Require integration proof before deploy: resume under real auth/expired
session; real-device iOS stop/background/refresh; managed-runtime smoke.
Folds the reflection's 11-item conditions checklist (docs/plans/upstream-883-reflection.md §8):
Pin exact betas:workflow: "5.0.0-beta.5" and @workflow/ai: "5.0.0-beta.4" (no carets); bun.lock regenerated deliberately; POC
harness re-run against the resolved versions.
204-swallow reconnectToStream override kept: compiles; no-active-stream
path returns null, not an error.
prepareReconnectToStreamRequest: ({ id }) => ({ api: /api/chat/${id}/stream }) kept:
test/trace confirms the resume GET hits the chatId URL, not a runId URL.
Fork-only recovery logic NOT deleted: iOS userStoppedRef mitigation
and speculative mount-resume probe in use-session-chat-runtime.ts, and
the surviving visibility-probe branch in use-stream-recovery.ts,
preserved.
Tests-first for the touched test files:stream-recovery-policy.test.ts
updated for the deleted stall branch; stream/route.test.ts covers startIndex + x-workflow-stream-tail-index + invalid→400.
Resume GET proven under real auth: an expired-session reconnect returns
a non-204 status that degrades to a clean error and does NOT wedge the
recovery loop.
Real-device iOS smoke (highest-priority manual check): (a) stop
mid-stream → one tap; (b) background/foreground mid-stream → resume with no
duplicate content; (c) refresh mid-stream → replays-from-0 + de-dupes
(parity with today).
Full bun --bun run ci + managed-runtime smoke against the v5 runtime
(not just the transport): start/getRun/getReadable/getWritable/ cancel exercised end-to-end, with proof evidence captured per the Managed
Runtime Proof Standard (target Level 2).
Follow-up recorded (separate, optional): delete the vestigial STREAM_RECOVERY_STALL_MS / getStreamRecoveryDelayMs / dead stall-timer
effect in a scoped cleanup after the port lands green.
git diff --check clean; RED and GREEN commits captured in the TDD audit
trail; change committed, pushed to the fork, and a PR opened against dennisonbertram/fork-open-agents.
Assumptions and alternatives
Assumptions
The exact version pins (workflow@5.0.0-beta.5, @workflow/ai@5.0.0-beta.4)
are still installable from the npm registry. If they have been yanked, the
latest stable beta in the ^5.0.0-beta.5 / ^5.0.0-beta.4 range should be
used after re-running the POC typecheck harness to verify the transport .d.ts
is still byte-compatible.
The three fork source files remain byte-identical to 712e1f49^ (the
reflection confirmed this; the current git show confirms the fork has not
touched these files since). Any divergence discovered at port time means the
patch must be hand-applied rather than cherry-picked.
The AI SDK (ai@6.0.168, @ai-sdk/react@3.0.170) stays pinned. If a
subsequent grooming step bumps ai, re-verify the @workflow/ai peer
constraint.
Alternatives considered
Preserve fork recovery vs adopt upstream WorkflowChatTransport recovery:
Chosen approach is BOTH — adopt the v5 transport for mid-stream-drop recovery
while keeping all fork-only recovery logic (iOS, speculative probe, visibility
probe) that upstream never exercised. Upstream-only approach (delete fork
recovery logic) would regress iOS stop behavior. Fork-only approach (keep DefaultChatTransport) foregoes the deterministic chunk-index resume protocol.
Caret ranges vs exact pins for beta deps: Chosen is exact pins. The
dependency-pinning hazard (mismatched beta peers on fresh install) is well-documented
in the reflection. The alternative (caret ranges, rely on lockfile) is riskier
for fast-moving betas in CI environments that may reinstall.
Negative initialStartIndex for refresh-resume: Out of scope for parity.
Would require opting into a new v5 capability that the current behavior contract
does not require and that has not been user-tested.
Grooming clarifications (2026-05-31)
Groomed by: issue-groomer autonomous agent, read-only research pass. Grooming verdict: STATUS:READY — the reflection doc provides a GO-WITH-CONDITIONS
decision backed by a 10/10 POC, all conditions are documentable as a concrete
implementation plan (the DoD above), and no genuine unresolved strategic decision
remains.
What this grooming pass confirmed (grounding against live fork state):
apps/web/app/sessions/[sessionId]/chats/[chatId]/stream-recovery-policy.ts
still has the full stall-detection branch (lines 60–72) and shouldScheduleStallRecovery returns a real computation. Direct read confirmed.
use-session-chat-runtime.ts contains the iOS userStoppedRef mitigation
(lines 155–249) and speculative mount-resume probe (lines 296–354). Direct read
confirmed. These are fork-only; upstream commit 712e1f4 does not touch this file.
Upstream commit 712e1f49 is reachable; git show 712e1f49 --stat confirmed
the 7-file diff (+244/-73 lines).
bun.lock in the upstream commit shows the full @workflow/* constellation
bumping from v4 to v5.0.0-beta.5, and @workflow/ai@5.0.0-beta.4 being added.
Fork's bun.lock does not have @workflow/ai. Confirmed.
What required no update: the existing issue body (as groomed in the prior
session) is already highly complete and accurate. This grooming pass validated
the body against current fork state and upstream commit reality. The only
substantive additions in this pass: (a) explicit sequencing table (depends on /
blocks); (b) confirmed migration number (0047); (c) this provenance block.
Risks flagged for human awareness (not blockers):
Real-device iOS smoke and double-resume interaction (transport auto-reconnect
visibility-probe + speculative-probe) are UNPROVEN on real hardware. The DoD
requires this proof before the PR can be considered done. No automated harness
can substitute.
The workflow runtime v5 major bump covers more surface than the POC validated
(POC tested transport only, not start/getRun/claimActiveStream/cancel).
Full managed-runtime smoke is required in the green phase, not optional.
Beta version availability: if workflow@5.0.0-beta.5 or @workflow/ai@5.0.0-beta.4 have been yanked from npm by the time the port
begins, the implementer must re-run the POC typecheck with the next available
compatible beta and confirm the .d.ts surface is still compatible.
Provenance: research sources — docs/plans/upstream-883-reflection.md (full
read), docs/context/command-intent.md (full read), gh issue view 117 (full
read), git show 712e1f49 --stat + full diff (confirmed), direct reads of abortable-chat-transport.ts, stream/route.ts, stream-recovery-policy.ts, use-session-chat-runtime.ts, package.json, migration directory, POC/upstream-883-workflow-v5/FINDINGS.md (header).
Guardrail: created on dennisonbertram/fork-open-agents (the fork). Do not push
any artifact to vercel-labs/open-agents.
Sequencing and dependencies
of feat: port upstream #880 — start sandbox provisioning on session create #115 and feat: port upstream #881 — expose sandbox actions with lazy startup #116, which port fix: loop run detail page's live polling wedges — shows stale 'Running' snapshot long after the run failed #880 and fix: #877 loop settings edits marked dirty and saved via one path #881 respectively).
upstream fix: #879 honor guardrails embedded in the run's definitionSnapshot #884 soft-depends on the v5 transport contract (the "single
totally-ordered chunk log" invariant that the v5
startIndexprotocolindexes). feat: port upstream #884 — parallelize chat workflow startup #118 must land after feat: port upstream #883 — adopt Workflow v5 stream handling (WorkflowChatTransport) #117. The feat: port upstream #884 — parallelize chat workflow startup #118 ticket must record the
"single totally-ordered chunk log" invariant as a constraint in its DoD.
v5 reconciliation as out-of-scope pending this port. Landing feat: port upstream #883 — adopt Workflow v5 stream handling (WorkflowChatTransport) #117 is the
signal to revisit those notes and confirm none require follow-up work.
is
0047_cheerful_thunderbolt_ross.sql.Why this matters
Mid-stream stream drops (Vercel Function timeout, mobile network drop, tab
backgrounding) are a real user-facing failure: today the user can lose assistant
output or see duplicated/replayed content, and the fork papers over this with a
bespoke stall-timer probe loop. Upstream #883 moves stream recovery into the
@workflow/aiWorkflowChatTransport, which counts UI chunks and resumes fromthe exact next chunk via an indexed GET (
startIndex). The user-visible outcome:on a mid-stream POST drop the client deterministically resumes with zero
duplicate chunks and zero gaps instead of relying on a 4s wall-clock heuristic.
For operators it replaces a fragile timer with a transport-managed protocol and a
correlatable
x-workflow-stream-tail-indexheader.Beyond user value, this is a prerequisite for upstream #884 (parallel work in
chat.ts), which soft-depends on the v5 transport contract (the "singletotally-ordered chunk log" invariant the v5
startIndexprotocol indexes).User/operator path protected
drops mid-stream (function timeout / network drop) resumes automatically and
the rendered conversation contains no duplicated or missing content.
first tap (the previously-shipped "need to tap stop 3 times on iOS" bug must
not regress); backgrounding/foregrounding mid-stream resumes cleanly.
with today — replays from index 0).
via
chatId,x-workflow-run-id, andx-workflow-stream-tail-index.Behavior contract
assistant reply, when the POST stream drops without a
finishchunk, then thetransport auto-issues
GET /api/chat/{chatId}/stream?startIndex=<chunkIndex>and resumes from the next chunk with zero duplicate chunks and zero gaps.
page, then
resumeStream()replays from index 0 and the client de-dupes bymessage id (identical to today; this is parity, not a regression, and not an
improvement unless a negative
initialStartIndexis opted into later).204(no activestream), when the transport reconnects, then the override returns
nullandno error is surfaced to the UI.
fires, then the stream aborts on the first tap and auto-recovery does not
immediately re-attach to the still-running server stream (
userStoppedRefsuppression intact).
single append-only chunk log, when the route is called with
startIndex=K,then
run.getReadable({ startIndex: K })seeks the same absolute chunk thetransport's global
chunkIndexcounted (no re-emittedstart/text-startpreamble).
Product and design spec
inside
AbortableChatTransportandGET /api/chat/[chatId]/stream.appending after a brief gap with no visible duplication or "Load failed"
banner for the recoverable (POST-drop) case.
experimental_throttle(75ms).error.
resumeStream()from index 0; de-dupesby id.
204; resume is a cheap no-op (returnsnull).that routes through the surviving
retry-errorbranch; must NOT wedge intoan infinite probe loop.
append to the existing assistant message region.
requireAuthenticatedUser+requireOwnedChatById; ownership and auth are unchanged.204/no-op; loading →normal streaming; error (non-204) → existing error banner + retry path.
Integration spec
Source of truth
Upstream commit
712e1f49(PR #883) touches 7 files (3 load-bearing sourcefiles + 2 test files +
package.json+bun.lock):apps/web/package.jsonworkflow ^4.2.0-beta.72 → ^5.0.0-beta.5; add@workflow/ai ^5.0.0-beta.4workflow ^4.2.0-beta.72(resolves4.2.4);@workflow/aiabsentworkflow: "5.0.0-beta.5",@workflow/ai: "5.0.0-beta.4"(no carets per reflection §5)apps/web/lib/abortable-chat-transport.tsDefaultChatTransport(ai) →WorkflowChatTransport(@workflow/ai); addbodyintersection +prepareSendMessagesRequestfold +reconnectToStream204-swallow overrideDefaultChatTransport(byte-identical to712e1f49^; verified by reflection doc §0)prepareReconnectToStreamRequestchatId override (fork-only); keep 204-swallowapps/web/app/api/chat/[chatId]/stream/route.tsparseStartIndex,run.getReadable({ startIndex }),getTailIndex(),x-workflow-stream-tail-indexheader;GETnow acceptsrequestparam712e1f49^(verified)apps/web/app/sessions/[sessionId]/chats/[chatId]/stream-recovery-policy.tsgetStreamRecoveryDecisiontoreturn "none";shouldScheduleStallRecovery→ alwaysreturn false712e1f49^)apps/web/app/api/chat/[chatId]/stream/route.test.tsstartIndexpassthrough,x-workflow-stream-tail-index, invalid-startIndex→400testsapps/web/app/sessions/[sessionId]/chats/[chatId]/stream-recovery-policy.test.ts"none"/false"probe"/trueexpectationsbun.lock@workflow/*from v4.2.x to v5.0.0-beta.5 constellation; adds@workflow/ai@5.0.0-beta.4; dropsconfigVersion: 1@workflow/core@4.2.4;@workflow/aiabsentconfigVersion: 1dropFork-only files NOT in the diff — keep unchanged
apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.tsuserStoppedRefmitigation (lines 155–249) and speculative mount-resume probe (lines 296–354); upstream has no counterpart; deleting either would regress iOS stop behaviorapps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-stream-recovery.tsprobebranch post-#883; the stall-timer effect goes inert but is NOT deleted (follow-up cleanup scope)Key fork-specific overrides that must survive the transport re-base
prepareReconnectToStreamRequest: ({ id }) => ({ api: /api/chat/${id}/stream })— this is what makes our DB-chatId reconnect model work (overrides v5 default runId-derived URL). POC Scenario 1 verified it routes the resume GET to/api/chat/{chatId}/stream, not a runId URL.reconnectToStream204-swallow override — v5 throwsFailed to fetch chat: 204on no-active-stream; the override returnsnull(POC Sub-goal 4 confirmed this is still required under v5).AbortableChatTransport.abort()— still needed becauseWorkflowChatTransport.reconnectToStreamdoes not thread an abort signal through its internal fetch.Workflow producer surface (
chat.ts) — unchanged by #883chat.tsusesgetWritable<UIMessageChunk>()(line 1009),getRun(line 2187),claimActiveStream(line 1043),getWorkflowMetadata. The #883 diff changes noproducer code. However, the
workflowruntime major bump (v4.2.4 → v5 beta)is a broader surface and must be validated end-to-end by a full CI run and
managed-runtime smoke.
AI-SDK version skew — confirmed no forced bump
@workflow/aipeersai: "^6". Fork resolvesai@6.0.168via catalog^6.0.165.@workflow/ai@5.0.0-beta.4also peers@ai-sdk/provider: "^3.0.0";fork satisfies this via
@ai-sdk/provider@3.0.8inbun.lock. Noaior@ai-sdk/*upgrade is forced. The ~32 otherfrom "ai"import sites inapps/web(generateText, streamText, InferUIMessageChunk, UIMessage types, etc.)are unaffected. Confirmed by reflection doc §4 and POC "Dependency / peer
surprises."
Dependency pinning — drop carets, pin exact
Per reflection doc §5 and the dependency-pinning hazard:
Rationale:
@workflow/ai@5.0.0-beta.6peersworkflow ^5.0.0-beta.8; withcaret ranges on both, a fresh
bun installcan float to mismatched beta minorsof API-unstable packages. The transport
.d.tsis byte-identical beta.4→beta.6(POC verified), but this is not guaranteed for future betas. Pin exact; let the
lockfile be the single source of truth.
WorkflowChatTransportOptionsAPI surface (POC-proven)The flat interface (does NOT extend
HttpChatTransportInitOptions):fetch(NOTcustomFetch— any stale docs saying otherwiseare wrong for this beta line).
headers,credentials,bodyare NOT native options — the fork addsbodyvia intersection and folds it through
prepareSendMessagesRequest.prepareReconnectToStreamRequestandprepareSendMessagesRequestare the AISDK 6 types re-exported — same callback shape the fork already uses.
In scope
workflow→"5.0.0-beta.5"(exact) and add@workflow/ai→"5.0.0-beta.4"(exact) inapps/web/package.json; regeneratebun.lock.AbortableChatTransportontoWorkflowChatTransport; keep the abortwrapper, the
bodyintersection +prepareSendMessagesRequestfold, the204-swallow
reconnectToStreamoverride, and theprepareReconnectToStreamRequestchatId override.
startIndexparsing (+400on invalid) and thex-workflow-stream-tail-indexresponse header to the GET stream route.stream-recovery-policy.tsstall branch toreturn "none"andshouldScheduleStallRecovery→ alwaysfalse(upstream as-is); updatestream-recovery-policy.test.tsandstream/route.test.tstests-first.userStoppedRef, speculativemount-resume probe, DB-chatId reconnect model, visibility-
probebranch).non-wedging error), real-device iOS stop/background/refresh, and a
managed-runtime smoke against the v5 runtime.
Out of scope
STREAM_RECOVERY_STALL_MS,getStreamRecoveryDelayMs, and the inert stall-timer effect /inFlightStartedAtplumbing in
use-stream-recovery.ts. These stay (still exported/referenced)and should be removed in a separate, clearly-scoped follow-up so the port diff
stays reviewable.
initialStartIndexopt-in for cheap refresh-resume (skippingalready-rendered chunks on refresh). The capability exists (proven in POC
Scenario 3) but is optional and not required for parity.
onChatEnd/onChatSendMessageobservability hooks.chat.ts. This ticket is part 3 of 4 deferredupstream ports; fix: #879 honor guardrails embedded in the run's definitionSnapshot #884 soft-depends on the v5 transport contract and must honor
the "single totally-ordered chunk log" invariant (parallel computation,
serialized emission into one
getWritable) — record that constraint in thefeat: port upstream #884 — parallelize chat workflow startup #118 ticket, do not implement it here.
ai/@ai-sdk/*upgrade (none is forced by@workflow/ai).Research and context sources
712e1f49(PR feat: unblock authenticated production canary and wire journey harnesses (#866) #883, "Update Workflow beta streamhandling") — 7-file diff being ported (3 load-bearing source files +
2 test files +
package.json+bun.lock). Our 3 changed source files arebyte-identical to upstream's pre-feat: unblock authenticated production canary and wire journey harnesses (#866) #883 state, so the patch applies cleanly.
docs/plans/upstream-883-reflection.md— deep reflection;GO-WITH-CONDITIONS verdict; the 11-item conditions checklist folded into
Definition of done below; identifies the real risk (fork-only client recovery
the patch does not touch) and the double-resume hazard.
POC/upstream-883-workflow-v5/FINDINGS.md— 10/10 PASS. Definitivev5 transport surface (
fetchnotcustomFetch; flat options interface;defaults), abort/resume proof, chunk-index semantics, and DB-chatId
compatibility.
run.getReadable({ startIndex })/
getTailIndex()semantics, and the runId+localStorage reconnect model thefork overrides. Fetch current docs via
ctx7/ Context7 (Vercel Workflow)before relying on training data, since the betas are fast-moving.
apps/web/lib/abortable-chat-transport.ts,apps/web/app/api/chat/[chatId]/stream/route.ts,apps/web/app/sessions/[sessionId]/chats/[chatId]/stream-recovery-policy.ts,apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts.Agent todo checklist
POC/upstream-883-workflow-v5) against theversions that actually resolve to confirm the protocol surface still holds.
apps/web/package.json:workflow: "5.0.0-beta.5",@workflow/ai: "5.0.0-beta.4"(no carets). Regeneratebun.lockdeliberately; confirm
aistays6.0.168and no@ai-sdk/*skew isforced.
stream-recovery-policy.test.tsfor thecollapsed stall branch (expects
"probe"→ should be"none"; expectstrue→ should befalse); addstartIndex+x-workflow-stream-tail-index+ invalid-
startIndex→400coverage tostream/route.test.ts. ConfirmRED.
abortable-chat-transport.tsontoWorkflowChatTransport(keep abort wrapper,bodyintersection,prepareSendMessagesRequestfold, 204-swallowreconnectToStreamoverride,
prepareReconnectToStreamRequestchatId override).stream/route.ts: parsestartIndex(reject non-integer with400), callrun.getReadable({ startIndex }), awaitgetTailIndex(),return
x-workflow-stream-tail-indexheader.stream-recovery-policy.tsstall branch toreturn "none"andshouldScheduleStallRecovery→false(upstream as-is). Keepretry-errorand visibility-probebranches.use-session-chat-runtime.tsfork-only logic is UNCHANGED:prepareReconnectToStreamRequestchatId override,userStoppedRefiOS mitigation, speculative mount-resume probe.
/api/chat/{chatId}/stream, not a runId URL.error and does NOT wedge the recovery/probe loop.
bun testfor the two touched suites, thenbun --bun run ci(full v5-runtime CI) and
git diff --check.(
start/getRun/getReadable/getWritable/cancel).background/foreground mid-stream = resume, no duplicate content; (c)
refresh mid-stream = replays-from-0 + de-dupes.
fork; open the PR.
feat: port upstream #884 — parallelize chat workflow startup #118 ticket before or alongside this PR.
Tests to add first
delivers chunks 0..N, drops without
finish; assert the transport reconnectswith
startIndex=N+1and the delivered chunk set has no duplicates and nogaps. (Mirrors POC Scenario 1.)
resumeStream()sends nostartIndex; assert replay-from-0 + de-dupe by id (matches today).204; assert thereconnectToStreamoverride returnsnulland no error surfaces.userStoppedRef; assert autoretryChatStream({ auto: true })clears the error and does NOT re-attach.GET .../stream?startIndex=8→run.getReadable({ startIndex: 8 })receives8, response carriesx-workflow-stream-tail-index; invalidstartIndex→400. (Mirrorsupstream
route.test.tsadditions.)Observability and user feedback
no duplicated text; non-204 resume errors surface the existing retry banner.
GET /api/chat/[chatId]/stream(route) andAbortableChatTransport(client transport) own stream-lifecycle signals.chatId,x-workflow-run-id(POST response header →chat.activeStreamId), andx-workflow-stream-tail-index(GET resume response header). Resume requestshit
GET /api/chat/{chatId}/stream?startIndex=<int>.{ chatId, workflowRunId (activeStreamId), startIndex, tailIndex, status }; transport-level abort andreconnect are attributable to
chatId. Typed error kinds:errorKind: "resume-204"(swallowed → no-op),resume-auth(401/403),resume-failed(other non-OK). The single ordered
getWritable<UIMessageChunk>()chunk log isthe index the resume seeks — keep it one totally-ordered sequence.
tokens; log only ids, indexes, status, and error kind.
grep '"chatId":"<id>"' logs | grep 'stream'to follow aPOST→drop→resume sequence; confirm
startIndexon the resume GET advances andx-workflow-stream-tail-indexis present.NOT see chunks
0..Kon a reconnect — confirm no client-side metric counts onfull-stream delivery.
(broader surface than the transport-only POC), acceptance requires a full
bun --bun run ciagainst the v5 runtime + a managed-runtime smoke perdocs/process/managed-runtime-proof-standard.md(target Level 2: local-or-livesandbox proof exercising
start/getRun/getReadable/getWritable/cancelend-to-end, with provenance, command evidence, verification, and limitations
recorded).
Regression harness plan
stream/route.test.ts):startIndexpassthrough,x-workflow-stream-tail-indexheader, invalid-startIndex→400. Fail-before:no
startIndex/header handling. Pass-after: covered.stream-recovery-policy.test.ts): collapsed stall branchreturns
"none";shouldScheduleStallRecoveryreturnsfalse. Fail-before:expects
"probe"/true. Pass-after:"none"/false.exactly once (no dup, no gap); 204 →
null.PR if no automated harness reaches the real runtime) — required because the
POC ran against a Node http harness, not our real stack.
background-foreground / refresh, captured as PR evidence.
TDD audit trail
stream/route.test.ts(+startIndex/header/400) andupdated
stream-recovery-policy.test.ts(collapsed branch), committedtest-only on the work branch; record the RED command + expected failure reason.
bun.lockthat turns the targeted tests green; record the GREEN command andthe adjacent-suite +
bun --bun run ciresults.after any follow-up integration-proof adjustments.
Regression risks and concerns
reconnectToStreamIteratorAND our visibility-probe / speculativemount-resume loops can both fire. The SDK should reconcile by message id, but
this is UNPROVEN on a real device. Watch for visible jank / chunk replay on top
of a live stream.
userStoppedRefmitigation guards apreviously-shipped bug upstream never exercised (upstream has no
use-session-chat-runtime.ts). Removing it to "match upstream" would regressiOS stop. KEEP it; prove one-tap-stop on a real device.
reconnect throws a non-204 (e.g.
Failed to fetch chat: 401) that the 204-onlyoverride does NOT swallow — must degrade to a clean error via the surviving
retry-errorbranch, not an infinite probe loop. Add an explicit test.workflowruntime major bump: feat: unblock authenticated production canary and wire journey harnesses (#866) #883's diff changes no producer code, butv4.2.4 → v5 beta is a broader surface (
start,getRun,getReadable,getWritable,getWorkflowMetadata,claimActiveStreamdurability,cancel). The POC only validated the transport in isolation — a full CI run +managed-runtime smoke is required, not optional.
@workflow/ai@5.0.0-beta.4peersworkflow ^5.0.0-beta.5, but@workflow/ai@5.0.0-beta.6peers^5.0.0-beta.8. Caretranges on both can float to mismatched beta minors on a fresh install — hence
exact pins.
diverged; land the updates tests-first.
multiple writables or reorders chunks post-hoc would desync the v5 resume
index — record the single-ordered-log invariant in the feat: port upstream #884 — parallelize chat workflow startup #118 ticket.
Deploy or migration impact
workflowmajor + new@workflow/ai); no databasemigration. Latest migration is
0047_cheerful_thunderbolt_ross.sql.Regenerate
bun.lock(note: upstream lockfile dropsconfigVersion: 1—reconcile deliberately during regeneration).
bun run build, but this change adds none.Require integration proof before deploy: resume under real auth/expired
session; real-device iOS stop/background/refresh; managed-runtime smoke.
bun.lock;the byte-identical pre-feat: unblock authenticated production canary and wire journey harnesses (#866) #883 base means revert is clean.
Definition of done
Folds the reflection's 11-item conditions checklist (
docs/plans/upstream-883-reflection.md§8):workflow: "5.0.0-beta.5"and@workflow/ai: "5.0.0-beta.4"(no carets);bun.lockregenerated deliberately; POCharness re-run against the resolved versions.
startIndex/tail-index header, policy branch collapse.reconnectToStreamoverride kept: compiles; no-active-streampath returns
null, not an error.prepareReconnectToStreamRequest: ({ id }) => ({ api: /api/chat/${id}/stream })kept:test/trace confirms the resume GET hits the chatId URL, not a runId URL.
userStoppedRefmitigationand speculative mount-resume probe in
use-session-chat-runtime.ts, andthe surviving visibility-
probebranch inuse-stream-recovery.ts,preserved.
stream-recovery-policy.test.tsupdated for the deleted stall branch;
stream/route.test.tscoversstartIndex+x-workflow-stream-tail-index+ invalid→400.a non-204 status that degrades to a clean error and does NOT wedge the
recovery loop.
mid-stream → one tap; (b) background/foreground mid-stream → resume with no
duplicate content; (c) refresh mid-stream → replays-from-0 + de-dupes
(parity with today).
bun --bun run ci+ managed-runtime smoke against the v5 runtime(not just the transport):
start/getRun/getReadable/getWritable/cancelexercised end-to-end, with proof evidence captured per the ManagedRuntime Proof Standard (target Level 2).
STREAM_RECOVERY_STALL_MS/getStreamRecoveryDelayMs/ dead stall-timereffect in a scoped cleanup after the port lands green.
invariant (parallel computation, serialized emission into one
getWritable) noted in the feat: port upstream #884 — parallelize chat workflow startup #118 ticket.git diff --checkclean; RED and GREEN commits captured in the TDD audittrail; change committed, pushed to the fork, and a PR opened against
dennisonbertram/fork-open-agents.Assumptions and alternatives
Assumptions
workflow@5.0.0-beta.5,@workflow/ai@5.0.0-beta.4)are still installable from the npm registry. If they have been yanked, the
latest stable beta in the
^5.0.0-beta.5/^5.0.0-beta.4range should beused after re-running the POC typecheck harness to verify the transport
.d.tsis still byte-compatible.
712e1f49^(thereflection confirmed this; the current
git showconfirms the fork has nottouched these files since). Any divergence discovered at port time means the
patch must be hand-applied rather than cherry-picked.
ai@6.0.168,@ai-sdk/react@3.0.170) stays pinned. If asubsequent grooming step bumps
ai, re-verify the@workflow/aipeerconstraint.
Alternatives considered
WorkflowChatTransportrecovery:Chosen approach is BOTH — adopt the v5 transport for mid-stream-drop recovery
while keeping all fork-only recovery logic (iOS, speculative probe, visibility
probe) that upstream never exercised. Upstream-only approach (delete fork
recovery logic) would regress iOS stop behavior. Fork-only approach (keep
DefaultChatTransport) foregoes the deterministic chunk-index resume protocol.dependency-pinning hazard (mismatched beta peers on fresh install) is well-documented
in the reflection. The alternative (caret ranges, rely on lockfile) is riskier
for fast-moving betas in CI environments that may reinstall.
initialStartIndexfor refresh-resume: Out of scope for parity.Would require opting into a new v5 capability that the current behavior contract
does not require and that has not been user-tested.
Grooming clarifications (2026-05-31)
Groomed by: issue-groomer autonomous agent, read-only research pass.
Grooming verdict: STATUS:READY — the reflection doc provides a GO-WITH-CONDITIONS
decision backed by a 10/10 POC, all conditions are documentable as a concrete
implementation plan (the DoD above), and no genuine unresolved strategic decision
remains.
What this grooming pass confirmed (grounding against live fork state):
apps/web/lib/abortable-chat-transport.tsstill importsDefaultChatTransportfrom
"ai"(byte-identical to pre-feat: unblock authenticated production canary and wire journey harnesses (#866) #883 upstream as claimed). Direct readconfirmed.
apps/web/package.jsonstill hasworkflow: "^4.2.0-beta.72"and no@workflow/aientry. Direct read confirmed.0047_cheerful_thunderbolt_ross.sql— no migration neededfor this port. Confirmed.
apps/web/app/api/chat/[chatId]/stream/route.tsbyte-identical to pre-feat: unblock authenticated production canary and wire journey harnesses (#866) #883:no
startIndexparsing, nox-workflow-stream-tail-indexheader,getReadable()called with no arguments. Direct read confirmed.apps/web/app/sessions/[sessionId]/chats/[chatId]/stream-recovery-policy.tsstill has the full stall-detection branch (lines 60–72) and
shouldScheduleStallRecoveryreturns a real computation. Direct read confirmed.use-session-chat-runtime.tscontains the iOSuserStoppedRefmitigation(lines 155–249) and speculative mount-resume probe (lines 296–354). Direct read
confirmed. These are fork-only; upstream commit 712e1f4 does not touch this file.
712e1f49is reachable;git show 712e1f49 --statconfirmedthe 7-file diff (+244/-73 lines).
bun.lockin the upstream commit shows the full@workflow/*constellationbumping from v4 to v5.0.0-beta.5, and
@workflow/ai@5.0.0-beta.4being added.Fork's
bun.lockdoes not have@workflow/ai. Confirmed.What required no update: the existing issue body (as groomed in the prior
session) is already highly complete and accurate. This grooming pass validated
the body against current fork state and upstream commit reality. The only
substantive additions in this pass: (a) explicit sequencing table (depends on /
blocks); (b) confirmed migration number (
0047); (c) this provenance block.Risks flagged for human awareness (not blockers):
requires this proof before the PR can be considered done. No automated harness
can substitute.
workflowruntime v5 major bump covers more surface than the POC validated(POC tested transport only, not
start/getRun/claimActiveStream/cancel).Full managed-runtime smoke is required in the green phase, not optional.
workflow@5.0.0-beta.5or@workflow/ai@5.0.0-beta.4have been yanked from npm by the time the portbegins, the implementer must re-run the POC typecheck with the next available
compatible beta and confirm the
.d.tssurface is still compatible.Provenance: research sources —
docs/plans/upstream-883-reflection.md(fullread),
docs/context/command-intent.md(full read),gh issue view 117(fullread),
git show 712e1f49 --stat+ full diff (confirmed), direct reads ofabortable-chat-transport.ts,stream/route.ts,stream-recovery-policy.ts,use-session-chat-runtime.ts,package.json, migration directory,POC/upstream-883-workflow-v5/FINDINGS.md(header).Guardrail: created on
dennisonbertram/fork-open-agents(the fork). Do not pushany artifact to
vercel-labs/open-agents.