Skip to content

feat(chat-routes): product turn-lifecycle seams (heartbeat, beforeTurn, lifecycle, context-gate, turn-lock)#200

Merged
drewstone merged 1 commit into
mainfrom
feat/chat-routes-seams
Jul 17, 2026
Merged

feat(chat-routes): product turn-lifecycle seams (heartbeat, beforeTurn, lifecycle, context-gate, turn-lock)#200
drewstone merged 1 commit into
mainfrom
feat/chat-routes-seams

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

What

Five optional, domain-free seams on createChatTurnRoutes so a complex product turn-orchestrator (the driving consumer is gtm's ~1,862-line api.chat.ts Effect 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.

handleChatTurn stays 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

Seam Signature (abridged) gtm concern it unblocks
heartbeat heartbeat?: { intervalMs; event({elapsedMs, tick}) => {type, data?} } Keepalive during silent waits (withHeartbeat around 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 beforeTurn?(args) => { prompt?, priorMessages? } | void Observe the assembled producer input and augment it before the turn (produceTurnWithIntelligence — system-prompt / message composition). The product's produce still owns the system prompt.
lifecycle lifecycle?: { onTurnStart(info); onTurnComplete({finalText, usage, durationMs}); onTurnError({error, durationMs}) } Run telemetry (startRun / endRun / flush). onTurnStart fires 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.failed events or a drain throw), not the engine envelope.
contextGate contextGate?(args) => { proceed: true } | { proceed: false, response } Context-sufficiency short-circuit (computeContextSufficiency → ask for missing context). Runs before the producer; returns a canned product Response. Distinct from authorize — domain readiness, not access. The user row is still recorded; no assistant row.
turnLock turnLock?: { acquire(args) => { acquired: true, handle? } | { acquired: false, response }; release(handle) } Single-flight lock (dual-scope session+workspace). Acquire before any side effect; release exactly once — in the drain finally on a normal turn, on a gate short-circuit, or on a throw.
onRawEvent onRawEvent?(event, context) Raw producer events for telemetry, before the engine frames them (distinct from 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)

  • heartbeat: emits keepalives while quiet, then stops on the first real event (none after the answer).
  • beforeTurn: observes the route-assembled prompt and rewrites prompt + prior messages handed to produce.
  • lifecycle: onTurnStart→onTurnComplete on success, onTurnStart→onTurnError on failure, always ordered.
  • contextGate: short-circuits with the product Response before the producer runs (user row kept, no assistant row).
  • turnLock: acquires before the turn and releases in finally even when the turn throws; plus a rejected-when-held path (no producer run, no user row).
  • onRawEvent: observes exactly the producer's own events (no engine lifecycle envelopes).

Verification

  • pnpm typecheck — clean.
  • pnpm test — 2474 passed / 17 skipped (the 2 create-agent-app tests are build-order dependent; green after pnpm build).
  • NODE_OPTIONS=--max-old-space-size=8192 pnpm build — clean; create-agent-app + --chat scaffolder 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.

…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 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 — 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 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.

🟡 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 checks options.X before 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.

value-audit · 20260717T071755Z

@drewstone
drewstone merged commit ad9fc08 into main Jul 17, 2026
1 check passed
drewstone added a commit that referenced this pull request Jul 17, 2026
…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.
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