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
Tracking issue for chat stream delivery: when the /api/chat SSE response drops mid-run, the UI must reconnect and keep rendering instead of freezing until the user refreshes. Split out of chat#1918, which tracks scheduled-task email correctness — a different defect that happens to share the workflow. Business context, out of scope here: a user watching a long agent turn silently loses the second half of it, including the final answer.
Live on prod today, reproduced 2026-08-02. No PRs open.
Root cause 2026-08-02 — the stream drops and nothing reconnects. The /api/chat SSE response ends at ~123 s with a clean data: [DONE] and no finish chunk, while the workflow keeps running. useChat sees a stream that ended without a terminal chunk, stops rendering, and never marks the message complete. Our only resume path (maybeResumeChatStream) fires on a new POST, which is why a page refresh recovers and sitting still does not. The drop itself is expected behaviour — the Workflow SDK documents getReadable({ startIndex }) as "useful for reconnecting after timeouts or network interruptions", and upstream vercel-labs/open-agents runs the same N-step architecture with a dedicated resume route and a client recovery subsystem. We shipped neither.
Correction 2026-08-03 — the endpoint is already documented, just not implemented. Scoping the docs PR turned up api-reference/chat/workflow-stream.mdx ("Resume Chat Stream") and a complete spec block for GET /api/chat/{chatId}/stream in api-reference/openapi/research.json, cross-referenced from POST /api/chat, POST /api/chat/runs and GET /api/chat/runs/{runId}. api/app/api/chat/[chatId]/ contains only stop/, so this is documented-but-missing drift rather than the undocumented-endpoint case this issue first assumed. The only contract gap was startIndex (and the 400 for a malformed one), which is what docs#286 adds; the api row is now implementing an already-published contract.
Correction 2026-08-03 — the original bug is still live on prod after chat#1924 merged. Replaying the original prompt ("can you send me an email with the status of all artists across accounts?") on prod 7a56bb65: POST /api/chat died at 123 s after 1,498 chunks, zero reconnects fired, the run kept writing until 19:19:00 (3¼ min later, 25 parts persisted), and the UI froze with no stop control. Root cause: shouldRecoverStalledStream only fires while status is streaming/submitted, but a stream ending with a clean [DONE] and no finish chunk moves useChatout of in-flight — so the detector goes silent exactly when it is needed. Everything downstream (frame counting, URL building, auth, the api route) is correct and simply never gets invoked. The earlier "verified" run passed for the wrong reason: its prompt was five sequential 45 s sleeps, which guarantees a stall while still streaming, so a resume was already in flight at 17:10:28 before the stream died at 17:11:48 — it never exercised the post-drop path. Tracked as row 1.
Decision 2026-08-03 (Sweets) — replace the multi-trigger client recovery with a server probe, and delete rather than add.chat#1925 fixed the trigger gap but its shape is wrong, proven on preview chat-1eerhhswj: the post-drop reconnect fired correctly (200, real content), then 24 reconnects in six minutes — 12 succeeded, 12 threw — re-downloading 12,146 chunks for a turn of ~4,000, still retrying five minutes after the run ended. Cause: it added a third trigger onto two existing ones, and a probe cadence was driving a stream-opening operation. resumeStream() is not a probe. The fix is the capability we lack, not more client logic: surface streaming state on a cheap read, then collapse the client to every N seconds: if (not receiving && server says streaming) resumeStream(). That deletes shouldRecoverStalledStream's stall and visibility branches and the activityMarker plumbing outright — a net reduction, and it removes the storm by construction rather than by tuning a cooldown. Mirrors upstream open-agents, which probes /api/sessions/{id}/chats for isStreaming and resumes only on a yes.
Correction 2026-08-03 — the probe endpoint already exists; only the client needs work. I claimed active_stream_id was "surfaced by no endpoint" and planned a docs + api pair to add one. That was wrong: I grepped lib/chats/, lib/supabase/chats/ and app/api/chats/ and missed lib/sessions/chats/. GET /api/sessions/{sessionId}/chats already returns ChatSummary[] with isStreaming, derived from active_stream_id, carrying no message bodies — implemented in getChatSummaries.ts and documented as ChatSummary in sessions.json. It is precisely the endpoint upstream open-agents probes. So the three-PR plan collapses to one: the client rewrite. (Note the sibling GET /api/sessions/{sessionId}/chats/{chatId} also returns isStreaming, but bundles the full transcript — the wrong one to poll.)
Goal
A chat turn renders to completion in the browser regardless of how many times the underlying SSE connection drops. Concretely:
GET /api/chat/{chatId}/stream?startIndex=N is documented in docs first, then implemented: it resumes an in-flight run's stream from a given chunk index, and returns 204 when there is nothing to resume.
The chat client detects a stalled in-flight stream and reconnects from the last chunk it received, without user action.
A turn that the server completed is fully rendered client-side without a refresh.
Key files: api/lib/chat/handleChatWorkflowStream.ts, api/lib/chat/wrapWorkflowStreamWatcher.ts, api/lib/chat/maybeResumeChatStream.ts, api/app/api/chat/[chatId]/stop/route.ts (the only sibling route today), chat/hooks/useChatTransport.ts, chat/hooks/useVercelChat.ts.
Decision 2026-08-04 (Sweets) — stop hand-rolling recovery; port upstream open-agents verbatim, and fix the drop at the server. Preview verification of the polled design on chat-git-fix-probe-gated-stream-recovery-recoup proved the probe gate works and the cadence does not: the post-drop reconnect fired correctly (200 at 160.9 s), zero reconnects once the probe reported isStreaming: false (85 s observed — the failure mode chat#1925 never escaped), but 16 reconnects across two drops, firing at the 5 s probe interval instead of the 8 s cooldown. Cause was in our glue, not the rule: useStreamRecovery reset the cooldown on status submitted, and resumeStream()itself drives that status — so every resume wiped the cooldown it had just set. The unit test covered shouldResumeStream in isolation, where it was correct, and never saw it. Reading upstream settled the design.vercel-labs/open-agents has both files we were reinventing — use-stream-recovery.ts and stream-recovery-policy.ts. We had independently converged on their probe (GET /api/sessions/{id}/chats → isStreaming) and their STREAM_RECOVERY_MIN_INTERVAL_MS = 8_000, and their stall path is hard-disabled (shouldScheduleStallRecovery returns false unconditionally), matching the branch we deleted. The one divergence was ours alone: upstream never polls. Recovery runs on visibilitychange, focus and online only, and lastRecoveryAt is never reset. Consequence, accepted deliberately: a stream dying mid-turn on a focused tab emits no browser event, so this design does not recover it — and neither does upstream's, because their streams do not die mid-turn. That case is the ~123 s ceiling, which moves to HIGHEST as the real fix. Row 1 is now an alignment/cleanup PR, not the bug fix.
Decision 2026-08-04 (Sweets) — scope the UI-stall fix to two changes, verified against upstream source rather than inferred. The stall is not the same bug as the ~120s truncation. It is this: a turn whose stream closes without a finish chunk leaves useChat in-flight forever — composer stuck on a stop control, no error, no resolution. Two facts settle the scope, both read from code rather than deduced. (1)runAgentWorkflow.ts sends sendStreamFinishonly on the happy path — there is no catch, and the finally calls closeChatStreamwithout finish. Any throw (e.g. the observed AI_NoOutputGeneratedError: No output generated after 3 retries) closes the stream with no terminal chunk. Upstream does the opposite in chat.ts: sendFinish(writable).then(() => closeStream(writable)) on the success path, a catch that writes a visible error into the stream, and a finally that repeats sendFinish().then(closeStream) when !streamClosed, commented "so the chat is never permanently marked as streaming." Our code carries a comment claiming it mirrors that Promise.all, but the sendFinish was dropped from it. (2) An api fix alone cannot close the hole: handleResumeChatStream.ts returns 204 once the run status is terminal, clearing active_stream_id — so a client that has not yet read finish can never obtain it, and a workflow killed outright never runs its finally at all. The client therefore needs its own terminal state. Also verified: the finish chunk is mandatory under WorkflowChatTransport, not optional. Its reconnect loop is while (!gotFinish) with the only exit being a finish chunk, and onFinish throws "No finish chunk received" otherwise; a 204 has no body and raises Failed to fetch chat: 204, counted against maxConsecutiveErrors = 3. So the api change is a prerequisite for the transport swap, not an alternative to it — under the new transport a missing finish becomes a retry loop ending in a thrown error instead of a silent hang. The transport swap (712e1f4, "Use Workflow chat transport / Avoid overlapping chat stream recovery", which is also where upstream deleted its stall path) is deferred: it needs @workflow/ai in chat and a workflow 4.2.4 → 5.x beta bump in api, a far larger blast radius than the stall itself.
PRs (updated 2026-08-04)
Fewest-merges path to a prod-testable fix: row 1 alone. The api row was first until 2026-08-04; it was moved to second because it only unfreezes turns that fail within the 120 s connection window — prod's long turns need the client attached to receive finish at all. The api row still ships: it fixes the cause rather than the symptom, and gates the transport swap.
Rows are ordered highest priority first; merged work sits at the bottom. Abandoned rows are deleted rather than struck through — the dated callouts above carry what was dropped and why. chat#1925 is superseded and must not be merged (still open on GitHub; close it).
Stream-end-triggered reconnect (verified: 5 chained reconnects, ~13 min turn, email sent) + reach a terminal state when the server says there is nothing to resume (204 / probe schedule exhausted)
🔄 1 — HIGHEST, open. The single merge that makes the stall testable on prod. Covers both halves: reconnects keep the client attached for long turns so it receives finish, and the terminal state resets the composer when a run ends without one. Terminal state is not yet implemented — that is the next work
api#TBD
Always send finish before closing the writable — add the missing catch, a streamClosed guard, and sendFinish().then(closeStream) in the finally; mirrors upstream chat.ts line for line
⏳ 2 — required, ships second. Fixes the stall at its source rather than at the client, and is a hard prerequisite for the transport swap: WorkflowChatTransport loops while (!gotFinish) and throws "No finish chunk received" without it
Identify and fix the ~120 s per-connection stream cap
🔄 3 — open investigation. Measured: two consecutive readers on one live run ended at 120 s and 121 s, both clean [DONE], run still going; startIndex resume verified working
Reconcile the published contract with shipped behaviour: ChatStreamErrorResponsemessage → error (+ missing_fields), the account_id override, the x-workflow-stream-tail-index header
🔄 open — 25+/2-, spec re-validated; Mintlify render check pending
chat#TBD
Deferred: swap DefaultChatTransport → WorkflowChatTransport, deleting createChunkCountingFetch, buildStreamReconnectUrl and most of useStreamRecovery
⏸ deferred — needs @workflow/ai in chat + workflow 4.2.4 → 5.x beta in api; blocked on row 1
api#TBD
Deferred: surface a run failure as visible text in the stream (upstream's sendTextMessage(writable, "setup-error", …))
✅ merged 2026-08-03 — necessary but NOT sufficient; see the correction callout
Merge sequencing: docs → api → chat, per documentation-driven development. docs#286 and api#809 both merged 2026-08-03, so the contract is published and the route is live; chat#1924 merged 2026-08-03, closing the original three-row fleet; rows 4-6 are follow-ups. The docs OpenAPI change is the contract the api route is written against, so it leads even though it is independently mergeable; the client has nothing to reconnect to until the route exists. The ~123 s investigation is independent and blocks nothing.
Follow-up ordering (rows 4-6), after chat#1924 lands: docs first — the published contract is inaccurate today, so every consumer reading it is misled, and it is the only row with a live correctness cost. Then expose-headers, a one-line CORS change that makes the documented 200 headers genuinely readable by browser JS. Then the negative-startIndex refresh improvement, which is a feature and cannot work until the header is readable.
Evidence (context)
Reproduced on prod 2026-08-02 by replaying the reported prompt ("can you send me an email with the status of all artists across accounts?") in the real chat UI via Chrome DevTools. Run wrun_01KZ04KVHBVA405WDFVYCEADE8, chat 9571bd11-8311-4ffa-bc5f-a02bf2d99369, session f5a1e821-5d9a-4038-b765-202a4b9db7a3, model moonshotai/kimi-k3.
The SSE response ends at exactly ~123 s, twice. From performance.getEntriesByType('resource'):
Two identical durations across two independent runs points at a fixed ceiling, not a variable idle timeout.
The UI froze at 02:26:55-02:26:58 — exactly when request Tech322/desktop #2 closed — and was still frozen at 02:36:12, nine minutes later. document text length pinned at 4,702 chars across every 3 s sample. No stop button; the composer looked idle, as if the turn had finished.
Only 2 /api/chat requests were ever made. The client never attempted a third. There is no reconnect.
The server was healthy the whole time. Step poll: 02:27:30 iters=8, 02:28:23 iters=10, 02:29:15 iters=12, 02:30:56 iters=13 done=13; run completed 02:31:05. All 13 runAgentStep records at attempt: 1. The client received 6 of 13 iterations — everything after 02:26:58, including the final answer, was produced and persisted but never delivered.
The stream ended cleanly, not violently. Captured response body, 121,172 bytes. Chunk histogram: 1 × start, 6 × start-step, 6 × finish-step, 0 × finish; tail is finish-step → message-metadata → data: [DONE]. [DONE] is what createUIMessageStreamResponse emits when its source ReadableStream closes normally — a killed function or a severed proxy connection would surface as a network error instead. So run.getReadable() returned done mid-run and wrapWorkflowStreamWatcher correctly treated that as end-of-stream.
Originally reported onwrun_01KZ039AV3Y325DR5494HT1HBK (prod, 2026-08-02): 10 iterations, all attempt: 1, run completed 02:09:11, same frozen-UI symptom, messages appearing only after refresh.
Done
chat#1924 — reconnect a dropped response stream instead of freezing mid-turn.
✅ Shipped 2026-08-03 (merged to main as 7a56bb65). useChat gains resume: true so returning to a chat mid-turn re-attaches; useStreamRecovery watches an in-flight turn for silence and calls resumeStream(), also re-checking on visibilitychange; shouldRecoverStalledStream holds the decision as a pure, unit-tested function; and the transport gained prepareReconnectToStreamRequest (auth on reconnect), createChunkCountingFetch (tracks the absolute stream position off the wire) and buildStreamReconnectUrl. Verified live on preview 2026-08-03 (results), signed in as the real account against test-recoup-api: POST /api/chat died at 123 s after 418 chunks — a third independent reproduction — and GET /api/chat/2daee8a8…/stream?startIndex=348 returned 200, streamed 72 more chunks, and carried the turn to completion. alpha/bravo/charlie/delta each rendered exactly once, which is the assertion that matters: a wrong startIndex would have duplicated them. It took three preview runs, each finding a different bug the tests could not see — 405 (query appended to the transport base, destroying the path), then 404 (used the useChatinstance id, still a client placeholder, instead of the api-minted id), then 200. All 86 test files passed against every broken URL, because they cover the decision to reconnect rather than the URL the SDK builds. Thresholds are ours, not upstream's, deliberately: upstream's STREAM_RECOVERY_STALL_MS = 4_000 feeds a scheduler their shouldScheduleStallRecovery unconditionally disables, and a 4 s window would fire dozens of reconnects per turn against our ~200 s legitimate tool-call silences. Upstream's live triggers (status === "error", visibility probe on ready) would not have caught this bug at all: our stream ends with a clean [DONE], no error, on a visible tab.
docs#286 — add startIndex (+ 400) to GET /api/chat/{chatId}/stream.
✅ Shipped 2026-08-03 (merged to main as f24a9537).
Adds the optional startIndex query parameter (integer, minimum: 0) and the 400 returned when it is present but not a non-negative integer, reusing the existing ChatStreamErrorResponse schema. The endpoint itself was already documented — the only contract gap was where to resume from, so the diff is 20 insertions, 0 deletions. Applied via anchored text edits rather than load→dump, because json.dumps(json.load(f), indent=2) does not round-trip research.json byte-for-byte (165,132 → 165,567 bytes) and a rewrite would have buried the change in a spurious diff. Verified against a local Mintlify dev server (npx mintlify@latest dev, driven through Chrome DevTools — screenshots and full results): page serves HTTP 200; a new Query Parameters section renders startIndex as integer with its prose and inline code intact; response tabs read 200 / 400 / 401 / 403 / 404 and the 400 tab resolves the ChatStreamErrorResponse$ref; the sidebar entry under Streaming was already present. Spec parsed on both sides to confirm the delta: main params ['chatId'] / responses ['200','204','401','403','404'] → PR params ['chatId','startIndex'] / responses +400.
Two things deliberately not claimed: 204 renders without a response tab (correct — Mintlify only tabs responses carrying content), and llms.txt was not verified because the local dev server returns the SPA shell for it rather than generated text.
api#809 — GET /api/chat/{chatId}/stream resume route.
✅ Shipped 2026-08-03 (merged to main as 88ab6404).
Implements the published contract: 200 SSE + x-workflow-run-id when the run is live, 204 when there is nothing to resume (clearing a stale active_stream_id on the way), 400 on a malformed startIndex, 401/403/404 from auth and ownership. lib/chat/validateChatOwnership.ts was extracted from validateStopChatWorkflowRequest so /stream and /stop share one auth rule, and wrapWorkflowStreamWatcher is reused so a resumed stream gets the same tool-call reconciliation and cancel propagation as the primary one. Three defects found by preview testing, none visible to unit tests:
Headless runs were unresumable.lib/chat/runs/ never set chats.active_stream_id, so a live headless run returned 204 — contradicting the published cross-reference in both directions. Fixed by claiming the slot after start(); the workflow's existing clearChatActiveStream already releases it.
No admin override.validateChatOwnership passed no override options to validateAuthContext, so an org/admin key got 403 on a chat it legitimately administers — the same defect as DELETE /api/tasks (chat#1918 row). Now reads account_id from the query string. Because the validator is shared, this fixed POST /api/chat/{chatId}/stop at the same time.
No x-workflow-stream-tail-index. Upstream returns readable.getTailIndex() so a client can compute absolute chunk positions; without it a reconnect replays from zero. Added. Verified on preview api-ogiqx7a90 built from c75ece11 (results, earlier run here), using agent keys minted on the preview itself via the documented POST /api/agents/signup path: live resume returns 200 + both headers; startIndex=10 returned a chunk byte-identical to chunk Tech322/get top score #11 of the from-zero read, proving the resume is zero-based and gap-free; 204 on a finished run; a planted stale id → 204 and the slot read back null; account_id for another account → 403 Access denied to specified account_id, so the override is validated rather than trusted; plus 400 ×3, 401, 404 and cross-account 403. Deliberate divergences from upstream open-agents, both kept: a failed getRun returns 502 and keeps the slot rather than upstream's clear-and-204, which would tell a client with a live run to stop reconnecting; and wrapWorkflowStreamWatcher stays instead of upstream's createCancelableReadableStream because ours also reconciles orphaned tool-calls.
Full api suite 4,329 passing; tsc delta 0 vs main.
Open
Rewrite client recovery as a single probe-gated trigger. ← do first; the original bug is live on prod
Why: three triggers produced behaviour none had alone — on preview chat-1eerhhswj, 24 reconnects, 12 of them throwing, 12,146 chunks re-downloaded for a ~4,000-chunk turn, still retrying five minutes after the run ended. resumeStream() is not a probe: it opens a stream, so any polling cadence attached to it costs a full read per tick.
Fix: one rule — every N seconds: if (not receiving && isStreaming) resumeStream(), where isStreaming comes from the existingGET /api/sessions/{sessionId}/chats. Delete the stall branch, the visibility branch, STREAM_STALL_MS, and the activityMarker plumbing. Keep the cooldown, the in-flight guard, and the frame counting (still the only way to know the resume position). Bound thrown reconnects so a dead run cannot retry forever.
Done when: the original prompt on a preview reconnects after the ~123 s drop, completes the turn, and issues no reconnect at all once the probe reports isStreaming: false.
Reconcile the published contract with what the route actually does. (docs)
Why: three drifts, all live. (1) ChatStreamErrorResponse declares { status, message } with messagerequired, but every 4xx returns error — and validation failures add missing_fields. Observed on preview: {"status":"error","missing_fields":["startIndex"],"error":"startIndex must be a non-negative integer"}. The schema is pre-existing and also backs 401/403/404. (2) The account_id query override shipped in api#809 and is undocumented. (3) x-workflow-stream-tail-index ships on the 200 and is undocumented.
Done when: the error schema matches a real 4xx body field-for-field; account_id and x-workflow-stream-tail-index appear in the spec; a reader can predict the actual response from the docs alone.
Expose the custom response headers to browser clients. (api; blocked by nothing, blocks the refresh row)
Why:getCorsHeaders sets Access-Control-Allow-Headers but no Access-Control-Expose-Headers, so cross-origin JS cannot read any custom response header. Confirmed on preview 2026-08-03: a browser read of x-workflow-stream-tail-index returned null on a 200 reconnect. This affects x-workflow-run-id too — documented as part of the 200 response since the workflow cutover and never readable by the chat client. It would also silently break the SDK's WorkflowChatTransport, which needs the tail header to anchor relative indices.
Fix: add Access-Control-Expose-Headers: x-workflow-run-id, x-workflow-stream-tail-index to getCorsHeaders().
Done when: browser JS on chat can read both headers off a /api/chat/{chatId}/stream response.
Use a negative startIndex when resuming after a page refresh. (chat; blocked by the expose-headers row)
Why: on a fresh load lastChunkIndexRef is null, so resume: true reconnects with no startIndex and replays the entire turn — 418 chunks in the verified run. The SDK supports a negative initialStartIndex ("last N chunks"), but the server's answer can only be anchored to an absolute position via the x-workflow-stream-tail-index header, which browsers cannot currently read.
Note: this does not replace the frame counting in chat#1924. HTTP headers are sent before the body, so no header can report where a stream ended — counting stays the only way to know how far a long-lived read actually got. The header is an anchor for relative indices, nothing more.
Done when: reloading mid-turn renders recent output without replaying the whole turn, and subsequent retries still resume from an exact absolute position.
Add GET /api/chat/{chatId}/stream?startIndex=N. (api)
Why: there is no way for a client to re-attach to an in-flight run. maybeResumeChatStream only runs inside the POST handler, so recovery requires a full page load. app/api/chat/[chatId]/ currently contains only stop/.
Fix: mirror upstream's apps/web/app/api/chat/[chatId]/stream/route.ts: parse and validate startIndex (400 on NaN, undefined when absent); authenticate and verify chat ownership; 204 when chats.active_stream_id is null; read getRun(runId).status and, when terminal, clear the stale active_stream_id and return 204; otherwise return createUIMessageStreamResponse over run.getReadable({ startIndex }). Reuse the existing cancel-propagation behaviour rather than duplicating it — consider extracting the cancelable-stream helper out of wrapWorkflowStreamWatcher the way upstream keeps lib/chat/create-cancelable-readable-stream.ts shared between both routes.
Docs first: land the OpenAPI entry in recoupable/docs before the api PR — path, the optional integer startIndex, the text/event-stream 200, and the 204 / 400 / 403 cases — so the implementation is written against a reviewed contract.
Done when: a GET with a startIndex mid-run returns only chunks after that index; a completed run returns 204 and leaves active_stream_id null; a non-owner gets 403; the shipped behaviour matches the documented contract.
Detect a stalled stream client-side and reconnect from the last chunk index. (chat; blocked by the api row)
Why: the client currently has no notion that a stream ended early. useChat sees [DONE] without a finish and simply stops — no error, no retry, no visible state change, which is why the composer looks idle while the run is still going.
Fix: port upstream's recovery pair — use-stream-recovery.ts and its stream-recovery-policy (getStreamRecoveryDecision, shouldScheduleStallRecovery, getStreamRecoveryDelayMs). Track how long the turn has been in flight, probe the server for whether it still considers the chat streaming, and reconnect with a soft strategy against the new resume route. Upstream also recovers on visibilitychange, which covers the backgrounded-tab case.
Done when: a turn whose SSE connection is dropped mid-run finishes rendering in the browser with no user action; the reconnect requests the correct startIndex so no chunk is duplicated or skipped.
Identify what imposes the ~123 s ceiling. (investigation)
Why: 123 s twice, to the second, across two independent runs is too regular to be incidental. app/api/chat/route.ts declares maxDuration = 800, so the route's own limit is not it, and the clean [DONE] rules out an abrupt kill. Recovery makes this survivable either way, but every drop costs a reconnect and a gap in the user's view, so the ceiling is worth knowing and possibly raising.
Fix: determine whether the terminating party is the Workflow SDK's readable, the Vercel edge, or an intermediary — e.g. by reading a run's stream directly from a Node client outside the browser and timing when it ends.
Done when: we can name what closes the stream at ~123 s, and say whether it is configurable.
Architecture decisions
Treat mid-run stream drops as expected, and recover — do not try to hold one connection open. The Workflow SDK documents getReadable({ startIndex }) for "reconnecting after timeouts or network interruptions", and upstream open-agents runs the same one-step-per-LLM-call architecture while shipping a resume route and a client recovery subsystem. An earlier hypothesis — that the fix was keeping a writer attached across step boundaries so the readable never ends — was dropped: upstream has the same gaps between writers and solves this at the delivery layer instead.
This is delivery, not task-email. It shares runAgentWorkflow with chat#1918 but has a different failure mode, a different blast radius (anyone watching a long turn, not just scheduled reports) and a different fix surface (a route plus a client hook). Kept separate so neither issue's acceptance criteria depend on the other's.
Exposed by, not caused by, the decompose.api#808 made runs long enough to routinely exceed the ceiling — before it, runs were killed at ~13 min and never got the chance. The missing capability predates it: we never had a resume route or client recovery at all.
Source references
Reference implementation: vercel-labs/open-agents at main HEAD cf865e94 (2026-06-04) — apps/web/app/api/chat/[chatId]/stream/route.ts, apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-stream-recovery.ts, the sibling stream-recovery-policy, and apps/web/lib/chat/create-cancelable-readable-stream.ts.
Workflow SDK streaming docs: https://workflow-sdk.dev/docs/foundations/streaming — stream persistence across steps, and getReadable({ startIndex }) for reconnection. Vendored copy at api/node_modules/workflow/docs/foundations/streaming.mdx (v4.2.4).
Sibling issue: chat#1918 (scheduled-task email correctness); the decompose that surfaced this is api#808, merged 2026-08-02 as 2b2b427b.
How to inspect a run: npx workflow inspect run|steps --runId=<id> --backend vercel --project api --team recoup --env production --json, with VERCEL_TOKEN in the environment.
Tracking issue for chat stream delivery: when the
/api/chatSSE response drops mid-run, the UI must reconnect and keep rendering instead of freezing until the user refreshes. Split out of chat#1918, which tracks scheduled-task email correctness — a different defect that happens to share the workflow. Business context, out of scope here: a user watching a long agent turn silently loses the second half of it, including the final answer.Live on prod today, reproduced 2026-08-02. No PRs open.
Goal
A chat turn renders to completion in the browser regardless of how many times the underlying SSE connection drops. Concretely:
GET /api/chat/{chatId}/stream?startIndex=Nis documented indocsfirst, then implemented: it resumes an in-flight run's stream from a given chunk index, and returns 204 when there is nothing to resume.Key files:
api/lib/chat/handleChatWorkflowStream.ts,api/lib/chat/wrapWorkflowStreamWatcher.ts,api/lib/chat/maybeResumeChatStream.ts,api/app/api/chat/[chatId]/stop/route.ts(the only sibling route today),chat/hooks/useChatTransport.ts,chat/hooks/useVercelChat.ts.PRs (updated 2026-08-04)
finish, and the terminal state resets the composer when a run ends without one. Terminal state is not yet implemented — that is the next workfinishbefore closing the writable — add the missingcatch, astreamClosedguard, andsendFinish().then(closeStream)in thefinally; mirrors upstreamchat.tsline for lineWorkflowChatTransportloopswhile (!gotFinish)and throws"No finish chunk received"without it[DONE], run still going;startIndexresume verified workingAccess-Control-Expose-Headersso browsers can readx-workflow-run-idandx-workflow-stream-tail-indexWorkflowChatTransport, so it stays relevant after the swapstartIndexon page-refresh resume, so a fresh load shows recent output instead of replaying the whole turnChatStreamErrorResponsemessage→error(+missing_fields), theaccount_idoverride, thex-workflow-stream-tail-indexheaderDefaultChatTransport→WorkflowChatTransport, deletingcreateChunkCountingFetch,buildStreamReconnectUrland most ofuseStreamRecovery@workflow/aiin chat +workflow4.2.4 → 5.x beta in api; blocked on row 1sendTextMessage(writable, "setup-error", …))startIndex(+ 400) to the already-documentedGET /api/chat/{chatId}/streamGET /api/chat/{chatId}/stream?startIndex=Nresume routeEvidence (context)
Reproduced on prod 2026-08-02 by replaying the reported prompt ("can you send me an email with the status of all artists across accounts?") in the real chat UI via Chrome DevTools. Run
wrun_01KZ04KVHBVA405WDFVYCEADE8, chat9571bd11-8311-4ffa-bc5f-a02bf2d99369, sessionf5a1e821-5d9a-4038-b765-202a4b9db7a3, modelmoonshotai/kimi-k3.The SSE response ends at exactly ~123 s, twice. From
performance.getEntriesByType('resource'):Two identical durations across two independent runs points at a fixed ceiling, not a variable idle timeout.
The UI froze at 02:26:55-02:26:58 — exactly when request Tech322/desktop #2 closed — and was still frozen at 02:36:12, nine minutes later.
documenttext length pinned at 4,702 chars across every 3 s sample. No stop button; the composer looked idle, as if the turn had finished.Only 2
/api/chatrequests were ever made. The client never attempted a third. There is no reconnect.The server was healthy the whole time. Step poll:
02:27:30 iters=8,02:28:23 iters=10,02:29:15 iters=12,02:30:56 iters=13 done=13; run completed 02:31:05. All 13runAgentSteprecords atattempt: 1. The client received 6 of 13 iterations — everything after 02:26:58, including the final answer, was produced and persisted but never delivered.The stream ended cleanly, not violently. Captured response body, 121,172 bytes. Chunk histogram:
1 × start,6 × start-step,6 × finish-step,0 × finish; tail isfinish-step→message-metadata→data: [DONE].[DONE]is whatcreateUIMessageStreamResponseemits when its sourceReadableStreamcloses normally — a killed function or a severed proxy connection would surface as a network error instead. Sorun.getReadable()returneddonemid-run andwrapWorkflowStreamWatchercorrectly treated that as end-of-stream.Originally reported on
wrun_01KZ039AV3Y325DR5494HT1HBK(prod, 2026-08-02): 10 iterations, allattempt: 1, run completed 02:09:11, same frozen-UI symptom, messages appearing only after refresh.Done
chat#1924 — reconnect a dropped response stream instead of freezing mid-turn.
✅ Shipped 2026-08-03 (merged to
mainas7a56bb65).useChatgainsresume: trueso returning to a chat mid-turn re-attaches;useStreamRecoverywatches an in-flight turn for silence and callsresumeStream(), also re-checking onvisibilitychange;shouldRecoverStalledStreamholds the decision as a pure, unit-tested function; and the transport gainedprepareReconnectToStreamRequest(auth on reconnect),createChunkCountingFetch(tracks the absolute stream position off the wire) andbuildStreamReconnectUrl.Verified live on preview 2026-08-03 (results), signed in as the real account against
test-recoup-api:POST /api/chatdied at 123 s after 418 chunks — a third independent reproduction — andGET /api/chat/2daee8a8…/stream?startIndex=348returned 200, streamed 72 more chunks, and carried the turn to completion.alpha/bravo/charlie/deltaeach rendered exactly once, which is the assertion that matters: a wrongstartIndexwould have duplicated them.It took three preview runs, each finding a different bug the tests could not see — 405 (query appended to the transport base, destroying the path), then 404 (used the
useChatinstance id, still a client placeholder, instead of the api-minted id), then 200. All 86 test files passed against every broken URL, because they cover the decision to reconnect rather than the URL the SDK builds.Thresholds are ours, not upstream's, deliberately: upstream's
STREAM_RECOVERY_STALL_MS = 4_000feeds a scheduler theirshouldScheduleStallRecoveryunconditionally disables, and a 4 s window would fire dozens of reconnects per turn against our ~200 s legitimate tool-call silences. Upstream's live triggers (status === "error", visibility probe onready) would not have caught this bug at all: our stream ends with a clean[DONE], no error, on a visible tab.docs#286 — add
startIndex(+ 400) toGET /api/chat/{chatId}/stream.✅ Shipped 2026-08-03 (merged to
mainasf24a9537).Adds the optional
startIndexquery parameter (integer,minimum: 0) and the400returned when it is present but not a non-negative integer, reusing the existingChatStreamErrorResponseschema. The endpoint itself was already documented — the only contract gap was where to resume from, so the diff is 20 insertions, 0 deletions. Applied via anchored text edits rather than load→dump, becausejson.dumps(json.load(f), indent=2)does not round-tripresearch.jsonbyte-for-byte (165,132 → 165,567 bytes) and a rewrite would have buried the change in a spurious diff.Verified against a local Mintlify dev server (
npx mintlify@latest dev, driven through Chrome DevTools — screenshots and full results): page servesHTTP 200; a new Query Parameters section rendersstartIndexasintegerwith its prose and inline code intact; response tabs read200 / 400 / 401 / 403 / 404and the 400 tab resolves theChatStreamErrorResponse$ref; the sidebar entry under Streaming was already present. Spec parsed on both sides to confirm the delta:mainparams['chatId']/ responses['200','204','401','403','404']→ PR params['chatId','startIndex']/ responses+400.Two things deliberately not claimed:
204renders without a response tab (correct — Mintlify only tabs responses carrying content), andllms.txtwas not verified because the local dev server returns the SPA shell for it rather than generated text.api#809 —
GET /api/chat/{chatId}/streamresume route.✅ Shipped 2026-08-03 (merged to
mainas88ab6404).Implements the published contract: 200 SSE +
x-workflow-run-idwhen the run is live, 204 when there is nothing to resume (clearing a staleactive_stream_idon the way), 400 on a malformedstartIndex, 401/403/404 from auth and ownership.lib/chat/validateChatOwnership.tswas extracted fromvalidateStopChatWorkflowRequestso/streamand/stopshare one auth rule, andwrapWorkflowStreamWatcheris reused so a resumed stream gets the same tool-call reconciliation and cancel propagation as the primary one.Three defects found by preview testing, none visible to unit tests:
lib/chat/runs/never setchats.active_stream_id, so a live headless run returned 204 — contradicting the published cross-reference in both directions. Fixed by claiming the slot afterstart(); the workflow's existingclearChatActiveStreamalready releases it.validateChatOwnershippassed no override options tovalidateAuthContext, so an org/admin key got 403 on a chat it legitimately administers — the same defect asDELETE /api/tasks(chat#1918 row). Now readsaccount_idfrom the query string. Because the validator is shared, this fixedPOST /api/chat/{chatId}/stopat the same time.x-workflow-stream-tail-index. Upstream returnsreadable.getTailIndex()so a client can compute absolute chunk positions; without it a reconnect replays from zero. Added.Verified on preview
api-ogiqx7a90built fromc75ece11(results, earlier run here), using agent keys minted on the preview itself via the documentedPOST /api/agents/signuppath: live resume returns 200 + both headers;startIndex=10returned a chunk byte-identical to chunk Tech322/get top score #11 of the from-zero read, proving the resume is zero-based and gap-free; 204 on a finished run; a planted stale id → 204 and the slot read back null;account_idfor another account → 403Access denied to specified account_id, so the override is validated rather than trusted; plus 400 ×3, 401, 404 and cross-account 403.Deliberate divergences from upstream open-agents, both kept: a failed
getRunreturns 502 and keeps the slot rather than upstream's clear-and-204, which would tell a client with a live run to stop reconnecting; andwrapWorkflowStreamWatcherstays instead of upstream'screateCancelableReadableStreambecause ours also reconciles orphaned tool-calls.Full api suite 4,329 passing;
tscdelta 0 vsmain.Open
Rewrite client recovery as a single probe-gated trigger. ← do first; the original bug is live on prod
chat-1eerhhswj, 24 reconnects, 12 of them throwing, 12,146 chunks re-downloaded for a ~4,000-chunk turn, still retrying five minutes after the run ended.resumeStream()is not a probe: it opens a stream, so any polling cadence attached to it costs a full read per tick.every N seconds: if (not receiving && isStreaming) resumeStream(), whereisStreamingcomes from the existingGET /api/sessions/{sessionId}/chats. Delete the stall branch, the visibility branch,STREAM_STALL_MS, and theactivityMarkerplumbing. Keep the cooldown, the in-flight guard, and the frame counting (still the only way to know the resume position). Bound thrown reconnects so a dead run cannot retry forever.isStreaming: false.Reconcile the published contract with what the route actually does. (docs)
ChatStreamErrorResponsedeclares{ status, message }withmessagerequired, but every 4xx returnserror— and validation failures addmissing_fields. Observed on preview:{"status":"error","missing_fields":["startIndex"],"error":"startIndex must be a non-negative integer"}. The schema is pre-existing and also backs 401/403/404. (2) Theaccount_idquery override shipped in api#809 and is undocumented. (3)x-workflow-stream-tail-indexships on the 200 and is undocumented.account_idandx-workflow-stream-tail-indexappear in the spec; a reader can predict the actual response from the docs alone.Expose the custom response headers to browser clients. (api; blocked by nothing, blocks the refresh row)
getCorsHeaderssetsAccess-Control-Allow-Headersbut noAccess-Control-Expose-Headers, so cross-origin JS cannot read any custom response header. Confirmed on preview 2026-08-03: a browser read ofx-workflow-stream-tail-indexreturnednullon a 200 reconnect. This affectsx-workflow-run-idtoo — documented as part of the 200 response since the workflow cutover and never readable by the chat client. It would also silently break the SDK'sWorkflowChatTransport, which needs the tail header to anchor relative indices.Access-Control-Expose-Headers: x-workflow-run-id, x-workflow-stream-tail-indextogetCorsHeaders()./api/chat/{chatId}/streamresponse.Use a negative
startIndexwhen resuming after a page refresh. (chat; blocked by the expose-headers row)lastChunkIndexRefisnull, soresume: truereconnects with nostartIndexand replays the entire turn — 418 chunks in the verified run. The SDK supports a negativeinitialStartIndex("last N chunks"), but the server's answer can only be anchored to an absolute position via thex-workflow-stream-tail-indexheader, which browsers cannot currently read.Add
GET /api/chat/{chatId}/stream?startIndex=N. (api)maybeResumeChatStreamonly runs inside the POST handler, so recovery requires a full page load.app/api/chat/[chatId]/currently contains onlystop/.apps/web/app/api/chat/[chatId]/stream/route.ts: parse and validatestartIndex(400 on NaN,undefinedwhen absent); authenticate and verify chat ownership; 204 whenchats.active_stream_idis null; readgetRun(runId).statusand, when terminal, clear the staleactive_stream_idand return 204; otherwise returncreateUIMessageStreamResponseoverrun.getReadable({ startIndex }). Reuse the existing cancel-propagation behaviour rather than duplicating it — consider extracting the cancelable-stream helper out ofwrapWorkflowStreamWatcherthe way upstream keepslib/chat/create-cancelable-readable-stream.tsshared between both routes.recoupable/docsbefore the api PR — path, the optional integerstartIndex, thetext/event-stream200, and the 204 / 400 / 403 cases — so the implementation is written against a reviewed contract.GETwith astartIndexmid-run returns only chunks after that index; a completed run returns 204 and leavesactive_stream_idnull; a non-owner gets 403; the shipped behaviour matches the documented contract.Detect a stalled stream client-side and reconnect from the last chunk index. (chat; blocked by the api row)
useChatsees[DONE]without afinishand simply stops — no error, no retry, no visible state change, which is why the composer looks idle while the run is still going.use-stream-recovery.tsand itsstream-recovery-policy(getStreamRecoveryDecision,shouldScheduleStallRecovery,getStreamRecoveryDelayMs). Track how long the turn has been in flight, probe the server for whether it still considers the chat streaming, and reconnect with asoftstrategy against the new resume route. Upstream also recovers onvisibilitychange, which covers the backgrounded-tab case.startIndexso no chunk is duplicated or skipped.Identify what imposes the ~123 s ceiling. (investigation)
app/api/chat/route.tsdeclaresmaxDuration = 800, so the route's own limit is not it, and the clean[DONE]rules out an abrupt kill. Recovery makes this survivable either way, but every drop costs a reconnect and a gap in the user's view, so the ceiling is worth knowing and possibly raising.Architecture decisions
getReadable({ startIndex })for "reconnecting after timeouts or network interruptions", and upstream open-agents runs the same one-step-per-LLM-call architecture while shipping a resume route and a client recovery subsystem. An earlier hypothesis — that the fix was keeping a writer attached across step boundaries so the readable never ends — was dropped: upstream has the same gaps between writers and solves this at the delivery layer instead.runAgentWorkflowwith chat#1918 but has a different failure mode, a different blast radius (anyone watching a long turn, not just scheduled reports) and a different fix surface (a route plus a client hook). Kept separate so neither issue's acceptance criteria depend on the other's.Source references
vercel-labs/open-agentsatmainHEADcf865e94(2026-06-04) —apps/web/app/api/chat/[chatId]/stream/route.ts,apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-stream-recovery.ts, the siblingstream-recovery-policy, andapps/web/lib/chat/create-cancelable-readable-stream.ts.getReadable({ startIndex })for reconnection. Vendored copy atapi/node_modules/workflow/docs/foundations/streaming.mdx(v4.2.4).2b2b427b.npx workflow inspect run|steps --runId=<id> --backend vercel --project api --team recoup --env production --json, withVERCEL_TOKENin the environment.