Skip to content

perf(transcript): coalesce streaming projections to one pass per frame - #1289

Merged
pascalandr merged 6 commits into
NeuralNomadsAI:devfrom
heunghingwan:perf/frame-level-projection
Nov 5, 2026
Merged

pascalandr merged 6 commits into
NeuralNomadsAI:devfrom
heunghingwan:perf/frame-level-projection

Conversation

@heunghingwan

@heunghingwan heunghingwan commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What

Frame-level coalescing for event-driven transcript projection. Native transcript events still reduce synchronously and in order through the SDK reducer, but the projection into the message store — and the Solid re-render it triggers — now runs at most once per animation frame per session, always from the reducer's newest state.

This is the follow-up #1017's review explicitly deferred ("if the trace still shows a need"): even after #1284, every session.text.delta still paid ~2.8ms of synchronous reducer→projection→store→render, so a reply arriving as one burst still blocked the renderer for the whole series.

Measurements

Same browser harness on real SessionView + real sseManager.handleEvent, 400-delta reply delivered in one task (tests/browser/streaming-projection.test.ts):

Metric dev (805945b) this branch
Burst ingress dispatch 1093.9 ms 24.7–29.1 ms (~40×)
Longest long task 1096 ms none
Projections per burst 400 1
Per-delta dispatch p95 3.9 ms 0.2 ms
Spaced 24-delta stream (live-tunnel-like) 24 projections 11–16

How

  • New stores/opencode-projection-frame.ts: one pending entry per instanceId\0sessionId; same-frame schedules merge (the last event decides confirmPending; usage updates accumulate); runs on requestAnimationFrame, on a bounded 250 ms timer while the window is hidden (hidden windows pause rAF), and synchronously where no frame scheduler exists (node harnesses keep the previous behavior).
  • instances.ts registers gates (active session, latest window, inactive-load invalidation, updateSessionInfo); publishTranscriptChunk (its replay-ordering identity unchanged) and ordinary event projections schedule instead of projecting inline.
  • Destructive events (session.deleted, session.inbox.cancelled, session.revert.committed) and forced authoritative publications still project synchronously and subsume the pending frame.
  • Execution settlement (including replayed settlement chunks) publishes synchronously and adopts the pending frame's usage update. Every session-selection write and visibilitychange flush pending pages; a normal reducer retirement publishes its own session's pending page first, leaving other sessions on their armed wake. Resets, eviction, deletion and instance teardown cancel at the shared destroyOpenCodeData boundary — no later frame can resurrect a destroyed store.
  • Store readers (copy/speak) lag at most one frame; per-event admission revisions still bump synchronously, so read fences keep their ordering.

Invariants kept: no reference-anchored projection skip (the reducer mutates rows in place); hydrateMessages record reuse and the identical-snapshot early return stay the cheap path; transcript queue publication/onApplied ordering barriers untouched.

Tests

  • opencode-projection-frame.test.ts (unit, manual frame clock): same-task burst projects exactly once at the next frame; selection switch flushes; teardown cancels without resurrecting stores; destructive events subsume the pending frame.
  • tests/browser/streaming-projection.test.ts (borrowFixture, real SessionView): burst coalescing, zero HTTP message reads during streaming, one-frame reader convergence, visibility/switch flushes; timings logged as observations, not CI thresholds.
  • compaction-responsiveness.test.ts settles the compaction row's creation frame before resetting its counters.

Verified: typecheck clean; transcript/instance unit families (opencode-data, replay, settlement, reconnect, idle, compaction, native events, request authority, pruning, catalog… 437+ tests) pass; browser suites streaming-projection, part-durations, compaction-responsiveness, render-cost, message-scroll (128/128) pass; markdown-latest-request fails one pre-existing test, identical on base.

Docs: dev-docs/TRANSCRIPT.md (agent conventions), dev-docs/CACHE_REFRESH_CONVENTIONS.md (transcript pages row).

Every native transcript event reduced synchronously and then projected
into the message store in the same task, so a reply delivered as one
burst blocked the renderer for the whole projection series: a 400-delta
reply measured a single ~1.1s long task at ~2.8-4ms per delta, freezing
input on local and remote transcripts alike (NeuralNomadsAI#1017 explicitly left
frame-level batching as follow-up work).

Events still reduce immediately and in order through the SDK reducer;
only the projection into the store and the re-render it causes moves to
the frame level. stores/opencode-projection-frame.ts runs at most one
projection per animation frame per session, always from the reducer's
newest state: same-frame schedules merge, the last event decides
pending-send confirmation, and usage updates accumulate. Hidden windows
fall back to a bounded 250ms timer (they pause animation frames), and
environments without a frame scheduler keep the previous synchronous
projection. Store readers lag at most one frame.

Ordering and lifetime edges: a session selection write and a visibility
transition flush pending pages first; transcript eviction and instance
teardown cancel them so no later frame resurrects a destroyed store;
destructive events (session.deleted, inbox cancellation, revert commit)
and forced authoritative publications still project synchronously and
subsume the pending frame; an event whose session stopped being active
at frame time invalidates the loaded page so the next activation reads
authoritatively. The reference-anchored projection-skip prohibition and
hydrateMessages record reuse are untouched.

Covered by opencode-projection-frame.test.ts (manual frame clock) and
tests/browser/streaming-projection.test.ts (real SessionView with a
400-delta burst: projections 400 -> 1, burst dispatch ~1094ms -> ~25ms,
no long task); compaction-responsiveness settles the compaction row's
creation frame before resetting its counters.
Two gaps the native pruning UI check exposed on a quiet page:

An idle compositor stops producing frames (a hidden or headless page with
nothing to draw), so a schedule armed only on requestAnimationFrame could
hang a pending projection forever — no projection means no DOM change,
which means no next frame. Every visible-window schedule now also arms a
32ms fallback; whichever wake wins cancels the other, so a live page
still projects exactly once per frame and an idle one projects within
the fallback window. The hidden-window timer stays at its calmer 250ms.

An execution settlement (session.execution.{succeeded,failed,interrupted})
now projects synchronously like the destructive events: it is the stable
point interactive reads synchronize on — part deletion rejects a row
whose status still reads "streaming" — and it fires once per turn, so
the synchronous cost is one projection at settlement.

Verified by reproducing the failing native pruning UI run locally: the
traced page showed the final event batch scheduled but never projected
on an idle compositor, and the deletion click reading a settled row a
frame behind. Both fixed runs pass all nine UI checks twice; unit tests
cover the idle-compositor fallback and the same-task settlement status.
@heunghingwan

Copy link
Copy Markdown
Contributor Author

Fixed both root causes and pushed 2c4da016; new checks are running.

What the failure exposed (reproduced locally by tracing the fixture page):

  1. Idle compositor deadlock — the traced page showed the final event batch scheduled but never projected: on a quiet page (hidden/headless, nothing to draw) the compositor stops producing frames, so a callback armed only on requestAnimationFrame never ran — and no projection meant no DOM change, so no next frame either. Every visible-window schedule now also arms a 32 ms fallback; whichever wake wins cancels the other, so a live page still projects exactly once per frame and an idle one within the fallback window.

  2. Settlement reads a frame behind — an execution settlement is the stable point interactive reads synchronize on: part deletion rejects a row whose status still reads streaming. session.execution.{succeeded,failed,interrupted} now projects synchronously like the destructive events (once per turn, so the cost is one projection at settlement).

Verified: the failing native pruning UI run passes all nine UI checks twice locally; opencode-projection-frame.test.ts grows cases for the idle-compositor fallback and the same-task settlement status (6/6); transcript unit families 192/192; typecheck clean; streaming-projection 3/3 with the burst still ~26 ms and no long task; compaction-responsiveness 5/5.

session.tool.progress renders through the coalesced frame projection, so
the call list assertion must wait for the frame it lands on instead of
reading the DOM in the same task as the event.
@heunghingwan

Copy link
Copy Markdown
Contributor Author

Pushed 534bdfb7 for the tool-images timing; notes on the two other failures from the previous run:

  • tests — Test Tauri crate panicked in appimage_exit::tests::only_children_running_from_the_mount_are_stopped with spawn sleeper: Os { code: 26, kind: ExecutableFileBusy, message: "Text file busy" } (ETXTBSY on a freshly written fixture executable). This PR touches no Rust/Tauri code; the same test passed in the first run of this PR (10238cab). Looks like a runner-level flake — a rerun of the failed job would confirm (I don't have rights to rerun on this repo).
  • tests-browser (2) — localized Missions preferences wait for a held French dictionary… timed out at 30 s. I reproduced the same timeout locally on the PR's parent commit 805945b (unmodified dev), so it predates this change. The other browser shard failure (tool-images Code Mode progress count 2 !== 3) was a same-task DOM read of an event-driven update that now lands on its frame; the test settles a frame before counting (fixed in 534bdfb7, suite 6/6 locally).

@heunghingwan

Copy link
Copy Markdown
Contributor Author

Remaining reds on 534bdfb7 are the two known-flaky lanes (both passed the first run of this PR on the same code, and both reproduce green locally):

  • tests-runtime-contracts (latest) — test-design-activation-native.mjs failed with Agent not found: "design" from the externally installed OpenCode service. This PR touches only UI-side store projection (no provisioning, agents or server code); the same job passed on 10238cab with the same client v2.2.5.
  • tests-browser (2) — automation-preview "shared engine drives a real Electron preview" expects /select.*Plan/ in the snapshot but got input "Title". Same failure lane as the known automation-suite flakiness; passes locally on this branch, and shard 2's earlier failures from the previous run (mission-control French dictionary, tool-images) are now green.

tests, all session-pruning-native lanes (including the Windows UI check this PR originally broke), and the other browser shards are green. If a maintainer could rerun the two failed jobs, that should clear them — I don't have rerun rights on this repo.

@pascalandr pascalandr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@heunghingwan I reviewed 534bdfb7. The performance direction checks out: the burst uses one projection instead of 400, and the warmed-up browser probe avoids the long task seen on the base. Three correctness regressions remain. Each reproduces on this head and passes on the base; isolated scratch fixes also pass.

P2: a synchronous settlement drops the pending usage update

packages/ui/src/stores/instances.ts:2314-2322

Deliver session.step.ended with usage, immediately followed by session.execution.succeeded before the frame runs. The step schedules updateUsage: true; settlement cancels that entry and projects synchronously, but skips updateSessionInfo because execution settlement is absent from USAGE_EVENT_TYPES. The message store reports 118 combined tokens while session-info reports 0 (or remains absent in a cold store). Running another frame cannot recover the cancelled obligation.

Please preserve the pending usage update when a synchronous publication subsumes it. Updating session info at synchronous barriers passes the reproduction.

P2: a pending frame can cross a hard reconnect/source reset

packages/ui/src/stores/opencode-projection-frame.ts:135-139

The entry retains its old Data without a lifetime/generation fence. The instance callers added here cancel frames, but reconnect's resetInstance: destroyOpenCodeData goes directly through the shared destruction boundary. Once that deletes the transcript entry, projectOpenCodeMessages' conditional current-entry guard permits the old data.

Reproduction: queue streamed text, receive server.connected from a new source, complete a forced authoritative empty load, then run the old frame. The authoritative empty store regains old rows ([] becomes msg_000, msg_001, msg_live in the store probe; the browser production-dispatch probe also resurrects msg_live).

Please cancel at the shared destroy/reset lifetime boundary, or fence publication on the captured lifetime. Central cancellation passes the reproduction.

P2: queued replay misses the settlement barrier and can lose a completed reply

packages/ui/src/stores/instances.ts:2280-2283

publishTranscriptChunk always schedules, even for a chunk containing execution settlement. The synchronous-event set protects only the immediate event path.

Reproduction: fill the reducer's 200-message window, then admit another reply and settlement while rotation queues events. Replay drains and retires the reducer before the reply reaches the display store. If another execution starts before the frame, its different Data replaces the pending entry. The next reply becomes visible while the previous completed reply remains absent. The base preserves both.

Please synchronously publish replay chunks containing settlement before retirement, subsuming the pending frame just as on the immediate path. This passes the back-to-back-execution reproduction.

Test follow-ups

  • P3: opencode-projection-frame.test.ts is missing from the workflow's explicit UI unit lists. Add it to the browser-conditioned integration step; browser-suite discovery does not run store unit files.
  • P3: streaming-projection.test.ts:25-28 answers /api/events with the generic JSON fallback. A strict console collector catches the EventSource MIME error. Please use fixtureEventStream and retain console/network failure checks, as in the hardened #1284 fixture.
  • history-navigation.test.ts:985 assumes the scheduled row is already rendered. It fails intermittently on this head and passes on the base. Await actual publication, then recheck the original marker's identity so the assertion still protects the scroll/DOM invariant.
  • The new scheduler unit tests do not catch removal of usage publication, the latest-window gate, hidden-window timer behavior or pending-send confirmation semantics. The spaced-stream browser assertion also passes on the base: check complete text and the intended projection behavior, rather than only its suffix.

Validation

UI typecheck passes. The 35-file transcript/instance/replay/settlement/reconnect/idle/compaction/authority/pruning selection passes 460/460 twice. New browser suite passes three runs; stream-delta-churn, tool-images, compaction-responsiveness, part-durations, project-tab-restore (50/50) and focused scrolling (34/34) pass. The independent store/browser probes expose the regressions above despite those green suites.

I also checked the two red CI jobs: the Design activation failure comes from a native fixture outside this UI scheduler's import path; the Electron preview snapshot test passes locally on both head and base. Those failures do not account for the three reproduced regressions here.

Adopting the review's three reproductions, each now covered by a unit
case in opencode-projection-frame.test.ts:

A synchronous settlement or destructive event now adopts the pending
frame's usage obligation instead of dropping it (a step end with usage
followed by execution.succeeded in one task used to leave session info
at zero while the message store carried the tokens).

destroyOpenCodeData cancels coalesced frames at the shared lifetime
boundary — a hard reconnect's reset previously deleted the transcript
entry while a pending frame kept the old Data, and the missing entry
slipped past the projection guard, resurrecting the pre-reset rows over
an authoritative empty load. The frame module now reaches the store only
through the registered gates, so the reducer module can cancel without
an import cycle.

A replayed chunk carrying an execution settlement publishes
synchronously through the same barrier as the immediate path, so a
drained-and-retired rotation can no longer lose the completed reply it
held to a later frame or a newer execution's Data.

Also per review: the frame test joins the workflow's browser-conditioned
unit list; streaming-projection borrows the quiet fixture event stream
and keeps strict console/network failure checks (which immediately
caught the unmocked Yolo probe); its spaced-stream case now asserts the
complete coalesced text and fewer passes than deltas; and
history-navigation awaits the scheduled row's actual publication before
rechecking the distant marker's identity.

Verified: 252/252 across the transcript/instance unit families,
typecheck clean, streaming-projection 3/3, compaction-responsiveness
5/5, tool-images 6/6, and the isolated native pruning UI run passes all
nine checks on this head.
@heunghingwan

Copy link
Copy Markdown
Contributor Author

Thanks @pascalandr — all three reproductions are fixed and unit-covered in d90d7506, each verified against the scenario you described:

  • Settlement drops pending usage — synchronous barriers now run through one publishSynchronousPage helper that takes the pending entry via a new takeTranscriptProjection and adopts its updateUsage obligation (your step-end-then-succeeded sequence now publishes totals in the same task; covered by "a synchronous settlement adopts the pending frame's usage obligation").
  • Pending frame crosses a hard reconnect — destroyOpenCodeData now cancels coalesced frames at the shared boundary. To let the reducer module cancel without an import cycle, the frame scheduler reaches the store only through the registered gates (projectPage), which also fixed a parameter-order slip the new tests caught (preserveOmitted/confirmPending). Covered by "destroying the reducer cancels its pending frame at the shared boundary".
  • Replay loses a completed reply — a replayed chunk carrying an execution settlement publishes synchronously through the same barrier before the drained reducer retires. Covered by "a replayed settlement chunk publishes before the drained reducer retires" (full 200-message window, reply queued through rotation, asserted complete in the store with no frame fired).

Test follow-ups, also in d90d7506: the frame test joins the workflow's browser-conditioned unit list; streaming-projection now uses the quiet fixtureEventStream with strict console/network checks (which immediately caught an unmocked Yolo probe — now served), and its spaced case asserts the complete coalesced text plus fewer passes than deltas; history-navigation awaits the streamed row's actual publication, then rechecks the distant marker's identity. Added unit coverage also includes the latest-window gate and the hidden-window timer (bounded-interval behavior), and pending-send confirmation is asserted in both merge directions.

Validation on this head: 252/252 across the transcript/instance unit selection (twice), typecheck clean, streaming-projection 3/3, compaction-responsiveness 5/5, tool-images 6/6, history-navigation resident-streaming case green, and the isolated native pruning UI run passes all nine checks.

@heunghingwan

Copy link
Copy Markdown
Contributor Author

d90d7506 is green everywhere except the three cancelled macOS lanes (tests-tauri-macos, tests-opencode-compatibility (macos-26), tests-session-pruning-native (macos-latest) — all conclusion: cancelled, the known runner-loss pattern; their first run on this PR completed fine). Everything that actually ran passes, including tests (with the frame test now in the workflow's unit list), both tests-runtime-contracts lanes, all four browser shards, tests-tauri-windows, and tests-session-pruning-native on Windows/ubuntu. If a maintainer could rerun the cancelled macOS jobs, that should complete the matrix — I don't have rerun rights on this repo.

@pascalandr pascalandr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@heunghingwan Round 2 on d90d7506: all three original reproductions are fixed, and independently reverting each fix breaks its corresponding regression. The CI registration, persistent SSE, pending-confirmation checks and hidden-window coverage are also addressed. One side effect of the shared cancellation boundary remains blocking.

P2: normal retirement cancels the final tool publication

packages/ui/src/stores/opencode-data.ts:1036, called by retireDrainedTranscript at :924

Reproduced through the production dispatcher, in both a schema-validated store probe and a real browser:

  1. Settle an execution while one of its tools is still running.
  2. Let the settlement read return the still-running tool; its reducer correctly remains resident.
  3. Deliver session.tool.success afterward.
  4. The event schedules the completed page, then finishOpenCodeDataEvent retires the now-drained reducer.
  5. The new central cancellation drops that pending final page.

After subsequent frames the display still says running and has no successful output. This fails on the new head and passes on both the first-round head and the original base.

Please publish this session's pending final page and usage obligations before normal retirement, while keeping resets, eviction and destructive teardown cancel-only. A scratch publication-before-retirement fix changes the browser result from running to completed and preserves all three original fixes. This should fit the existing publication machinery rather than require another scheduling path.

P3: the history regression still does not retain the original marker

packages/ui/tests/browser/history-navigation.test.ts:988-990

The publication wait fixes the timing issue, but marker is a Locator: its later evaluate resolves the currently matching element, not the original one. Replacing the original marker with a clone during the frame still passes the test, even though an independent witness confirms the original node is disconnected. Retain an ElementHandle before streaming, then check that same node after publication.

Validation

  • Original usage, hard-reset and full-window replay reproductions all pass. Reverting usage adoption, reset cancellation or the replay barrier independently makes its reproduction fail.
  • Typecheck passes; the 35-file transcript/instance/authority/pruning selection passes 466/466 twice. Browser suites pass: streaming-projection three runs, stream-delta-churn 2/2, tool-images 6/6, compaction-responsiveness 5/5, project-tab-restore 50/50 and the full history-navigation file 70/70.
  • Performance direction remains positive: 400 burst projections become one, with no burst long task observed in the warmed harness.
  • The three current macOS job failures are runner-acquisition failures: their annotations say the jobs were not acquired by a hosted runner. All browser shards and both runtime-contract lanes passed.

Round-2 review: the shared destroy boundary cancelled the page a late
tool settlement had just scheduled, so retiring the drained reducer right
after session.tool.success dropped the completed output and the display
kept showing the running row. Normal retirement now publishes the
session's pending page (and its usage obligations) through a scoped
flush before the entry goes away, while resets, eviction and destructive
teardown stay cancel-only. The scoped drain leaves other sessions on
their armed frame.

The history-navigation marker check pins the original node with an
ElementHandle, so a clone replacing it during the published frame can no
longer satisfy the identity assertion the way a re-resolving Locator
did.

Covered by "a retirement-scoped flush publishes the pending final page
and its usage". Verified: 259/259 across the transcript/instance unit
families, typecheck clean, streaming-projection 3/3, compaction
-responsiveness 5/5, the history case green, and the isolated native
pruning UI run passes all nine checks.
@heunghingwan

Copy link
Copy Markdown
Contributor Author

Round-2 items are fixed in 4df39cb5:

  • Retirement drops the final tool publication — retireDrainedTranscript now publishes the session's pending page (and its usage obligations) through a scoped flushTranscriptProjections(instanceId, sessionId) before the normal-retirement destroy; resets, eviction and destructive teardown still cancel only. The scoped drain leaves other sessions pending on their armed frame. Covered by "a retirement-scoped flush publishes the pending final page and its usage", which schedules a streamed page plus a usage-carrying step end and asserts both publish in the flush task with nothing left for a later frame.
  • Marker identity — the history-navigation case now pins the original node with an ElementHandle before streaming and rechecks that same node after publication, so a replacing clone fails the assertion.

Verified on this head: 259/259 across the transcript/instance unit selection (including the frame suite), typecheck clean, streaming-projection 3/3, compaction-responsiveness 5/5, the history case green, and the isolated native pruning UI run passes all nine checks. Thanks also for confirming the runner-acquisition cause on the macOS lanes.

…n flush

A normal reducer retirement publishes its own session's pending page through
a scoped flush. That flush cancelled the shared wake and armed a fresh one for
the sessions it left pending, so in a hidden window every retirement elsewhere
pushed an unrelated session's 250ms publication back again; a steady trickle
of late tool results could postpone it indefinitely.

A scoped drain now leaves the armed wake alone and only disarms once nothing
remains pending; projections scheduled during the drain still request a wake,
which is a no-op while one is armed.

Tests: the retirement case now goes through real events (a tool still running
at settlement, then its late success), so dropping the flush before retirement
fails it; a hidden-window case retires late tools in four other instances
100ms apart and requires the waiting session to publish on its original
250ms wake, which fails with the re-arming drain.
@heunghingwan

Copy link
Copy Markdown
Contributor Author

Thanks for the direct fix — 6f7f3f12 fast-forwards cleanly and I verified it here: the frame suite (including your hidden-window postponement case) is 14/14, the transcript/instance unit selection 172/172, and typecheck is clean. The keep-the-armed-wake correction is a strictly better shape than my re-arm; no further changes from my side.

@pascalandr
pascalandr merged commit d832728 into NeuralNomadsAI:dev Nov 5, 2026
4 of 19 checks passed
@heunghingwan
heunghingwan deleted the perf/frame-level-projection branch November 5, 2026 09:25
@pascalandr

Copy link
Copy Markdown
Contributor

@heunghingwan Thanks to you for all these nice optimisation PR !

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