@@ -186,3 +186,127 @@ describe('useSendMessage continuation state', () => {
186186 }
187187 } )
188188} )
189+
190+ // Regression tests for the syncRunState fix (#1054).
191+ //
192+ // Both scenarios previously caused a follow-up message to lose all conversation
193+ // context because previousRunStateRef was not updated before client.run()
194+ // settled. The tests below drive the real hook and assert that the snapshot
195+ // passed to onStateSnapshot() is the one carried into the next run's
196+ // previousRun, verifying the actual wiring — not a reimplemented proxy.
197+ describe ( 'useSendMessage syncRunState regression (#1054)' , ( ) => {
198+ test ( 'abort path: latestRunStateSnapshot is committed to previousRunStateRef before client.run() settles' , async ( ) => {
199+ // This exercises use-send-message.ts line ~355:
200+ // syncRunState(latestRunStateSnapshot) ← inside registerActiveRun callback
201+ // Calling stopActiveRun fires the real abort callback synchronously, before
202+ // client.run()'s promise resolves. The follow-up run must receive the
203+ // snapshot that was live at abort time, not an empty/null state.
204+ const { setup, root } = await mountHost ( )
205+ const runs : Promise < void > [ ] = [ ]
206+
207+ try {
208+ runs . push (
209+ sendMessageFromHost ! ( { content : 'first message' , agentMode : 'DEFAULT' } ) ,
210+ )
211+ await waitFor ( 'first run registered' , ( ) => runCalls . length === 1 )
212+
213+ const liveSnapshot = makeRunState ( 'mid-stream' )
214+ // Simulate a partial streaming state update arriving before Esc.
215+ runCalls [ 0 ] . runConfig . onStateSnapshot ( liveSnapshot )
216+ // User presses Esc — fires the real registerActiveRun abort callback.
217+ stopActiveRun ( 'user-interrupt' )
218+
219+ runs . push (
220+ sendMessageFromHost ! ( { content : 'follow-up after abort' , agentMode : 'DEFAULT' } ) ,
221+ )
222+ await waitFor ( 'second run registered' , ( ) => runCalls . length === 2 )
223+
224+ // The real hook must have assigned liveSnapshot into previousRunStateRef
225+ // via syncRunState before we got here — not the blank sentinel.
226+ expect ( runCalls [ 1 ] . runConfig . previousRun ) . toBe ( liveSnapshot )
227+ } finally {
228+ settlePendingRuns ( )
229+ await Promise . all ( runs )
230+ flushSync ( ( ) => root . unmount ( ) )
231+ setup . renderer . destroy ( )
232+ }
233+ } )
234+
235+ test ( 'error path: latestRunStateSnapshot is committed to previousRunStateRef when client.run() rejects' , async ( ) => {
236+ // This exercises use-send-message.ts line ~752:
237+ // syncRunState(latestRunStateSnapshot) ← inside catch (error) block
238+ // When client.run() throws (network error, session expiry, gate error),
239+ // the catch block must persist the last received snapshot so the user's
240+ // conversation context survives the failure.
241+ const { setup, root } = await mountHost ( )
242+ const runs : Promise < void > [ ] = [ ]
243+
244+ try {
245+ runs . push (
246+ sendMessageFromHost ! ( { content : 'first message' , agentMode : 'DEFAULT' } ) ,
247+ )
248+ await waitFor ( 'first run registered' , ( ) => runCalls . length === 1 )
249+
250+ const lastSnapshot = makeRunState ( 'before-error' )
251+ // Simulate a snapshot arriving mid-stream, then a network / gate error.
252+ runCalls [ 0 ] . runConfig . onStateSnapshot ( lastSnapshot )
253+ runCalls [ 0 ] . reject ( new Error ( 'session expired' ) )
254+ await runs [ 0 ]
255+
256+ runs . push (
257+ sendMessageFromHost ! ( { content : 'continue' , agentMode : 'DEFAULT' } ) ,
258+ )
259+ await waitFor ( 'second run registered' , ( ) => runCalls . length === 2 )
260+
261+ // The catch block must have called syncRunState(latestRunStateSnapshot),
262+ // making lastSnapshot available to the next run.
263+ expect ( runCalls [ 1 ] . runConfig . previousRun ) . toBe ( lastSnapshot )
264+ } finally {
265+ settlePendingRuns ( )
266+ await Promise . all ( runs )
267+ flushSync ( ( ) => root . unmount ( ) )
268+ setup . renderer . destroy ( )
269+ }
270+ } )
271+ test ( 'abort path: falls back to prior run state when client.run() is aborted before any snapshot arrives' , async ( ) => {
272+ // latestRunStateSnapshot is initialized from previousRunStateRef.current (line ~313).
273+ // If the user presses Esc immediately — before the SDK emits any onStateSnapshot —
274+ // syncRunState is called with that initial value, which is the prior completed run's
275+ // state. This directly answers the question: "is latestRunStateSnapshot guaranteed
276+ // to be populated at the abort callsite?" Yes — it is never null.
277+ const { setup, root } = await mountHost ( )
278+ const runs : Promise < void > [ ] = [ ]
279+
280+ try {
281+ // Run 1: complete successfully so there IS a known prior state.
282+ runs . push (
283+ sendMessageFromHost ! ( { content : 'first message' , agentMode : 'DEFAULT' } ) ,
284+ )
285+ await waitFor ( 'first run registered' , ( ) => runCalls . length === 1 )
286+ const priorState = makeRunState ( 'completed' )
287+ runCalls [ 0 ] . resolve ( priorState )
288+ await runs [ 0 ]
289+
290+ // Run 2: abort immediately, before any onStateSnapshot arrives.
291+ runs . push (
292+ sendMessageFromHost ! ( { content : 'second message' , agentMode : 'DEFAULT' } ) ,
293+ )
294+ await waitFor ( 'second run registered' , ( ) => runCalls . length === 2 )
295+ // Deliberately NO onStateSnapshot call — simulates Esc before any streaming progress.
296+ stopActiveRun ( 'user-interrupt' )
297+
298+ // Run 3 should carry priorState (from run 1), not a blank/null sentinel.
299+ runs . push (
300+ sendMessageFromHost ! ( { content : 'follow-up' , agentMode : 'DEFAULT' } ) ,
301+ )
302+ await waitFor ( 'third run registered' , ( ) => runCalls . length === 3 )
303+
304+ expect ( runCalls [ 2 ] . runConfig . previousRun ) . toBe ( priorState )
305+ } finally {
306+ settlePendingRuns ( )
307+ await Promise . all ( runs )
308+ flushSync ( ( ) => root . unmount ( ) )
309+ setup . renderer . destroy ( )
310+ }
311+ } )
312+ } )
0 commit comments