@@ -16,7 +16,10 @@ type OnceWaiter = {
1616 abortHandler ?: ( ) => void ;
1717} ;
1818
19- type Handler = ( data : unknown ) => void | Promise < void > ;
19+ // Same contract as the production manager: a handler that synchronously
20+ // returns `true` CONSUMES the record (not buffered, not re-delivered on a
21+ // future `on()` attach). See `SessionStreamManager.on` in types.ts.
22+ type Handler = ( data : unknown ) => void | boolean | Promise < void > ;
2023
2124function keyFor ( sessionId : string , io : SessionChannelIO ) : string {
2225 return `${ sessionId } :${ io } ` ;
@@ -51,20 +54,32 @@ export class TestSessionStreamManager implements SessionStreamManager {
5154 }
5255 set . add ( handler ) ;
5356
54- // Note: we intentionally do NOT replay buffered records into the
55- // newly-registered handler, and we do NOT drain the buffer. The
56- // buffer is owned by `once()` — registering a passive observer
57- // (`on`) must not consume records destined for a future `once`
58- // waiter. This matches production SSE semantics where handlers
59- // observe records as they arrive, not retroactively.
60- //
61- // Earlier versions drained the buffer here, which caused user
62- // messages buffered during the runtime's `runFn` boot phase to be
63- // silently swallowed by the `stopInput.on()` handler registered at
64- // ai.ts:4806 (the stop handler ignores `kind: "message"` chunks).
65- // The next `messagesInput.waitWithIdleTimeout` then waited 30s for
66- // a record that had already been "delivered" to a handler that
67- // didn't want it.
57+ // Selective drain, matching the production manager: offer each
58+ // buffered record to the new handler and remove ONLY the ones it
59+ // consumed (returned `true`). Records the handler filtered out (other
60+ // kinds) stay buffered for a future `once()`. This is the corrected
61+ // form of two historical bugs: a blind drain swallowed boot-phase user
62+ // messages into the stop facade (which ignores `kind: "message"`),
63+ // and no-drain-at-all let production re-deliver already-processed
64+ // messages into every newly attached per-turn handler.
65+ const buffered = this . buffer . get ( key ) ;
66+ if ( buffered && buffered . length > 0 ) {
67+ const kept : unknown [ ] = [ ] ;
68+ for ( const data of buffered ) {
69+ let consumed = false ;
70+ try {
71+ consumed = handler ( data ) === true ;
72+ } catch {
73+ // Never let a handler error break test state
74+ }
75+ if ( ! consumed ) kept . push ( data ) ;
76+ }
77+ if ( kept . length > 0 ) {
78+ this . buffer . set ( key , kept ) ;
79+ } else {
80+ this . buffer . delete ( key ) ;
81+ }
82+ }
6883
6984 return {
7085 off : ( ) => {
@@ -212,20 +227,20 @@ export class TestSessionStreamManager implements SessionStreamManager {
212227 /**
213228 * Push a record onto the given channel.
214229 *
215- * Dispatch rules — similar to the production manager, but with a tweak
216- * that makes unit tests deterministic:
230+ * Dispatch rules — same as the production manager:
231+ *
232+ * 1. **A pending `.once` waiter consumes first.** Handlers still observe
233+ * a copy.
234+ * 2. **Otherwise handlers observe.** A handler that synchronously
235+ * returns `true` consumes the record (kind-filtering facades do this
236+ * for the kinds they own) — it is NOT buffered.
237+ * 3. **Records no one consumed are buffered** for the next `.once` call
238+ * or the next consuming `on()` attach.
217239 *
218- * 1. **Handlers always observe** (like production). A session-level `.on`
219- * is a filter-observer — it fires every time a record arrives,
220- * regardless of whether a `.once` waiter is also active.
221- * 2. **First waiter consumes** the record if present (like production).
222- * 3. **If no waiter, the record is buffered for the next `.once` call.**
223- * Production discards records that only match handlers — but in
224- * production the SSE tail introduces enough latency that the next
225- * `.once` is usually registered before the next record arrives. Tests
226- * send synchronously right after `turn-complete`, so without this
227- * buffer the next `waitWithIdleTimeout` would race and lose the
228- * message. The buffer is the only deviation from production semantics.
240+ * Handler promises are awaited before resolving so test code can rely
241+ * on async handler work having settled by the time `__sendFromTest`
242+ * resolves. Consumption is decided on the synchronous return value,
243+ * exactly like production.
229244 */
230245 async __sendFromTest (
231246 sessionId : string ,
@@ -234,23 +249,6 @@ export class TestSessionStreamManager implements SessionStreamManager {
234249 ) : Promise < void > {
235250 const key = keyFor ( sessionId , io ) ;
236251
237- const handlers = this . handlers . get ( key ) ;
238- if ( handlers && handlers . size > 0 ) {
239- // Awaited so test code can rely on handlers having completed by the
240- // time `__sendFromTest` resolves. Wrapped per-handler so a
241- // throwing/rejecting handler doesn't poison Promise.all and break
242- // unrelated test state.
243- await Promise . all (
244- Array . from ( handlers ) . map ( async ( h ) => {
245- try {
246- await h ( data ) ;
247- } catch {
248- // Never let a handler error break test state
249- }
250- } )
251- ) ;
252- }
253-
254252 const waiters = this . onceWaiters . get ( key ) ;
255253 if ( waiters && waiters . length > 0 ) {
256254 const w = waiters . shift ( ) ! ;
@@ -260,6 +258,27 @@ export class TestSessionStreamManager implements SessionStreamManager {
260258 w . signal . removeEventListener ( "abort" , w . abortHandler ) ;
261259 }
262260 w . resolve ( { ok : true , output : data } ) ;
261+ await this . #invokeHandlers( key , data ) ;
262+ return ;
263+ }
264+
265+ const consumed = await this . #invokeHandlers( key , data ) ;
266+ if ( consumed ) return ;
267+
268+ // Re-check waiters: handler invocation above is awaited (unlike the
269+ // synchronous production dispatch), and the runtime commonly registers
270+ // its next `once()` during that window — e.g. the turn loop reaching
271+ // `waitWithIdleTimeout` while a handler settles. Without this second
272+ // look the record would be buffered while the fresh waiter hangs.
273+ const lateWaiters = this . onceWaiters . get ( key ) ;
274+ if ( lateWaiters && lateWaiters . length > 0 ) {
275+ const w = lateWaiters . shift ( ) ! ;
276+ if ( lateWaiters . length === 0 ) this . onceWaiters . delete ( key ) ;
277+ if ( w . timer ) clearTimeout ( w . timer ) ;
278+ if ( w . signal && w . abortHandler ) {
279+ w . signal . removeEventListener ( "abort" , w . abortHandler ) ;
280+ }
281+ w . resolve ( { ok : true , output : data } ) ;
263282 return ;
264283 }
265284
@@ -271,6 +290,34 @@ export class TestSessionStreamManager implements SessionStreamManager {
271290 buffered . push ( data ) ;
272291 }
273292
293+ /**
294+ * Invoke all handlers; resolves once any returned promises settle.
295+ * Returns true when any handler synchronously consumed the record.
296+ * Wrapped per-handler so a throwing/rejecting handler doesn't poison
297+ * Promise.all and break unrelated test state.
298+ */
299+ async #invokeHandlers( key : string , data : unknown ) : Promise < boolean > {
300+ const handlers = this . handlers . get ( key ) ;
301+ if ( ! handlers || handlers . size === 0 ) return false ;
302+
303+ let consumed = false ;
304+ await Promise . all (
305+ Array . from ( handlers ) . map ( async ( h ) => {
306+ try {
307+ const result = h ( data ) ;
308+ if ( result === true ) {
309+ consumed = true ;
310+ return ;
311+ }
312+ await result ;
313+ } catch {
314+ // Never let a handler error break test state
315+ }
316+ } )
317+ ) ;
318+ return consumed ;
319+ }
320+
274321 /**
275322 * Immediately resolve every pending `once()` waiter for the given channel
276323 * with a timeout error. Simulates a closed stream (e.g. session closed).
0 commit comments