@@ -118,6 +118,49 @@ describe("chat.agent pending wire buffer", () => {
118118 } ) ;
119119} ) ;
120120
121+ describe ( "chat.agent errored turn" , ( ) => {
122+ it (
123+ "does not duplicate messages buffered after a turn that threw" ,
124+ { timeout : 20000 } ,
125+ async ( ) => {
126+ // Throw from a pre-stream hook: throws inside the streaming section are
127+ // already covered by its finally, but a hook throw used to leak the
128+ // turn's message handler into the loop-level buffer.
129+ let turnStarts = 0 ;
130+ const agent = chat . agent ( {
131+ id : "pending-drain.errored-turn" ,
132+ onTurnStart : async ( ) => {
133+ turnStarts ++ ;
134+ if ( turnStarts === 1 ) {
135+ throw new Error ( "synthetic turn failure" ) ;
136+ }
137+ } ,
138+ run : async ( { messages, signal } ) => {
139+ return streamText ( { model : echoModel ( ) , messages, abortSignal : signal } ) ;
140+ } ,
141+ } ) ;
142+
143+ const harness = mockChatAgent ( agent , { chatId : "pending-drain-4" } ) ;
144+ try {
145+ // Turn 1 throws — pre-fix its message handler leaked past the turn.
146+ await harness . sendMessage ( userMessage ( "boom" , "u-1" ) ) ;
147+ const second = harness . sendMessage ( userMessage ( "m2" , "u-2" ) ) ;
148+ // m3 lands mid-turn; a leaked handler would push it twice.
149+ await waitFor ( ( ) => streamedText ( harness ) . includes ( "ANSWER(m2)" ) ) ;
150+ void harness . sendMessage ( userMessage ( "m3" , "u-3" ) ) ;
151+ await second ;
152+
153+ await waitFor ( ( ) => streamedText ( harness ) . includes ( "ANSWER(m3)" ) ) ;
154+ await new Promise ( ( r ) => setTimeout ( r , 500 ) ) ;
155+ const text = streamedText ( harness ) ;
156+ expect ( text . match ( / A N S W E R \( m 3 \) / g) ) . toHaveLength ( 1 ) ;
157+ } finally {
158+ await harness . close ( ) ;
159+ }
160+ }
161+ ) ;
162+ } ) ;
163+
121164describe ( "chat.createSession pending wire buffer" , ( ) => {
122165 it ( "dispatches messages buffered during a turn as subsequent turns" , async ( ) => {
123166 const agent = chat . customAgent ( {
@@ -158,46 +201,50 @@ describe("chat.createSession pending wire buffer", () => {
158201} ) ;
159202
160203describe ( "chat.createSession stop + immediate send" , ( ) => {
161- it ( "dispatches a message that arrives right after a stopped turn" , { timeout : 20000 } , async ( ) => {
162- const agent = chat . customAgent ( {
163- id : "pending-drain.session-stop" ,
164- run : async ( payload ) => {
165- const session = chat . createSession ( payload , {
166- signal : new AbortController ( ) . signal ,
167- idleTimeoutInSeconds : 2 ,
168- // Steering config active — the failure mode routed post-stream
169- // arrivals into the dead steering queue instead of the next turn.
170- pendingMessages : { } ,
171- } ) ;
172- for await ( const turn of session ) {
173- const result = streamText ( {
174- model : echoModel ( ) ,
175- messages : turn . messages ,
176- abortSignal : turn . signal ,
204+ it (
205+ "dispatches a message that arrives right after a stopped turn" ,
206+ { timeout : 20000 } ,
207+ async ( ) => {
208+ const agent = chat . customAgent ( {
209+ id : "pending-drain.session-stop" ,
210+ run : async ( payload ) => {
211+ const session = chat . createSession ( payload , {
212+ signal : new AbortController ( ) . signal ,
213+ idleTimeoutInSeconds : 2 ,
214+ // Steering config active — the failure mode routed post-stream
215+ // arrivals into the dead steering queue instead of the next turn.
216+ pendingMessages : { } ,
177217 } ) ;
178- await turn . complete ( result ) ;
179- }
180- } ,
181- } ) ;
218+ for await ( const turn of session ) {
219+ const result = streamText ( {
220+ model : echoModel ( ) ,
221+ messages : turn . messages ,
222+ abortSignal : turn . signal ,
223+ } ) ;
224+ await turn . complete ( result ) ;
225+ }
226+ } ,
227+ } ) ;
182228
183- const harness = mockChatAgent ( agent , { chatId : "pending-drain-3" } ) ;
184- try {
185- const first = harness . sendMessage ( userMessage ( "write a long essay" , "u-1" ) ) ;
186- await waitFor ( ( ) => streamedText ( harness ) . length > 0 ) ;
187- await harness . sendStop ( ) ;
188- // Land the next message inside the stopped turn's post-stream window
189- // (the ~2s totalUsage race), after the abort has settled — previously
190- // the still-attached handler steering-routed it into the dead queue.
191- await new Promise ( ( r ) => setTimeout ( r , 150 ) ) ;
192- void harness . sendMessage ( userMessage ( "m2" , "u-2" ) ) ;
193- await first ;
229+ const harness = mockChatAgent ( agent , { chatId : "pending-drain-3" } ) ;
230+ try {
231+ const first = harness . sendMessage ( userMessage ( "write a long essay" , "u-1" ) ) ;
232+ await waitFor ( ( ) => streamedText ( harness ) . length > 0 ) ;
233+ await harness . sendStop ( ) ;
234+ // Land the next message inside the stopped turn's post-stream window
235+ // (the ~2s totalUsage race), after the abort has settled — previously
236+ // the still-attached handler steering-routed it into the dead queue.
237+ await new Promise ( ( r ) => setTimeout ( r , 150 ) ) ;
238+ void harness . sendMessage ( userMessage ( "m2" , "u-2" ) ) ;
239+ await first ;
194240
195- await waitFor ( ( ) => turnCompleteCount ( harness ) >= 2 ) ;
196- await waitFor ( ( ) => streamedText ( harness ) . includes ( "ANSWER(m2)" ) ) ;
197- } finally {
198- await harness . close ( ) ;
241+ await waitFor ( ( ) => turnCompleteCount ( harness ) >= 2 ) ;
242+ await waitFor ( ( ) => streamedText ( harness ) . includes ( "ANSWER(m2)" ) ) ;
243+ } finally {
244+ await harness . close ( ) ;
245+ }
199246 }
200- } ) ;
247+ ) ;
201248} ) ;
202249
203250describe ( "session.in.wait() consume cursor" , ( ) => {
0 commit comments