feat(chat-routes): persist assistant parts incrementally as a turn streams#250
Merged
Conversation
…reams The assistant row was written once, after the stream drained. A viewer arriving mid-run read an empty transcript unless the streaming gateway's hot event buffer still held the turn — which pushes toward stretching that buffer's TTL, and its Redis cost is one sorted set per session refreshed on every push, so memory grows LINEARLY with `ttl x concurrent sessions`. Raising it buys memory proportional to the increase and still serves nothing past the new horizon. So: keep the hot buffer a short reconnect window and make durable storage the history tier — which only works if storage actually holds the in-flight turn. - `/stream` gains `draftAssistantParts`: finalize MINUS the dangling-tool terminalizer. A mid-stream snapshot through `finalizeAssistantParts` would persist every in-flight tool as `status:'error'` + `metadata.terminalized`. Terminalization stays a completion-time decision. - `/chat-routes/draft-persistence`: coalescing writer — time floor (default 2 s), dirty gate (content events only, never heartbeats), single-flight, best-effort, redaction parity on draft text AND draft text parts, draft-only tool-output cap with size-based backoff. - Both lanes: `createChatTurnRoutes` (`incrementalPersistence`, on by default when the store can patch rows) and `runDetachedTurn` (`persist`, opt-in, transfers row ownership and returns `messageId`). - Idempotency reuses the turn's OWN identity — no new state. The row id derives from `deriveExecutionId` / `turnId`, so a re-entered turn adopts and patches the row a crashed attempt started. The final write is insert-or-patch under the same id and produces the row today's single write produces; an empty turn retracts the draft. - `resolveChatTurn` takes `hasRunningTurn` so a draft assistant row cannot break retry dedup: trailing assistant rows are walked past only while the thread's turn is still running, so a user genuinely repeating a message still gets a new turn. - `/chat-store` gains `updateMessage`/`deleteMessage` and honors a caller-assigned row id. Additive: a store without `updateMessage` keeps today's exact single-write behavior; an explicit opt-in against such a store throws at route construction rather than silently degrading.
tangletools
approved these changes
Jul 25, 2026
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — 5de90df8
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-25T00:40:12Z
…nside the drain - `createChatStore` satisfies `AssistantDraftStore` structurally, so the `as unknown as` in the route and the `as never` in the test go away. - Close the draft writer inside the waitUntil-tracked drain: on the path where the producer throws, `persistAssistantMessage` never runs, and an unawaited write would race isolate teardown.
tangletools
approved these changes
Jul 25, 2026
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — eb51c186
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-25T00:47:46Z
…fting on
Closes the one proof gap: with incremental persistence enabled, a
terminal error EVENT must still reach `onTurnComplete({failed:true})`
(billing branches on that) and leave exactly ONE assistant row carrying
the partial answer plus the visible error — not a second row, not an
empty one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The assistant row is written once today, after the stream drains. A viewer arriving mid-run reads an empty transcript unless the streaming gateway's hot event buffer still holds the turn. This persists the assistant row incrementally as the turn streams, in both lanes.
Why this and not "raise the buffer TTL"
The gateway's hot buffer is one Redis sorted set per session,
EXPIRErefreshed on every push. Lingering keys =arrival_rate x TTL, so memory is linear in the TTL:A 12x TTL bump buys 12x memory and zero extra coverage for a viewer who opens the tab tomorrow. The hot buffer must stay a short reconnect window; durable storage must be the history tier — which only works if storage actually holds the in-flight turn. That is this PR.
Design
/streamdraftAssistantParts—finalizeAssistantPartsMINUS the dangling-tool terminalizer. A mid-stream snapshot through the finalizer would rewrite every in-flight tool tostate.status:'error'+metadata.terminalized, so a reader would see phantom tool failures the final write silently reverses. Terminalization stays a completion-time decision. Pending asks likewise staypending./chat-routes/draft-persistence— coalescing writer: time floor (default 2 s), dirty gate (content events only — heartbeats and lifecycle envelopes never arm a write), single-flight (an in-flight write suppresses the next trigger rather than queueing), best-effort (a store outage degrades freshness, never the stream), redaction parity on draft text AND every draft text part, draft-only tool-output cap (32 KiB) with size-based interval backoff.createChatTurnRoutes(incrementalPersistence, on by default when the store can patch rows) andrunDetachedTurn(persist, opt-in; transfers row ownership and returnsmessageId).deriveExecutionId(interactive) /turnId(detached). A re-entered turn looks that id up through the store's ordinarylistMessagesand patches the row a crashed attempt started. The final write is insert-or-patch under the same id, producing the row today's single write produces; an empty turn retracts the draft.resolveChatTurngainshasRunningTurnso a draft row cannot break retry dedup. Trailing assistant rows are walked past only while the thread's turn is stillrunningin the turn buffer — so a retry converges, and a user genuinely repeating a message still gets a new turn./chat-storegainsupdateMessage/deleteMessageand honors a caller-assigned row id.Why 2 s
Measured on a real tool-heavy run: 517 stream events over ~90 s (~5.7 events/s). A 2 s floor plus the dirty gate turns 517 candidate writes into <=45, leaving the row at most 2 s stale — an order of magnitude below the time it takes a viewer to open a tab and render. Fleet arithmetic at 10k concurrent 60 s runs: <=30 updates/run x 167 run-starts/s = ~334 row-updates/s across per-tenant shards. Product-tunable via
incrementalPersistence: { intervalMs, backoffBytes, maxDraftToolOutputBytes }.Proof — a real sandbox turn, real model, real SQLite
tests/chat-routes/incremental-persistence.live.test.ts(skipped withoutLIVE_SANDBOX=1+ creds) against a real box onsandbox.tangle.tools, a real 26.5 s agent turn with two real bash tool calls, driven through the realcreateChatTurnRoutesinto a real better-sqlite3 chat store, with an independent poller reading the DURABLE row every 100 ms:The row grew from 4 chars to 469 while the turn ran, in-flight tools persisted as
running(never terminalized), exactly one assistant row survived, and the settled row byte-matchedproducer.assistantParts()— the only thing today'spersistAssistantMessagewrites.tests/chat-routes/incremental-persistence.test.ts— 10 tests over the real drizzle/SQLite store (no store fake): mid-run growth queried from inside the stream, byte-identical final row vs. the single-write path, coalescing (200 token events -> 1 append + <=3 patches), empty-turn retraction, draft redaction parity, interactive-lane crash convergence, detached-lane crash convergence, the genuine-repeat non-regression, and the additive guarantee.Every test proven capable of failing
expected 'error' to be 'running'waitUntil timed out: assistant row after first textexpected [2 rows] to have a length of 1(duplicate user row)expected 'Checking ' to be 'Checking the lease. Found 2.'expected 'SSN 123-45-6789 on file' to be 'SSN [redacted] on file'expected '[{"type":"text","text":"SSN 123-45-67…' not to contain '123-45-6789'expected 182 to be less than or equal to 3expected [1 row] to have a length of +0waitUntil timed out: partial row from the crashed detached attemptGates
pnpm typecheckclean -pnpm test194 files, 3161 passed, 1 skipped (the live test, correctly skipped without creds) -pnpm buildclean -agent-docs --checkfresh.Additive
A store without
updateMessagekeeps today's exact single-write behavior. An explicit opt-in against such a store throws at route construction rather than silently degrading every turn.