33 */
44
55import { NextRequest } from 'next/server'
6- import { beforeEach , describe , expect , it , vi } from 'vitest'
6+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
77import {
88 MothershipStreamV1CompletionStatus ,
99 MothershipStreamV1EventType ,
@@ -31,6 +31,7 @@ vi.mock('@/lib/copilot/request/session', () => ({
3131 readEvents,
3232 readFilePreviewSessions,
3333 checkForReplayGap,
34+ deriveReplayTerminalState : vi . fn ( ( ) => ( { } ) ) ,
3435 createEvent : ( event : Record < string , unknown > ) => ( {
3536 stream : {
3637 streamId : event . streamId ,
@@ -81,6 +82,10 @@ describe('copilot chat stream replay route', () => {
8182 checkForReplayGap . mockResolvedValue ( null )
8283 } )
8384
85+ afterEach ( ( ) => {
86+ vi . useRealTimers ( )
87+ } )
88+
8489 it ( 'returns preview sessions in batch mode' , async ( ) => {
8590 getLatestRunForStream . mockResolvedValue ( {
8691 status : 'active' ,
@@ -121,6 +126,28 @@ describe('copilot chat stream replay route', () => {
121126 } )
122127 } )
123128
129+ it ( 'returns the persisted run error in batch mode for terminal errors' , async ( ) => {
130+ getLatestRunForStream . mockResolvedValue ( {
131+ status : 'error' ,
132+ executionId : 'exec-1' ,
133+ id : 'run-1' ,
134+ error : 'tool replay failed' ,
135+ } )
136+
137+ const response = await GET (
138+ new NextRequest (
139+ 'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0&batch=true'
140+ )
141+ )
142+
143+ expect ( response . status ) . toBe ( 200 )
144+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
145+ success : true ,
146+ status : 'error' ,
147+ error : 'tool replay failed' ,
148+ } )
149+ } )
150+
124151 it ( 'stops replay polling when run becomes cancelled' , async ( ) => {
125152 getLatestRunForStream
126153 . mockResolvedValueOnce ( {
@@ -148,6 +175,35 @@ describe('copilot chat stream replay route', () => {
148175 expect ( getLatestRunForStream ) . toHaveBeenCalledTimes ( 2 )
149176 } )
150177
178+ it ( 'stops replay once persisted terminal events are flushed even if the run row is still active' , async ( ) => {
179+ getLatestRunForStream . mockResolvedValue ( {
180+ status : 'active' ,
181+ executionId : 'exec-1' ,
182+ id : 'run-1' ,
183+ } )
184+ readEvents . mockResolvedValue ( [
185+ {
186+ v : 1 ,
187+ type : MothershipStreamV1EventType . complete ,
188+ seq : 1 ,
189+ ts : '2026-01-01T00:00:00.000Z' ,
190+ stream : { streamId : 'stream-1' , cursor : '1' } ,
191+ trace : { requestId : 'req-1' } ,
192+ payload : {
193+ status : MothershipStreamV1CompletionStatus . complete ,
194+ } ,
195+ } ,
196+ ] )
197+
198+ const response = await GET (
199+ new NextRequest ( 'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0' )
200+ )
201+
202+ const chunks = await readAllChunks ( response )
203+ expect ( chunks . join ( '' ) ) . toContain ( `"type":"${ MothershipStreamV1EventType . complete } "` )
204+ expect ( getLatestRunForStream ) . toHaveBeenCalledTimes ( 1 )
205+ } )
206+
151207 it ( 'emits structured terminal replay error when run metadata disappears' , async ( ) => {
152208 getLatestRunForStream
153209 . mockResolvedValueOnce ( {
@@ -167,4 +223,47 @@ describe('copilot chat stream replay route', () => {
167223 expect ( body ) . toContain ( '"code":"resume_run_unavailable"' )
168224 expect ( body ) . toContain ( `"type":"${ MothershipStreamV1EventType . complete } "` )
169225 } )
226+
227+ it ( 'returns structured JSON when the initial replay lookup times out' , async ( ) => {
228+ vi . useFakeTimers ( )
229+ getLatestRunForStream . mockImplementation ( ( ) => new Promise ( ( ) => { } ) )
230+
231+ const responsePromise = GET (
232+ new NextRequest ( 'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0' )
233+ )
234+
235+ await vi . advanceTimersByTimeAsync ( 10_000 )
236+
237+ const response = await responsePromise
238+ expect ( response . status ) . toBe ( 504 )
239+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
240+ error : 'The stream recovery timed out before replay could start.' ,
241+ code : 'resume_initial_lookup_timeout' ,
242+ } )
243+ } )
244+
245+ it ( 'returns structured JSON when batch replay times out' , async ( ) => {
246+ vi . useFakeTimers ( )
247+ getLatestRunForStream . mockResolvedValue ( {
248+ status : 'active' ,
249+ executionId : 'exec-1' ,
250+ id : 'run-1' ,
251+ } )
252+ readEvents . mockImplementation ( ( ) => new Promise ( ( ) => { } ) )
253+
254+ const responsePromise = GET (
255+ new NextRequest (
256+ 'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0&batch=true'
257+ )
258+ )
259+
260+ await vi . advanceTimersByTimeAsync ( 10_000 )
261+
262+ const response = await responsePromise
263+ expect ( response . status ) . toBe ( 504 )
264+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
265+ error : 'The stream batch replay timed out before completion.' ,
266+ code : 'resume_batch_timeout' ,
267+ } )
268+ } )
170269} )
0 commit comments