> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# W3 chat extraction map

# 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`:

```ts theme={null}
export interface ChatTurnSink {
  /** Emit one SSE event. Synchronous enqueue; ordering is the contract. */
  send(event: string, data: object): void
  /** Terminal close of the transport (route: controller.close(); workflow: writer.close()). */
  close(): void
}
export async function runChatTurn(deps: ChatTurnDeps, sink: ChatTurnSink): Promise<void>
```

* 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)

| Range | What | Disposition |
| - | - | - |
| 1–120 | imports incl. `persistPartialChatResponse` (77), `needsSpaceBetween` (87) | split: turn-only imports move |
| 1262–1400 | `consumeStream` (emitter-parameterized already) | move to `lib/claude/chat-turn.ts` (module fn) |
| 1407–1683 | `createAndConsumeStreamWithFallback` (529/500/rate-limit fallback engine, `bedrock_fallback`/`rate_limited` events) | move (module fn) |
| 1683 | `POST` entry | stays |
| 1683–2560 | prep: auth, gate, model routing, chat-log create, token trim | stays (produces deps) |
| 2100–2160 | citation-summary merge helper, partial-persist + heartbeat closures | move (created inside turn) |
| 2163–2280 | `sendEvent` wrapper: streamLogger, activity persist, text/citation accumulation | move — becomes the sink wrapper inside `runChatTurn` |
| 2588–2674 | duplicate-run replay branch (session\_id → replay → done) | **stays in route**, evaluated before the transport fork |
| 2695 | `sendEvent("session_id", { sessionId, runId })` — first turn event | moves (both transports emit identically) |
| 2695–3115 | pre-loop turn setup: fast-mode/thinking events, context truncation (`context_truncated` 3085) | move |
| 3115–3925 | agentic loop: `loop` event, streamParams build (compaction gate, betas, fast-mode, thinking), fallback consume w/ citation-rollback snapshot (3244–3311), `model_fallback`, `citations_summary`, `message`, `context_management`, unstreamed-text backfill, tool exec (`tool_result`, present\_artifact registry, view\_images), token accounting (compaction iterations), `max_tokens` cutoff throw, loop exit | move verbatim |
| \~3925–4119 | completion: `token_usage`, `markChatLogCompleted` **before** `done`, `RESPONSE_COMPLETED` capture, raindrop finish, `suggested_prompts`, ftCollector final, `done`, close | move |
| 4120–4258 | catch: `context_overflow`, `markChatLogFailed`, `error` event, close, post-close Slack (max\_tokens) | move |
| 4259+ | finally: heartbeat stop, stream-logger flush | move |
| 4265 | `return new Response(sseStream, …)` | stays |
| 4272+ | API-level catch (pre-stream errors) | stays |

## 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.