@@ -132,6 +132,35 @@ function defaultSseResponse(
132132 } ) ;
133133}
134134
135+ /**
136+ * An SSE response whose body stays open until the request signal aborts.
137+ * Models a live subscription sitting on a quiet server.
138+ */
139+ function openSseResponse ( signal ?: AbortSignal | null ) : Response {
140+ const body = new ReadableStream < Uint8Array > ( {
141+ start ( controller ) {
142+ const onAbort = ( ) => {
143+ const err = new Error ( "aborted" ) ;
144+ err . name = "AbortError" ;
145+ try {
146+ controller . error ( err ) ;
147+ } catch {
148+ /* already errored */
149+ }
150+ } ;
151+ if ( signal ?. aborted ) onAbort ( ) ;
152+ else signal ?. addEventListener ( "abort" , onAbort , { once : true } ) ;
153+ } ,
154+ } ) ;
155+ return new Response ( body , {
156+ status : 200 ,
157+ headers : {
158+ "content-type" : "text/event-stream" ,
159+ "X-Stream-Version" : "v2" ,
160+ } ,
161+ } ) ;
162+ }
163+
135164function authError ( status = 401 ) : Response {
136165 return new Response ( JSON . stringify ( { error : "Unauthorized" , name : "TriggerApiError" , status } ) , {
137166 status,
@@ -1524,6 +1553,112 @@ describe("TriggerChatTransport", () => {
15241553 } ) ;
15251554 } ) ;
15261555
1556+ describe ( "superseded stream teardown" , ( ) => {
1557+ it ( "keeps the successor's controller registered when the aborted stream tears down" , async ( ) => {
1558+ vi . useFakeTimers ( ) ;
1559+ try {
1560+ let appendCount = 0 ;
1561+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL ) => {
1562+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1563+ if ( isSessionStreamAppendUrl ( urlStr ) ) {
1564+ appendCount ++ ;
1565+ return defaultAppendResponse ( ) ;
1566+ }
1567+ // Quiet stream: EOF, no records, never settled — watch keeps it open.
1568+ if ( isSessionOutSubscribeUrl ( urlStr ) ) return defaultSseResponse ( [ ] ) ;
1569+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1570+ } ) ;
1571+
1572+ const transport = new TriggerChatTransport ( {
1573+ task : "my-chat-task" ,
1574+ accessToken : ( ) => "pat" ,
1575+ watch : true ,
1576+ sessions : { "chat-race" : { publicAccessToken : "p" , isStreaming : true } } ,
1577+ } ) ;
1578+
1579+ const send = ( ) =>
1580+ transport . sendMessages ( {
1581+ trigger : "submit-message" as const ,
1582+ chatId : "chat-race" ,
1583+ messageId : undefined ,
1584+ messages : [ createUserMessage ( "hi" ) ] ,
1585+ abortSignal : undefined ,
1586+ } ) ;
1587+
1588+ const first = drainChunks ( await send ( ) ) ;
1589+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
1590+
1591+ // Supersede: the new stream registers its controller synchronously,
1592+ // the aborted one tears down a microtask later.
1593+ const second = await send ( ) ;
1594+ let secondClosed = false ;
1595+ const secondDrain = drainChunks ( second ) . then ( ( ) => {
1596+ secondClosed = true ;
1597+ } ) ;
1598+ await first ;
1599+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
1600+
1601+ // stopGeneration posts the stop chunk either way — only the
1602+ // closing assertion proves it found the successor to abort.
1603+ appendCount = 0 ;
1604+ expect ( await transport . stopGeneration ( "chat-race" ) ) . toBe ( true ) ;
1605+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
1606+ expect ( appendCount ) . toBe ( 1 ) ;
1607+ expect ( secondClosed ) . toBe ( true ) ;
1608+
1609+ transport . dispose ( ) ;
1610+ await secondDrain ;
1611+ } finally {
1612+ vi . useRealTimers ( ) ;
1613+ }
1614+ } ) ;
1615+
1616+ it ( "keeps the tab claim the successor took (multi-tab)" , async ( ) => {
1617+ vi . useFakeTimers ( ) ;
1618+ try {
1619+ global . fetch = vi . fn ( ) . mockImplementation ( async ( url : string | URL , init ?: RequestInit ) => {
1620+ const urlStr = typeof url === "string" ? url : url . toString ( ) ;
1621+ if ( isSessionStreamAppendUrl ( urlStr ) ) return defaultAppendResponse ( ) ;
1622+ // Open SSE that only ends when the subscription is aborted, so
1623+ // the superseded stream tears down while the successor is live.
1624+ if ( isSessionOutSubscribeUrl ( urlStr ) ) return openSseResponse ( init ?. signal ) ;
1625+ throw new Error ( `Unexpected URL: ${ urlStr } ` ) ;
1626+ } ) ;
1627+
1628+ const transport = new TriggerChatTransport ( {
1629+ task : "my-chat-task" ,
1630+ accessToken : ( ) => "pat" ,
1631+ multiTab : true ,
1632+ sessions : { "chat-race-tab" : { publicAccessToken : "p" , isStreaming : true } } ,
1633+ } ) ;
1634+
1635+ const send = ( ) =>
1636+ transport . sendMessages ( {
1637+ trigger : "submit-message" as const ,
1638+ chatId : "chat-race-tab" ,
1639+ messageId : undefined ,
1640+ messages : [ createUserMessage ( "hi" ) ] ,
1641+ abortSignal : undefined ,
1642+ } ) ;
1643+
1644+ const first = drainChunks ( await send ( ) ) ;
1645+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
1646+ const secondDrain = drainChunks ( await send ( ) ) ;
1647+ await first ;
1648+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
1649+
1650+ // The superseded stream must not release the claim its successor
1651+ // holds — otherwise this tab flips to read-only mid-turn.
1652+ expect ( transport . hasClaim ( "chat-race-tab" ) ) . toBe ( true ) ;
1653+
1654+ transport . dispose ( ) ;
1655+ await secondDrain ;
1656+ } finally {
1657+ vi . useRealTimers ( ) ;
1658+ }
1659+ } ) ;
1660+ } ) ;
1661+
15271662 describe ( "multi-tab coordination" , ( ) => {
15281663 it ( "isReadOnly defaults to false when multiTab is disabled" , ( ) => {
15291664 const transport = new TriggerChatTransport ( {
0 commit comments