@@ -10,13 +10,24 @@ import {
1010 MothershipStreamV1ToolOutcome ,
1111 MothershipStreamV1ToolPhase ,
1212} from '@/lib/copilot/generated/mothership-stream-v1'
13+
14+ vi . mock ( '@/lib/copilot/request/session' , async ( ) => {
15+ const actual = await vi . importActual < typeof import ( '@/lib/copilot/request/session' ) > (
16+ '@/lib/copilot/request/session'
17+ )
18+ return {
19+ ...actual ,
20+ hasAbortMarker : vi . fn ( ) . mockResolvedValue ( false ) ,
21+ }
22+ } )
23+
1324import {
1425 buildPreviewContentUpdate ,
1526 decodeJsonStringPrefix ,
1627 extractEditContent ,
1728 runStreamLoop ,
1829} from '@/lib/copilot/request/go/stream'
19- import { createEvent } from '@/lib/copilot/request/session'
30+ import { AbortReason , createEvent , hasAbortMarker } from '@/lib/copilot/request/session'
2031import { RequestTraceV1Outcome , TraceCollector } from '@/lib/copilot/request/trace'
2132import type { ExecutionContext , StreamingContext } from '@/lib/copilot/request/types'
2233
@@ -285,6 +296,137 @@ describe('copilot go stream helpers', () => {
285296 ) . toBe ( true )
286297 } )
287298
299+ it ( 'reclassifies as aborted when the body closes without terminal but the abort marker is set' , async ( ) => {
300+ const textEvent = createEvent ( {
301+ streamId : 'stream-1' ,
302+ cursor : '1' ,
303+ seq : 1 ,
304+ requestId : 'req-1' ,
305+ type : MothershipStreamV1EventType . text ,
306+ payload : {
307+ channel : 'assistant' ,
308+ text : 'partial response' ,
309+ } ,
310+ } )
311+
312+ vi . mocked ( fetch ) . mockResolvedValueOnce ( createSseResponse ( [ textEvent ] ) )
313+ vi . mocked ( hasAbortMarker ) . mockResolvedValueOnce ( true )
314+
315+ const context = createStreamingContext ( )
316+ const execContext : ExecutionContext = {
317+ userId : 'user-1' ,
318+ workflowId : 'workflow-1' ,
319+ }
320+
321+ await runStreamLoop ( 'https://example.com/mothership/stream' , { } , context , execContext , {
322+ timeout : 1000 ,
323+ } )
324+
325+ expect ( hasAbortMarker ) . toHaveBeenCalledWith ( context . messageId )
326+ expect ( context . wasAborted ) . toBe ( true )
327+ expect (
328+ context . errors . some ( ( message ) =>
329+ message . includes ( 'Copilot backend stream ended before a terminal event' )
330+ )
331+ ) . toBe ( false )
332+ } )
333+
334+ it ( 'invokes onAbortObserved with MarkerObservedAtBodyClose when reclassifying via the abort marker' , async ( ) => {
335+ const textEvent = createEvent ( {
336+ streamId : 'stream-1' ,
337+ cursor : '1' ,
338+ seq : 1 ,
339+ requestId : 'req-1' ,
340+ type : MothershipStreamV1EventType . text ,
341+ payload : {
342+ channel : 'assistant' ,
343+ text : 'partial response' ,
344+ } ,
345+ } )
346+
347+ vi . mocked ( fetch ) . mockResolvedValueOnce ( createSseResponse ( [ textEvent ] ) )
348+ vi . mocked ( hasAbortMarker ) . mockResolvedValueOnce ( true )
349+
350+ const context = createStreamingContext ( )
351+ const execContext : ExecutionContext = {
352+ userId : 'user-1' ,
353+ workflowId : 'workflow-1' ,
354+ }
355+ const onAbortObserved = vi . fn ( )
356+
357+ await runStreamLoop ( 'https://example.com/mothership/stream' , { } , context , execContext , {
358+ timeout : 1000 ,
359+ onAbortObserved,
360+ } )
361+
362+ expect ( onAbortObserved ) . toHaveBeenCalledTimes ( 1 )
363+ expect ( onAbortObserved ) . toHaveBeenCalledWith ( AbortReason . MarkerObservedAtBodyClose )
364+ expect ( context . wasAborted ) . toBe ( true )
365+ } )
366+
367+ it ( 'does not invoke onAbortObserved when no abort marker is present at body close' , async ( ) => {
368+ const textEvent = createEvent ( {
369+ streamId : 'stream-1' ,
370+ cursor : '1' ,
371+ seq : 1 ,
372+ requestId : 'req-1' ,
373+ type : MothershipStreamV1EventType . text ,
374+ payload : {
375+ channel : 'assistant' ,
376+ text : 'partial response' ,
377+ } ,
378+ } )
379+
380+ vi . mocked ( fetch ) . mockResolvedValueOnce ( createSseResponse ( [ textEvent ] ) )
381+ vi . mocked ( hasAbortMarker ) . mockResolvedValueOnce ( false )
382+
383+ const context = createStreamingContext ( )
384+ const execContext : ExecutionContext = {
385+ userId : 'user-1' ,
386+ workflowId : 'workflow-1' ,
387+ }
388+ const onAbortObserved = vi . fn ( )
389+
390+ await expect (
391+ runStreamLoop ( 'https://example.com/mothership/stream' , { } , context , execContext , {
392+ timeout : 1000 ,
393+ onAbortObserved,
394+ } )
395+ ) . rejects . toThrow ( 'Copilot backend stream ended before a terminal event' )
396+
397+ expect ( onAbortObserved ) . not . toHaveBeenCalled ( )
398+ } )
399+
400+ it ( 'still fails closed when the body closes without terminal and the abort marker check throws' , async ( ) => {
401+ const textEvent = createEvent ( {
402+ streamId : 'stream-1' ,
403+ cursor : '1' ,
404+ seq : 1 ,
405+ requestId : 'req-1' ,
406+ type : MothershipStreamV1EventType . text ,
407+ payload : {
408+ channel : 'assistant' ,
409+ text : 'partial response' ,
410+ } ,
411+ } )
412+
413+ vi . mocked ( fetch ) . mockResolvedValueOnce ( createSseResponse ( [ textEvent ] ) )
414+ vi . mocked ( hasAbortMarker ) . mockRejectedValueOnce ( new Error ( 'redis unavailable' ) )
415+
416+ const context = createStreamingContext ( )
417+ const execContext : ExecutionContext = {
418+ userId : 'user-1' ,
419+ workflowId : 'workflow-1' ,
420+ }
421+
422+ await expect (
423+ runStreamLoop ( 'https://example.com/mothership/stream' , { } , context , execContext , {
424+ timeout : 1000 ,
425+ } )
426+ ) . rejects . toThrow ( 'Copilot backend stream ended before a terminal event' )
427+ expect ( context . wasAborted ) . toBe ( false )
428+ } )
429+
288430 it ( 'fails closed when the shared stream receives an invalid event' , async ( ) => {
289431 vi . mocked ( fetch ) . mockResolvedValueOnce (
290432 createSseResponse ( [
0 commit comments