Skip to main content

W3 — Chat workflow transport: extraction map

Working document for feat/workflows-w3-chat. Line numbers are against the branch base (9cf7cc26ce, staging @ 2026-07-18, route = 4,305 lines). This is the reconnaissance for the plan’s “one unavoidable mechanical move” (docs/plans/vercel-workflows-rollout.md §W3): the chat turn leaves app/api/claude/route.ts so a workflow file can call it — workflow files cannot import route modules (route files may only export HTTP methods; same constraint that shaped W1’s lib/radar-worker-core.ts).

REVISION — as-built seam (2026-07-20, supersedes the tables below where they differ)

The reconnaissance above assumed several pieces lived in prep; the cut showed they were already inside the ReadableStream body. As implemented:
  • Route keeps ONLY: auth → request validation → token gate → portfolio gate → message extraction (userMessage, turnHasAttachments) → raindrop eventId mint → canonical runId derivation → transport fork → (inline path only) raindrop begin → controller sink + runChatTurn.
  • Everything else moved into the turn and runs identically on BOTH transports: session-history reconstruction, the duplicate-run replay branch, model routing, token trimming, the loop, completion, error handling.
  • Duplicate replay is deliberately IN-TURN, not pre-fork (review discussion): hoisting it into prep would add a DB read before the first byte of EVERY request (a TTFB regression — today the check runs inside the stream, after the response has started). Running it in-turn keeps TTFB unchanged and both transports byte-identical; the only cost is that a rare duplicate retry on the workflow path spins a cheap workflow run to serve its replay (no model calls).
  • Canonical runId (review fix, Critical): start() can be accepted even when the dispatcher observes an exception — the inline fallback then runs concurrently with the workflow. The route derives ONE run id (client’s when a valid UUID, else fresh) before the fork; both the envelope and the inline deps carry it, so the turn’s duplicate-run machinery can collapse the two executions instead of letting each mint its own id (which meant duplicate rows + double side-effectful tools).
  • Final ChatTurnDeps (9): body, lastPayloadMessage, clerkId, userMessage, turnHasAttachments, tokenStatus, toolContext, interaction, runId.
  • Envelope (ChatWorkflowInputV1): version, clerkId, userMessage, turnHasAttachments, eventId, runId, tokenStatus, body — prep products only; the step rebuilds toolContext/interaction and the turn derives the rest.
  • Sink contract: the turn frames SSE itself (single framer) and hands exact bytes to ChatTurnSink.sendRaw; disconnect semantics live in the sinks (route: controller enqueue-failure flips a flag; workflow: writer failure ditto, explicit close in finally).

Seam decision

Extract the entire ReadableStream start body — minus the duplicate-run replay branch — into lib/claude/chat-turn.ts:
  • The route keeps: auth → token gate → routing → chat-log create → token trim (prep, 1683–2560), the duplicate-replay branch (2588–2674, stays inline on BOTH transports per plan), then either new ReadableStream({ start: c => runChatTurn(deps, controllerSink(c)) }) (inline) or start(chatWorkflow, [frozenInput]) → Response(run.readable).
  • The workflow step rebuilds deps from the frozen envelope + re-created clients, then runChatTurn(deps, writerSink(getWritable())) with an explicit writer.close() — the PR-8 close pattern (run.readable never auto-closes; PR-0 finding).
  • Byte-identical guarantee: sendEvent’s text accumulation (needsSpaceBetween mirroring, citation offset tracking, 2163–2280) moves INSIDE runChatTurn unchanged, and the consumeStream / createAndConsumeStreamWithFallback call chain is untouched — chunk boundaries are produced by the same code on both transports.

Verified landmarks (route.ts @ base)

Deps inventory (inputs crossing the seam)

Per-turn state (accumulators, heartbeat, citation maps, partialResponseText, loopCount, cumulative tokens, presentedArtifacts, allToolsCalled) is created inside the moved body — NOT deps. True inputs:
  • ids: clerkId, sessId, runId, persistedChatLogId, tier
  • model plan: effectiveModel, thinkingConfig, isFastMode, skillsEnabled, cacheableSystem, cacheableTools/allTools, body.context_management (client edits)
  • conversation: messages (post-trim), coreMessages refs used by suggested-prompts
  • services: supabase, streamLogger, ftCollector, raindrop interaction handle, toolContext (built in prep; contains clerkId/sessionId/…)
  • config: DEFAULT_LLM_CONFIG (imported, not passed)
The workflow envelope (ChatWorkflowInputV1) freezes the serializable subset (ids + model plan + messages + client context_management edits); services are rebuilt inside the step from ids (same pattern as radar’s runRadarAnalysisForJob). Schema exists from PR 0 — reconcile fields against this inventory in this PR (still V1; never shipped).

Golden-transcript harness (before the fork lands)

app/api/claude/__tests__/chat-turn-golden.test.ts:
  • Mock the Anthropic client with scripted stream fixtures (deterministic event sequences): plain text; multi-chunk text exercising needsSpaceBetween joins; single tool round-trip; 529 → fallback (bedrock_fallback); max_tokens cutoff (error path); GenUI block.
  • Drive runChatTurn with a recording sink; snapshot the ordered (event, payload) list including text chunk boundaries.
  • Parity test: inline controllerSink vs workflow writerSink over the same fixture must produce byte-identical frame lists (the actual W3 risk).
  • Honest caveat: fixtures are recorded post-move (the pre-move loop is not callable in isolation — that’s the point of the move). Move-safety is carried by tsc + full suite + mechanical-diff review + staging soak; transport parity is what the harness proves mechanically.

Fork (lands after move + harness are green)

In POST, after the duplicate-branch check and prep: getDispatchDecision('chat', clerkId) → workflow: validate + freeze ChatWorkflowInputV1 (size guard → inline fallback over 800KB), start(chatWorkflow, [input]), return new Response(run.readable, SSE_HEADERS); inline: today’s path. workflows/chat.ts: single step, maxRetries = 0 (side-effectful tools; user watching), finding-11 import discipline (type-only top-level; await import() in step bodies), explicit writer.close() in a finally.

Merge gate (staging flips chat to workflows on merge — non-prod default)

  1. tsc + full vitest + next build green; golden parity suite green.
  2. Manual staging smoke: normal chat, tool-using chat, stop mid-stream, regenerate, reload-mid-turn (2s-partial recovery), session history.
  3. Kill-switch drill on staging: SET workflow-dispatch:chat off → inline within 5s.

Build order (each its own commit)

  1. Mechanical move + route adapter (zero behavior change; tsc + suite green)
  2. Golden harness + fixtures (parity suite)
  3. workflows/chat.ts + fork + envelope freeze
  4. Preview e2e + staging smoke evidence in PR body

W4.5 ADDENDUM — sliced transport (2026-07-21)

Trigger: first prod 800s truncation, canary turn #2 (wrun_01KY1758RKNES49KCKNQ9DGXWR, 19-loop hyperframes video build; step 504’d at exactly 800s, stream went silent, chat row stuck ‘running’). The plan’s pre-registered Phase-2 condition (“step-per-model-call only if 800s truncations are observed”) is met — pulled forward so the canary ramps WITH the wall removed.

What changed

  • runChatTurn is now a thin wrapper over runChatTurnSlice(deps, sink, opts) — the same single body, with a fresh/resume fork and an optional slice boundary. Inline passes {} (slicing disabled) and is behavior-identical.
  • ChatTurnCheckpoint (v:1): the loop-carried state (messages, accumulators, ids, token counters, ftCollector state). Threaded between workflow step invocations as step args/results; JSON round-trip enforced at build time.
  • Slice policy: TIME-based at loop boundaries — checkpoint at the first boundary past CHAT_TURN_SLICE_ELAPSED_MS (default 180s). Short turns (~99%) complete in slice 1 with zero new overhead. Oversized checkpoints (> CHAT_TURN_MAX_CHECKPOINT_BYTES, default 700KB) degrade to “bulk remainder”: keep looping in the current invocation (pre-split behavior).
  • Prep derivations (tools/system prompt/thinking/fast-mode) extracted to deriveTurnStaticConfig — pure function of serializable inputs; resumed slices re-derive instead of re-running prep’s side effects. Config SSE events fire on the fresh slice only (original order preserved).
  • workflows/chat.ts: orchestrator loops runTurnSlice (maxRetries=0 unchanged), threading checkpoints; the run writable SPANS slices (acquire → write → drain → releaseLock per checkpoint slice; only the final slice closes). New failTurn step on lost invocations: sanitized terminal error frame + stream close + chat row marked failed (checkpointed id, else run_id lookup) — the 800s failure mode becomes a clean error instead of a silent hang + stuck row.
  • Client resume: the POST response (workflow path only) carries x-chat-run-id + HMAC x-chat-resume-token (binds workflowRunId+clerkId; secret CHAT_STREAM_RESUME_SECRET || INTERNAL_API_TOKEN; none configured → headers omitted, pre-W4.5 behavior). If the pump dies without a terminal event, parseAnthropicStreamWithResume re-attaches at GET /api/claude/stream?runId&startIndex=<frames-consumed> (one SSE frame == one durable chunk) and continues yielding — downstream hook code cannot tell a resume happened. Tokens re-mint per hop.
  • Smoke: ?mode=sliced&stream=1 on /api/internal/workflow-smoke proves the repeated-step loop + writer handoff + single close on a real deployment.

Parity evidence

Golden suite grew 5→9: sliced-parity (checkpoint-every-loop == single invocation, byte-for-byte), checkpoint JSON stability, oversize degrade, slicing-disabled inline. Orchestrator suite (workflows/tests/chat.test.ts): checkpoint threading, release-vs-close lifecycle, failTurn row cleanup both lookup paths. Resume: token contract (6 cases) + stream re-attach (frame counting incl. unknown event types, token chaining, terminal/abort guards).

Deploy notes

  • Zero migrations. New env (all optional): CHAT_TURN_SLICE_ELAPSED_MS, CHAT_TURN_MAX_CHECKPOINT_BYTES, CHAT_STREAM_RESUME_SECRET (falls back to INTERNAL_API_TOKEN, already present).
  • Verification gates on preview/staging: sliced smoke run, then a long video-build chat proving >800s survival end-to-end with the live stream following across the pump boundary.