feat(chat-routes): product turn-lifecycle seams (heartbeat, beforeTurn, lifecycle, context-gate, turn-lock)#200
Conversation
…n, lifecycle, context-gate, turn-lock) 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).
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — 6ab46948
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-17T07:13:33Z
tangletools
left a comment
There was a problem hiding this comment.
🟡 Value Audit — sound-with-nits
| Verdict | sound-with-nits |
| Concerns | 2 (2 weak-concern) |
| Heuristic | 0.0s |
| Duplication | 0.0s |
| Interrogation | 154.1s (2 bridge agents) |
| Total | 154.1s |
💰 Value — sound-with-nits
Adds five optional, domain-free turn-lifecycle seams to createChatTurnRoutes (turnLock/contextGate/beforeTurn/lifecycle/heartbeat + onRawEvent) so a complex product orchestrator can compose the vertical instead of forking it — additive, in-grain, well-tested; one overlap nit with the pre-existing on
- What it does: Five optional callbacks on createChatTurnRoutes that wrap the turn's input, producer stream, and settle without altering handleChatTurn: (1) turnLock — async acquire before any side effect, release exactly once on drain-finish/short-circuit/throw (turn-routes.ts:459-482, 660, 684); (2) contextGate — pre-producer domain-readiness short-circuit returning a product Response, distinct from authorize (
- Goals it achieves: Let a complex product turn-orchestrator (PR body names gtm's ~1,862-line api.chat.ts Effect generator) compose the assembled vertical through typed callbacks instead of hand-rolling a parallel generator. Contribute-down: generic mechanism (single-flight, keepalive, telemetry, input-augment, readiness), domain supplied as callbacks — no product import, no baked domain value. Keeps handleChatTurn as
- Assessment: Sound and in the grain. The codebase's existing convention IS injected callbacks on CreateChatTurnRoutesOptions — authorize, produce, onEvent, onTurnComplete, transformFinalText, traceFlush were already there (verified against HEAD~1). The five new seams follow the exact same shape and ordering discipline. Additivity is honored: every seam is
?, every code path checksoptions.Xbefore invoking - Better / existing approach: Searched src/stream, src/runtime, src/missions, src/trace, src/assistant for prior art: no existing heartbeat/keepalive mechanism (only SSE comment-line skipping in src/assistant/sse.ts:55), no single-flight/turnLock primitive, no onTurnStart/onTurnError lifecycle pattern. src/trace FlowSpan/FlowTrace and src/missions status machine are different layers (cross-context delegation waterfalls; multi-
- Model: opencode/zai-coding-plan/glm-5.2
- Bridge attempts: 2
- Bridge warning: opencode/kimi-for-coding/k2p7: bridge stream ended without value-audit content
🎯 Usefulness — sound-with-nits
Five optional, domain-free turn-lifecycle seams on createChatTurnRoutes that let a complex orchestrator compose the vertical instead of hand-rolling a generator; each omittable, typed, tested, and example-documented.
- Integration: Reachable and wired correctly. createChatTurnRoutes is consumed today by create-agent-app/template-chat/src/chat.ts:123, examples/chat-app.md:170 (shows all five seams wired), and tests/chat-routes/turn-routes.test.ts:249-403 (one test per seam). All seams are optional so existing call sites stay green — the template-chat zero-override call at template-chat/src/chat.ts:123-149 ignores them. The dr
- Fit with existing patterns: Follows the established seam grain. authorize was already a Request→Response short-circuit seam; contextGate and turnLock reuse that exact shape (turn-routes.ts:161-170, 168-178). heartbeat/onRawEvent/beforeTurn mirror the existing optional-hook style (onTurnComplete, onEvent, transformFinalText at turn-routes.ts:254-259). No competing pattern: grep for withHeartbeat/heartbeat/keepalive across src
- Real-world viability: Mostly holds. Heartbeat window-reset is correct: withStreamHeartbeat (:358-390) re-awaits the same pending promise after a keepalive win, clears the timer, and closes the source in finally. Tested against a real silent window at turn-routes.test.ts:268-296 (>=2 keepalives, none after the first real event). turnLock release runs on all three exit paths — short-circuit (:500), drain finally (:660),
- Model: opencode/zai-coding-plan/glm-5.2
- Bridge attempts: 1
💰 Value Audit
🟡 Two success-path completion hooks now coexist: options.onTurnComplete and options.lifecycle.onTurnComplete [duplication] ``
Pre-PR the route already had options.onTurnComplete (turn-routes.ts:254, fires only on success via the engine hook at turn-routes.ts:593-598 with {identity, finalText, context}). This PR adds options.lifecycle.onTurnComplete (turn-routes.ts:207, fires on success with {identity, executionId, turnStreamId, context, finalText, usage, durationMs}) plus the missing onTurnError sibling. On the success path BOTH fire — a consumer wiring telemetry via lifecycle and billing via onTurnComplete now has two
🎯 Usefulness Audit
🟡 Two turn-complete hooks now coexist (onTurnComplete + lifecycle.onTurnComplete) [ergonomics] ``
The pre-existing options.onTurnComplete (turn-routes.ts:254, success-only, wired into the engine hook at :593-598, documented for billing/titles/audit) now sits beside lifecycle.onTurnComplete (turn-routes.ts:207, fires post-drain-settle at :617 with finalText+usage+durationMs). A product adopting lifecycle gets two completion callbacks with different payloads and timing. They serve different intents (post-processing vs telemetry) so this is not duplication, but a human should weigh whether the
What this audit checks
It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.
| Pass | What it asks |
|---|---|
| Heuristic | Vague title? Whitespace-only or cruft-bearing diff? (content signals only) |
| Duplication | Do added function/class names already exist elsewhere in the repo? |
| Value Audit | What does it do? What goal does it achieve? Is it good? Better architecture or already-exists? |
| Usefulness Audit | Does it integrate and fit? Will it hold up in real use and actually get used? |
Findings are concerns, not blocks — the human reviewer decides what to do with them.
…cument upload's sole consumer (#202) Audit simplification pass. No behavior change, no export added or removed — honest labeling of the single-consumer surface the over-engineering audit flagged. - turnLock/contextGate/beforeTurn/onRawEvent: @experimental + single-consumer (gtm, #200) JSDoc. They stay FLAT top-level options (not grouped under a `hooks` object): that regroup would break gtm's shipped createChatTurnRoutes call for no mechanism gain, and this package's exports are additive-only. lifecycle/heartbeat stay stable (generically useful). - createUploadRoute: kept, NOT deleted. Its sole consumer is the `--chat` scaffold (the shipped reference multimodal path, PR #199). Documented that the four fleet apps (gtm/tax/legal/insurance) keep their own durable-vault upload routes (KV / encrypted R2) — a different persistence model — and shouldn't route through it. - Docs (AGENTS.md/CLAUDE.md module map, ARCHITECTURE.md) reflect both.
What
Five optional, domain-free seams on
createChatTurnRoutesso a complex product turn-orchestrator (the driving consumer is gtm's ~1,862-lineapi.chat.tsEffect generator) can delete its hand-rolled generator and compose the vertical instead. Contribute-down: generic mechanism, the domain supplied as callbacks/values — no product import, no baked domain value.handleChatTurnstays the turn engine; the seams only wrap its input, its producer stream, and its settle. No loop logic added.Seams → the gtm concern each unblocks
heartbeat?: { intervalMs; event({elapsedMs, tick}) => {type, data?} }withHeartbeataround provisioning / first-token / long tool calls). Emits while the producer is quiet; the window resets on every real event, so a chatty producer never triggers one.beforeTurn?(args) => { prompt?, priorMessages? } | voidproduceTurnWithIntelligence— system-prompt / message composition). The product'sproducestill owns the system prompt.lifecycle?: { onTurnStart(info); onTurnComplete({finalText, usage, durationMs}); onTurnError({error, durationMs}) }startRun/endRun/flush).onTurnStartfires before the producer; exactly one of complete/error fires after settle, including on failure, always after start. Failure is the route's own verdict (error/session.run.failedevents or a drain throw), not the engine envelope.contextGate?(args) => { proceed: true } | { proceed: false, response }computeContextSufficiency→ ask for missing context). Runs before the producer; returns a canned productResponse. Distinct fromauthorize— domain readiness, not access. The user row is still recorded; no assistant row.turnLock?: { acquire(args) => { acquired: true, handle? } | { acquired: false, response }; release(handle) }finallyon a normal turn, on a gate short-circuit, or on a throw.onRawEvent?(event, context)onEvent, which sees the engine-framed stream incl. lifecycle envelopes).Back-compat guarantee
Every seam is optional; omitting all of them reproduces today's exact behavior. The existing #194 tests are untouched and green. New seam types export automatically via the existing
export * from './turn-routes'.Tests (one sharp test per seam, reusing the #194 fake-producer harness)
produce.onTurnStart→onTurnCompleteon success,onTurnStart→onTurnErroron failure, always ordered.Responsebefore the producer runs (user row kept, no assistant row).Verification
pnpm typecheck— clean.pnpm test— 2474 passed / 17 skipped (the 2create-agent-apptests are build-order dependent; green afterpnpm build).NODE_OPTIONS=--max-old-space-size=8192 pnpm build— clean;create-agent-app+--chatscaffolder suites green against the fresh dist.Docs: an "Advanced hooks (optional)" block added to
examples/chat-app.md.Do not merge — the coordinator drives merge + release.