77 type ThreadId as ThreadIdType ,
88} from "@t3tools/contracts" ;
99import * as Cause from "effect/Cause" ;
10+ import * as Duration from "effect/Duration" ;
1011import * as Effect from "effect/Effect" ;
1112import * as Option from "effect/Option" ;
1213import * as Queue from "effect/Queue" ;
@@ -32,6 +33,111 @@ import {
3233 type EnvironmentThreadStatus ,
3334} from "./threadState.ts" ;
3435
36+ // Coalesce one render-sized burst so web and mobile derive their large thread
37+ // views once instead of once per streamed event. The size cap bounds catch-up
38+ // memory and prevents a quiet connection from waiting indefinitely.
39+ const THREAD_EVENT_BATCH_WINDOW = Duration . millis ( 16 ) ;
40+ const THREAD_EVENT_BATCH_MAX_SIZE = 64 ;
41+
42+ interface ThreadStreamBatchReduction {
43+ readonly state : EnvironmentThreadState ;
44+ readonly lastSequence : number ;
45+ readonly awaitingCompletion : boolean ;
46+ readonly threadChanged : boolean ;
47+ readonly threadDeleted : boolean ;
48+ }
49+
50+ export interface EnvironmentThreadStateOptions {
51+ readonly eventBatchSize ?: number ;
52+ }
53+
54+ function reduceThreadStreamItems (
55+ currentState : EnvironmentThreadState ,
56+ currentSequence : number ,
57+ currentAwaitingCompletion : boolean ,
58+ items : ReadonlyArray < OrchestrationThreadStreamItem > ,
59+ ) : ThreadStreamBatchReduction {
60+ let state = currentState ;
61+ let lastSequence = currentSequence ;
62+ let awaitingCompletion = currentAwaitingCompletion ;
63+ let thread = Option . getOrNull ( currentState . data ) ;
64+ let threadChanged = false ;
65+ let threadDeleted = false ;
66+
67+ for ( const item of items ) {
68+ if ( item . kind === "synchronized" ) {
69+ awaitingCompletion = false ;
70+ if ( thread !== null && state . status !== "deleted" ) {
71+ state = {
72+ data : state . data ,
73+ status : "live" ,
74+ error : Option . none ( ) ,
75+ } ;
76+ }
77+ continue ;
78+ }
79+
80+ if ( item . kind === "snapshot" ) {
81+ lastSequence = item . snapshot . snapshotSequence ;
82+ thread = item . snapshot . thread ;
83+ threadChanged = true ;
84+ threadDeleted = false ;
85+ state = {
86+ data : Option . some ( thread ) ,
87+ status : awaitingCompletion ? "synchronizing" : "live" ,
88+ error : Option . none ( ) ,
89+ } ;
90+ continue ;
91+ }
92+
93+ if ( item . event . sequence <= lastSequence ) {
94+ continue ;
95+ }
96+ lastSequence = item . event . sequence ;
97+
98+ if ( thread === null ) {
99+ if ( item . event . type === "thread.deleted" ) {
100+ awaitingCompletion = false ;
101+ threadDeleted = true ;
102+ state = {
103+ data : Option . none ( ) ,
104+ status : "deleted" ,
105+ error : Option . none ( ) ,
106+ } ;
107+ }
108+ continue ;
109+ }
110+
111+ const result = applyThreadDetailEvent ( thread , item . event ) ;
112+ if ( result . kind === "updated" ) {
113+ thread = result . thread ;
114+ threadChanged = true ;
115+ state = {
116+ data : Option . some ( thread ) ,
117+ status : awaitingCompletion ? "synchronizing" : "live" ,
118+ error : Option . none ( ) ,
119+ } ;
120+ } else if ( result . kind === "deleted" ) {
121+ awaitingCompletion = false ;
122+ thread = null ;
123+ threadDeleted = true ;
124+ state = {
125+ data : Option . none ( ) ,
126+ status : "deleted" ,
127+ error : Option . none ( ) ,
128+ } ;
129+ }
130+ }
131+
132+ return {
133+ state,
134+ lastSequence,
135+ awaitingCompletion,
136+ threadChanged,
137+ threadDeleted,
138+ } ;
139+ }
140+
35141function statusWithoutLiveData ( data : Option . Option < OrchestrationThread > ) : EnvironmentThreadStatus {
36142 return Option . isSome ( data ) ? "cached" : "empty" ;
37143}
@@ -50,12 +156,14 @@ function shouldPersistThread(thread: OrchestrationThread): boolean {
50156
51157export const makeEnvironmentThreadState = Effect . fn ( "EnvironmentThreadState.make" ) ( function * (
52158 threadId : ThreadIdType ,
159+ options ?: EnvironmentThreadStateOptions ,
53160) {
54161 const supervisor = yield * EnvironmentSupervisor ;
55162 const cache = yield * EnvironmentCacheStore ;
56163 const snapshotLoader = yield * ThreadSnapshotLoader ;
57164 const wakeups = yield * Effect . serviceOption ( ConnectionWakeups . ConnectionWakeups ) ;
58165 const environmentId = supervisor . target . environmentId ;
166+ const eventBatchSize = options ?. eventBatchSize ?? THREAD_EVENT_BATCH_MAX_SIZE ;
59167 const cached = yield * cache . loadThread ( environmentId , threadId ) . pipe (
60168 Effect . catch ( ( error ) =>
61169 Effect . logWarning ( "Could not load cached thread." ) . pipe (
@@ -141,81 +249,47 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
141249 ) ,
142250 ) ;
143251
144- const setThread = Effect . fn ( "EnvironmentThreadState.setThread " ) ( function * (
145- thread : OrchestrationThread ,
252+ const applyItems = Effect . fn ( "EnvironmentThreadState.applyItems " ) ( function * (
253+ items : ReadonlyArray < OrchestrationThreadStreamItem > ,
146254 ) {
147- const waiting = yield * Ref . get ( awaitingCompletion ) ;
148- yield * SubscriptionRef . set ( state , {
149- data : Option . some ( thread ) ,
150- status : waiting ? "synchronizing" : "live" ,
151- error : Option . none ( ) ,
152- } ) ;
153- // Active threads can update many times per second and retain large tool
154- // payloads. The server remains the source of truth while a turn is active;
155- // persist once it settles so cache encoding stays off the streaming path.
156- if ( shouldPersistThread ( thread ) ) {
157- const snapshotSequence = yield * SubscriptionRef . get ( lastSequence ) ;
158- yield * Queue . offer ( persistence , { snapshotSequence, thread } ) ;
159- }
160- } ) ;
161-
162- const setDeleted = Effect . fn ( "EnvironmentThreadState.setDeleted" ) ( function * ( ) {
163- yield * Ref . set ( awaitingCompletion , false ) ;
164- yield * SubscriptionRef . set ( state , {
165- data : Option . none ( ) ,
166- status : "deleted" ,
167- error : Option . none ( ) ,
168- } ) ;
169- yield * cache . removeThread ( environmentId , threadId ) . pipe (
170- Effect . catch ( ( error ) =>
171- Effect . logWarning ( "Could not remove the cached thread." ) . pipe (
172- Effect . annotateLogs ( {
173- environmentId,
174- threadId,
175- error : error . message ,
176- } ) ,
177- ) ,
178- ) ,
255+ const currentState = yield * SubscriptionRef . get ( state ) ;
256+ const reduction = reduceThreadStreamItems (
257+ currentState ,
258+ yield * SubscriptionRef . get ( lastSequence ) ,
259+ yield * Ref . get ( awaitingCompletion ) ,
260+ items ,
179261 ) ;
180- } ) ;
181262
182- const applyItem = Effect . fn ( "EnvironmentThreadState.applyItem" ) ( function * (
183- item : OrchestrationThreadStreamItem ,
184- ) {
185- if ( item . kind === "synchronized" ) {
186- yield * Ref . set ( awaitingCompletion , false ) ;
187- yield * SubscriptionRef . update ( state , ( current ) =>
188- Option . isSome ( current . data ) && current . status !== "deleted"
189- ? { ...current , status : "live" as const , error : Option . none ( ) }
190- : current ,
191- ) ;
192- return ;
263+ yield * SubscriptionRef . set ( lastSequence , reduction . lastSequence ) ;
264+ yield * Ref . set ( awaitingCompletion , reduction . awaitingCompletion ) ;
265+ if ( reduction . state !== currentState ) {
266+ yield * SubscriptionRef . set ( state , reduction . state ) ;
193267 }
194268
195- if ( item . kind === "snapshot" ) {
196- yield * SubscriptionRef . set ( lastSequence , item . snapshot . snapshotSequence ) ;
197- yield * setThread ( item . snapshot . thread ) ;
198- return ;
199- }
200-
201- const sequence = yield * SubscriptionRef . get ( lastSequence ) ;
202- if ( item . event . sequence <= sequence ) {
269+ if ( reduction . threadDeleted ) {
270+ yield * cache . removeThread ( environmentId , threadId ) . pipe (
271+ Effect . catch ( ( error ) =>
272+ Effect . logWarning ( "Could not remove the cached thread." ) . pipe (
273+ Effect . annotateLogs ( {
274+ environmentId,
275+ threadId,
276+ error : error . message ,
277+ } ) ,
278+ ) ,
279+ ) ,
280+ ) ;
203281 return ;
204282 }
205- yield * SubscriptionRef . set ( lastSequence , item . event . sequence ) ;
206283
207- const current = yield * SubscriptionRef . get ( state ) ;
208- if ( Option . isNone ( current . data ) ) {
209- if ( item . event . type === "thread.deleted" ) {
210- yield * setDeleted ( ) ;
211- }
212- return ;
213- }
214- const result = applyThreadDetailEvent ( current . data . value , item . event ) ;
215- if ( result . kind === "updated" ) {
216- yield * setThread ( result . thread ) ;
217- } else if ( result . kind === "deleted" ) {
218- yield * setDeleted ( ) ;
284+ const thread = Option . getOrNull ( reduction . state . data ) ;
285+ // Active threads can update many times per second and retain large tool
286+ // payloads. The server remains the source of truth while a turn is active;
287+ // persist once it settles so cache encoding stays off the streaming path.
288+ if ( reduction . threadChanged && thread !== null && shouldPersistThread ( thread ) ) {
289+ yield * Queue . offer ( persistence , {
290+ snapshotSequence : reduction . lastSequence ,
291+ thread,
292+ } ) ;
219293 }
220294 } ) ;
221295
@@ -269,7 +343,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
269343 ) ;
270344 const httpSnapshot = yield * snapshotLoader . load ( prepared , threadId ) ;
271345 if ( Option . isSome ( httpSnapshot ) ) {
272- yield * applyItem ( { kind : "snapshot" , snapshot : httpSnapshot . value } ) ;
346+ yield * applyItems ( [ { kind : "snapshot" , snapshot : httpSnapshot . value } ] ) ;
273347 current = yield * SubscriptionRef . get ( state ) ;
274348 }
275349 }
@@ -295,7 +369,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
295369 retryExpectedFailureAfter : "250 millis" ,
296370 resubscribe : foregroundResubscriptions ,
297371 } ,
298- ) . pipe ( Stream . runForEach ( applyItem ) ) ,
372+ ) . pipe (
373+ Stream . groupedWithin ( eventBatchSize , THREAD_EVENT_BATCH_WINDOW ) ,
374+ Stream . runForEach ( applyItems ) ,
375+ ) ,
299376 ) ;
300377
301378 yield * Effect . addFinalizer ( ( ) =>
0 commit comments