Skip to content

perf: make streaming projection and activity appends incremental - #9152

Merged
t3dotgg merged 4 commits into
mainfrom
t3code/streaming-projection-incremental
Sep 2, 2026
Merged

perf: make streaming projection and activity appends incremental#9152
t3dotgg merged 4 commits into
mainfrom
t3code/streaming-projection-incremental

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Sep 2, 2026

Copy link
Copy Markdown
Member

Takes over #6608 by @x1xhlol.

Problem

A streaming turn emits one thread.activity-appended event per tool step and one thread.message-sent per delta. On the server, each user message still ran the full refreshThreadShellSummary, which reads every message body in the thread. On the client, every appended activity re-filtered and re-sorted the whole activity array, so a long turn cost O(n² log n) across the stream.

Fix

Server (ProjectionPipeline.ts): thread.message-sent now updates the thread row directly. It bumps updatedAt and advances latestUserMessageAt only when a newer user message arrives. That is the only summary field a message can change, and the message projector never rewrites createdAt of an existing message, so the running maximum matches the full recompute. Activities keep the existing lifecycle-kind gate from #8150.

Client (threadReducer.ts): thread.activity-appended takes an O(1) append path when the current array was produced by this reducer (so it is known to be activityOrder sorted), the new activity sorts at or after the tail, and its id is unseen. A WeakMap keyed by array identity holds the id set and moves forward with each append. Out-of-order arrivals, re-deliveries, resolvable context-window updates (which supersede earlier ones for the same turn), and snapshot-loaded arrays all fall through to the existing filter and sort path, which rebuilds the index.

Rebase notes

Main moved under the original branch:

Audit of every field refreshThreadShellSummary computes and which events can change it:

Field Source rows Events that write them Still refreshes
latestUserMessageAt messages (role user) thread.message-sent (user), thread.reverted folded directly / yes
pendingApprovalCount pending approvals approval.requested, approval.resolved, provider.approval.respond.failed, thread.approval-response-requested yes
pendingUserInputCount user-input lifecycle activities user-input.requested, user-input.resolved, provider.user-input.respond.failed yes
hasActionableProposedPlan latestTurnId plus plan rows thread.proposed-plan-upserted, thread.session-set, thread.turn-diff-completed, thread.reverted yes

Measurement

Reducer benchmark, N in-order thread.activity-appended events on one thread, vitest on this machine, best of 2:

N main this branch
2,000 61 ms (31 us per append) 12 ms (6 us per append)
10,000 1,180 ms (118 us per append) 70 ms (7 us per append)

The per-append cost on this branch stays flat as the thread grows. Server side, each user message no longer runs the four summary queries, including the full message-body read.

Tests

  • vp test run apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts packages/client-runtime/src/state/threadReducer.test.ts: 58 passed
  • pnpm typecheck in apps/server and packages/client-runtime: clean
  • vp lint on the four changed files: clean

The original commits are cherry-picked with @x1xhlol as author.

Change authored by Claude Fable 5.1 running in Claude Code, based on work by @x1xhlol.


Note

Medium Risk
Changes projection correctness for latestUserMessageAt and client activity ordering; behavior is heavily tested but touches hot paths during streaming.

Overview
Streaming turns were doing redundant work on every event: user messages triggered a full thread shell summary recompute (including scanning all message bodies), and each thread.activity-appended on the client re-sorted the entire activity list.

On the server (ProjectionPipeline.ts), thread.message-sent now updates projection_threads in place—updatedAt always advances; latestUserMessageAt only moves forward for newer user messages. The shouldRefreshThreadShellSummary gate no longer treats user messages as refresh triggers; lifecycle activities and other events still use the full refresh path.

On the client (threadReducer.ts), in-order live thread.activity-appended events can take an O(1) append when the activities array was produced by this reducer (tracked via a WeakMap id set), the new row sorts at or after the tail, and the id is new. Out-of-order delivery, re-deliveries, context-window supersession, and snapshot-loaded arrays still use filter, dedupe, and sort.

Tests add end-to-end shell summary assertions (user vs streaming assistant vs tool vs user-input activities) and reducer cases for reordering, snapshot repair, and redelivery.

Reviewed by Cursor Bugbot for commit c5ce425. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Make thread projection and client activity appends incremental

  • Server projection now treats every thread.message-sent event as eligible to refresh the thread shell summary, and the applyProjectsProjection handler for thread.message-sent updates updatedAt on every event while advancing latestUserMessageAt only for newer user messages.
  • Client threadReducer adds a WeakMap-backed activity-id index and an in-order fast path for activity-appended events: unseen activities that sort at or after the tail are appended without a full re-sort. The fallback deduplicates re-delivered activity ids, removes superseded context-window activities, and sorts the result.
  • Adds regression and integration tests covering shell-summary field persistence, out-of-order activity sorting, snapshot ordering repair, and activity redelivery deduplication.
  • Behavioral Change: shouldRefreshThreadShellSummary in ProjectionPipeline.ts no longer returns false for any thread.message-sent event; activity refresh is now restricted to approval and user-input lifecycle kinds only.

Macroscope summarized c5ce425.

x1xhlol and others added 4 commits September 1, 2026 17:49
…vity

User messages no longer refresh the shell summary, so the stale user-input
test now appends the last lifecycle activity through the pipeline to force
the read it checks. Also drops a comment duplicated by the rebase.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.4 KiB 13.5 KiB +7 B (+0.1%) 15.1 KiB
Codex Thread snapshot wire 6.9 KiB 6.9 KiB −7 B (−0.1%) 7.3 KiB
Codex Live turn WebSocket wire 6.5 KiB 6.6 KiB +14 B (+0.2%) 7.8 KiB
Codex Live turn WebSocket decoded 57.0 KiB 57.0 KiB 0 B (0.0%) 66.4 KiB
Codex Live turn messages 10 10 0 (0.0%) 21
Claude Total thread wire 13.5 KiB 13.5 KiB −21 B (−0.2%) 15.1 KiB
Claude Thread snapshot wire 6.9 KiB 6.9 KiB −4 B (−0.1%) 7.3 KiB
Claude Live turn WebSocket wire 6.6 KiB 6.6 KiB −17 B (−0.3%) 7.8 KiB
Claude Live turn WebSocket decoded 57.8 KiB 57.8 KiB 0 B (0.0%) 66.4 KiB
Claude Live turn messages 10 10 0 (0.0%) 21

Baseline: ea71a19 · PR result: c5ce425 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@macroscopeapp

macroscopeapp Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This production performance refactor changes how thread summaries and streamed activity histories are maintained, including gates that skip substantial existing work and a new client fast path. The added tests cover key ordering and summary cases, but the cross-layer hot-path behavior warrants human review.

You can add or adjust custom eligibility rules. Learn more.

@t3dotgg
t3dotgg merged commit c2283ce into main Sep 2, 2026
23 checks passed
@t3dotgg
t3dotgg deleted the t3code/streaming-projection-incremental branch September 2, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants