@@ -1696,6 +1696,139 @@ describe('useChat remount send recovery', () => {
16961696 expect ( state . postBodies [ 0 ] ) . toHaveProperty ( 'effort' )
16971697 } )
16981698
1699+ it . each ( [ 'initial' , 'tail' ] as const ) (
1700+ 'recovers a silent %s connection after a tool group without refresh or resending' ,
1701+ async ( connection ) => {
1702+ vi . useFakeTimers ( { toFake : [ 'setTimeout' , 'clearTimeout' ] } )
1703+ let unmount : ( ( ) => void ) | undefined
1704+ try {
1705+ const history : MothershipChatHistory = {
1706+ id : 'chat-silent-stream' ,
1707+ title : 'Silent stream' ,
1708+ messages : [ ] ,
1709+ activeStreamId : null ,
1710+ resources : [ ] ,
1711+ }
1712+ const cancelled = vi . fn ( )
1713+ const cursors : string [ ] = [ ]
1714+ let recovered = false
1715+ let tailReads = 0
1716+ let streamId = ''
1717+ const textEvent = ( ) : MothershipStreamV1EventEnvelope => ( {
1718+ v : 1 ,
1719+ seq : 3 ,
1720+ ts : new Date ( ) . toISOString ( ) ,
1721+ type : 'text' ,
1722+ stream : { streamId } ,
1723+ payload : { channel : 'assistant' , text : 'The work continued.' } ,
1724+ } )
1725+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
1726+ const url = String ( input )
1727+ if ( url === '/api/mothership/chat' && init ?. method === 'POST' ) {
1728+ const sent = JSON . parse ( String ( init . body ) )
1729+ state . postBodies . push ( sent )
1730+ streamId = sent . userMessageId
1731+ const events : MothershipStreamV1EventEnvelope [ ] = [
1732+ {
1733+ v : 1 ,
1734+ seq : 1 ,
1735+ ts : new Date ( ) . toISOString ( ) ,
1736+ type : 'tool' ,
1737+ stream : { streamId } ,
1738+ payload : {
1739+ phase : 'call' ,
1740+ executor : 'go' ,
1741+ mode : 'sync' ,
1742+ toolName : 'run_code' ,
1743+ toolCallId : 'finished-tool' ,
1744+ arguments : { code : 'return 1' } ,
1745+ } ,
1746+ } ,
1747+ {
1748+ v : 1 ,
1749+ seq : 2 ,
1750+ ts : new Date ( ) . toISOString ( ) ,
1751+ type : 'tool' ,
1752+ stream : { streamId } ,
1753+ payload : {
1754+ phase : 'result' ,
1755+ toolName : 'run_code' ,
1756+ toolCallId : 'finished-tool' ,
1757+ success : true ,
1758+ output : { value : 1 } ,
1759+ } ,
1760+ } ,
1761+ ]
1762+ return new Response (
1763+ new ReadableStream < Uint8Array > ( {
1764+ start ( controller ) {
1765+ for ( const event of events )
1766+ controller . enqueue (
1767+ new TextEncoder ( ) . encode ( `data: ${ JSON . stringify ( event ) } \n\n` )
1768+ )
1769+ if ( connection === 'tail' ) controller . close ( )
1770+ } ,
1771+ cancel : cancelled ,
1772+ } ) ,
1773+ {
1774+ headers : {
1775+ 'Content-Type' : 'text/event-stream' ,
1776+ 'x-mothership-chat-id' : history . id ,
1777+ } ,
1778+ }
1779+ )
1780+ }
1781+ if ( url . includes ( '/api/mothership/chat/stream' ) ) {
1782+ const params = new URL ( url , 'https://sim.test' ) . searchParams
1783+ if ( params . get ( 'batch' ) === 'true' ) {
1784+ cursors . push ( params . get ( 'after' ) ?? '' )
1785+ recovered = connection === 'initial' || tailReads > 0
1786+ return Response . json ( {
1787+ success : true ,
1788+ status : 'streaming' ,
1789+ events : recovered ? [ { eventId : 3 , streamId, event : textEvent ( ) } ] : [ ] ,
1790+ } )
1791+ }
1792+ tailReads ++
1793+ return new Response ( new ReadableStream < Uint8Array > ( { cancel : cancelled } ) , {
1794+ headers : { 'Content-Type' : 'text/event-stream' } ,
1795+ } )
1796+ }
1797+ return fetchStub ( input , init )
1798+ } )
1799+ const mounted = renderUseChatInChat ( history . id , history )
1800+ unmount = mounted . unmount
1801+ const { getResult } = mounted
1802+ await act ( async ( ) => {
1803+ void getResult ( ) . sendMessage ( 'Keep working' )
1804+ } )
1805+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 0 ) )
1806+ expect (
1807+ getResult ( )
1808+ . messages . flatMap ( ( message ) => message . contentBlocks ?? [ ] )
1809+ . find ( ( block ) => block . toolCall ?. id === 'finished-tool' ) ?. toolCall ?. status
1810+ ) . toBe ( 'success' )
1811+ expect ( recovered ) . toBe ( false )
1812+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 45_000 ) )
1813+ expect ( recovered ) . toBe ( true )
1814+ expect ( cursors . every ( ( cursor ) => cursor === '2' ) ) . toBe ( true )
1815+ expect ( cancelled ) . toHaveBeenCalledTimes ( 1 )
1816+ expect (
1817+ getResult ( )
1818+ . messages . filter ( ( message ) => message . role === 'assistant' )
1819+ . map ( ( message ) => message . content )
1820+ ) . toEqual ( [ 'The work continued.' ] )
1821+ expect ( getResult ( ) . isSending ) . toBe ( true )
1822+ expect ( getResult ( ) . error ) . toBeNull ( )
1823+ expect ( state . postBodies ) . toHaveLength ( 1 )
1824+ expect ( state . abortBodies ) . toHaveLength ( 0 )
1825+ } finally {
1826+ unmount ?.( )
1827+ vi . useRealTimers ( )
1828+ }
1829+ }
1830+ )
1831+
16991832 it ( 'recovers a running turn after reconnect exhaustion without reloading or resending' , async ( ) => {
17001833 vi . useFakeTimers ( { toFake : [ 'setTimeout' , 'clearTimeout' ] } )
17011834 try {
0 commit comments