Skip to content

feat(chat-routes): assembled chat-turn vertical + multimodal upload→parts (#188 Phase 1)#194

Merged
drewstone merged 1 commit into
mainfrom
feat/chat-turn-routes
Jul 15, 2026
Merged

feat(chat-routes): assembled chat-turn vertical + multimodal upload→parts (#188 Phase 1)#194
drewstone merged 1 commit into
mainfrom
feat/chat-turn-routes

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

The wave-2 centerpiece from #188 Phase 1: one factory that assembles the server chat vertical every product re-wired by hand, plus the multimodal upload→parts middle. Zero new loop logic — every hop is owned by a module that already ships it.

Composition (hop → owning module)

Hop Owner What this PR adds
Body parse + validation /web parseJsonObjectBody + new /chat-routes ./wire (import-free wire contract, parseChatTurnParts, inline-part byte gate) wiring only
Auth / access / gates injected authorize seam (composable with /app-auth guards; new guardResolution adapter in /platform — every product wrote this try/catch by hand) one 12-line helper
Turn identity /stream resolveChatTurn + agent-runtime deriveExecutionId wiring only
Producer seam: sandbox lane = streamSandboxPrompt + new createSandboxChatProducer (raw sidecar events → the client vocabulary /web-react already parses + persisted parts + usage receipt + non-renderable-ask auto-decline); router/openai-compat lane = product-supplied producer the bridge legal/tax each forked
Turn engine agent-runtime handleChatTurn — verbatim, no wrapper logic composed
Durability /stream turn-buffer tap, wired by default (tee + drain under waitUntil: the turn finishes and buffers after a client drop; legal skips this today and hand-rolls a 5s debounced partial-saver) + replay endpoint wiring only
Persistence injected /chat-store-shaped store: user row on send (retry-safe via turnId reuse), assistant row with parts/model/usage on completion wiring only
Interaction answering /interactions createInteractionAnswerRoute, composed as routes.interactions wiring only
Uploads → prompt parts new createUploadRoute: ≤700 KiB → inline data: part; larger → base64 write through a structural sandbox sink (box.fs) + path-ref part (the ~1 MiB gateway body cap makes the two-step mandatory); hard per-file cap 413s the missing middle
Composer ChatComposer.onSendParts(text, parts) (additive; onSend(string) unchanged), ComposerFile.part descriptor, file-only sends additive
Client glue chatTurnRequestInit + wire types re-exported through /web-react chat-stream (browser-safe: ./wire is import-free) additive

Reference assembly: examples/chat-app.md — auth factory + chat tables + turn routes + upload + composer in 117 code lines. This example is the seed of the create-agent-app --chat scaffold (#188 Phase 2).

Composition-eval follow-ups folded in (coordinates with #193)

  • Typed /stream/chat-store boundary: new validating projection toChatMessageParts in /chat-store (the module that owns the stored vocabulary; the stream normalizers stay storage-agnostic). The assembly's persistence hops use it — no casts. A compile-time exhaustiveness check makes a new ChatMessagePart kind unignorable.
  • No lost storable kinds: normalizePersistedPart previously returned null for file/image/step-start/step-finish/subtask (and getPartKey mis-keyed them into the text lane) — step-finish usage receipts and file parts silently fell out of persisted transcripts. Both fixed at the source in /stream; createSandboxChatProducer persists per-step receipts under unique keys so two receipts never merge.
  • Guard adapter exported once: guardResolution in /platform (thrown-Response guards → {ok,response} resolutions), used by the example instead of a per-product try/catch.
  • Vertical scenario added (tests/vertical/chat-routes-vertical.test.ts, rebased onto test(vertical): composition eval suite over the assembled chat modules (#188) #193): the mini-app's hand-rolled chat route replaced by createChatTurnRoutes + createSandboxChatProducer end-to-end — same app-auth guard (via guardResolution), same chat store, real client parser (streamChatTurn); asserts the client vocabulary, persisted rows with typed parts + flattened receipt columns, buffered replay after the live stream is gone, and the 401 guard contract. The factory wiring is ~20 lines vs the mini-app's ~150-line bespoke pump.
  • The mini-app's step-finish special-case (which relied on normalizePersistedPart returning null) is updated to consume the normalizer's new full-vocabulary projection — that suite stays green (17/17).

Net LOC products get to delete

  • legal src/routes/api.chat.ts: 895 lines → an assembly call (~60 lines of seams: authorize, produce, billing in onTurnComplete); plus legal finally gets buffered replay instead of the partial-saver.
  • tax chat-turn.ts: 1,058 lines → same shape; tax's hand-wired tee/drain/replay and its re-declared turn_events DDL are the factory's defaults.
  • Both apps' attachment dead-ends (files dropped on the floor) become the upload route + onSendParts.
  • Issue Roadmap: one-day multimodal chat app — assembled vertical + gates #188 sizes the full Phase-1 dead glue at ~3,500–4,500 lines across legal/tax/gtm; this PR is the largest single item.

Tests

  • tests/chat-routes/turn-routes.test.ts (11): fake producer streaming, turn marker + engine lifecycle framing, user/assistant persistence with parts+usage, turnId retry no-double-insert, echoed file parts → producer prompt, 400/401/413 (inline-cap gate), buffer replay after a simulated client drop (cancel mid-stream → drain → full replay + turn_status: complete, coalesced rows), replay auth, interaction answer round-trip through a fake sidecar.
  • tests/chat-routes/sandbox-producer.test.ts (6): delta + snapshot text, tool announce/settle, step-finish usage accumulation + per-step receipt persistence, file/image part persistence, renderable ask forwarding vs auto-decline, verbatim error/cancel forwarding.
  • tests/chat-routes/upload.test.ts (7): inline-vs-sandbox decision, base64 sink write + path-ref shape, SANDBOX_REQUIRED/FILE_TOO_LARGE 413s, auth short-circuit, hostile-filename sanitization, per-request uploadDir.
  • tests/chat-store/parts-projection.test.ts (3): every storable kind survives the typed projection; junk dropped; extra fields (turnId) round-trip.
  • tests/stream-normalizer.test.ts (+4): full-vocabulary projection, typed part-key lanes.
  • Composer (+3): onSendParts emission (ready-only), file-only send, precedence + onSend back-compat.

Full gate on a clean frozen-lockfile install: pnpm typecheck + pnpm test + pnpm build (NODE_OPTIONS=--max-old-space-size=8192) all green.

Closes the createChatTurnRoutes() and multimodal-middle items of #188 Phase 1.

🤖 Generated with Claude Code

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Auto-approved drewstone PR — 46c20543

This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: drewstone_author · 2026-07-15T19:16:14Z

@drewstone
drewstone merged commit f495a49 into main Jul 15, 2026
1 check passed
drewstone added a commit that referenced this pull request Jul 17, 2026
…n, lifecycle, context-gate, turn-lock) (#200)

Add five optional, domain-free seams to createChatTurnRoutes so a complex
product turn-orchestrator can delete its hand-rolled generator and compose the
vertical. Every seam is optional — omitting all of them reproduces today's exact
behavior, and the existing #194 tests are untouched. handleChatTurn stays the
engine; the seams only wrap its input, its producer stream, and its settle.

- turnLock: async acquire before any side effect / release once when the turn
  settles (drain finish, short-circuit, or throw).
- contextGate: pre-producer domain-readiness short-circuit with a product
  Response (distinct from authorize).
- beforeTurn: observe the assembled producer input and optionally rewrite the
  prompt / prior messages.
- lifecycle: deterministic onTurnStart then exactly one of
  onTurnComplete(finalText, usage, durationMs) / onTurnError(error, durationMs).
- heartbeat: keepalive injected while the producer is quiet; window resets on
  every real event.
- onRawEvent: the raw producer events, before the engine frames them (telemetry).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants