22 * @vitest -environment node
33 */
44
5- import { beforeEach , describe , expect , it , vi } from 'vitest'
5+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
66import type { OrchestratorOptions } from './types'
77
88const {
99 prepareExecutionContext,
1010 getEffectiveDecryptedEnv,
1111 runStreamLoop,
12- claimCompletedAsyncToolCall,
13- getAsyncToolCall,
14- getAsyncToolCalls,
15- markAsyncToolDelivered,
16- releaseCompletedAsyncToolClaim,
1712 updateRunStatus,
1813} = vi . hoisted ( ( ) => ( {
1914 prepareExecutionContext : vi . fn ( ) ,
2015 getEffectiveDecryptedEnv : vi . fn ( ) ,
2116 runStreamLoop : vi . fn ( ) ,
22- claimCompletedAsyncToolCall : vi . fn ( ) ,
23- getAsyncToolCall : vi . fn ( ) ,
24- getAsyncToolCalls : vi . fn ( ) ,
25- markAsyncToolDelivered : vi . fn ( ) ,
26- releaseCompletedAsyncToolClaim : vi . fn ( ) ,
2717 updateRunStatus : vi . fn ( ) ,
2818} ) )
2919
@@ -36,11 +26,6 @@ vi.mock('@/lib/environment/utils', () => ({
3626} ) )
3727
3828vi . mock ( '@/lib/copilot/async-runs/repository' , ( ) => ( {
39- claimCompletedAsyncToolCall,
40- getAsyncToolCall,
41- getAsyncToolCalls,
42- markAsyncToolDelivered,
43- releaseCompletedAsyncToolClaim,
4429 updateRunStatus,
4530} ) )
4631
@@ -56,37 +41,40 @@ vi.mock('@/lib/copilot/orchestrator/stream/core', async () => {
5641import { orchestrateCopilotStream } from './index'
5742
5843describe ( 'orchestrateCopilotStream async continuation' , ( ) => {
44+ const fetchMock = vi . fn ( )
45+
5946 beforeEach ( ( ) => {
6047 vi . clearAllMocks ( )
48+ vi . useFakeTimers ( )
49+ vi . stubGlobal ( 'fetch' , fetchMock )
6150 prepareExecutionContext . mockResolvedValue ( {
6251 userId : 'user-1' ,
6352 workflowId : 'workflow-1' ,
6453 chatId : 'chat-1' ,
6554 } )
6655 getEffectiveDecryptedEnv . mockResolvedValue ( { } )
67- claimCompletedAsyncToolCall . mockResolvedValue ( { toolCallId : 'tool-1' } )
68- getAsyncToolCall . mockResolvedValue ( {
69- toolCallId : 'tool-1' ,
70- toolName : 'read' ,
71- status : 'completed' ,
72- result : { ok : true } ,
73- error : null ,
74- } )
75- getAsyncToolCalls . mockResolvedValue ( [
76- {
77- toolCallId : 'tool-1' ,
78- toolName : 'read' ,
79- status : 'completed' ,
80- result : { ok : true } ,
81- error : null ,
82- } ,
83- ] )
84- markAsyncToolDelivered . mockResolvedValue ( null )
85- releaseCompletedAsyncToolClaim . mockResolvedValue ( null )
8656 updateRunStatus . mockResolvedValue ( null )
8757 } )
8858
89- it ( 'builds resume payloads with success=true for claimed completed rows' , async ( ) => {
59+ afterEach ( ( ) => {
60+ vi . useRealTimers ( )
61+ vi . unstubAllGlobals ( )
62+ } )
63+
64+ it ( 'resumes with checkpointId only after Go reports readiness' , async ( ) => {
65+ fetchMock . mockResolvedValueOnce ( {
66+ ok : true ,
67+ json : async ( ) => ( {
68+ success : true ,
69+ checkpointId : 'checkpoint-1' ,
70+ runId : 'run-1' ,
71+ resumeState : 'ready' ,
72+ ready : true ,
73+ pendingCallIds : [ 'tool-1' ] ,
74+ missingCallIds : [ ] ,
75+ } ) ,
76+ } )
77+
9078 runStreamLoop
9179 . mockImplementationOnce ( async ( _url : string , _opts : RequestInit , context : any ) => {
9280 context . awaitingAsyncContinuation = {
@@ -100,14 +88,6 @@ describe('orchestrateCopilotStream async continuation', () => {
10088 const body = JSON . parse ( String ( opts . body ) )
10189 expect ( body ) . toEqual ( {
10290 checkpointId : 'checkpoint-1' ,
103- results : [
104- {
105- callId : 'tool-1' ,
106- name : 'read' ,
107- data : { ok : true } ,
108- success : true ,
109- } ,
110- ] ,
11191 } )
11292 } )
11393
@@ -123,10 +103,21 @@ describe('orchestrateCopilotStream async continuation', () => {
123103 )
124104
125105 expect ( result . success ) . toBe ( true )
126- expect ( markAsyncToolDelivered ) . toHaveBeenCalledWith ( 'tool-1' )
106+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 )
127107 } )
128108
129- it ( 'marks claimed tool calls delivered even when the resumed stream later records errors' , async ( ) => {
109+ it ( 'surfaces an explicit error when Go readiness check fails' , async ( ) => {
110+ fetchMock . mockResolvedValueOnce ( {
111+ ok : false ,
112+ status : 424 ,
113+ json : async ( ) => ( {
114+ error : 'checkpoint not ready' ,
115+ code : 'checkpoint_not_ready' ,
116+ retryable : true ,
117+ missingCallIds : [ 'tool-1' ] ,
118+ } ) ,
119+ } )
120+
130121 runStreamLoop
131122 . mockImplementationOnce ( async ( _url : string , _opts : RequestInit , context : any ) => {
132123 context . awaitingAsyncContinuation = {
@@ -135,9 +126,6 @@ describe('orchestrateCopilotStream async continuation', () => {
135126 pendingToolCallIds : [ 'tool-1' ] ,
136127 }
137128 } )
138- . mockImplementationOnce ( async ( _url : string , _opts : RequestInit , context : any ) => {
139- context . errors . push ( 'resume stream failed after handoff' )
140- } )
141129
142130 const result = await orchestrateCopilotStream (
143131 { message : 'hello' } ,
@@ -151,7 +139,8 @@ describe('orchestrateCopilotStream async continuation', () => {
151139 )
152140
153141 expect ( result . success ) . toBe ( false )
154- expect ( markAsyncToolDelivered ) . toHaveBeenCalledWith ( 'tool-1' )
142+ expect ( result . errors ) . toEqual ( [ 'checkpoint not ready' ] )
143+ expect ( runStreamLoop ) . toHaveBeenCalledTimes ( 1 )
155144 } )
156145
157146 it ( 'forwards done events while still marking async pauses on the run' , async ( ) => {
@@ -189,30 +178,23 @@ describe('orchestrateCopilotStream async continuation', () => {
189178 expect ( updateRunStatus ) . toHaveBeenCalledWith ( 'run-1' , 'paused_waiting_for_tool' )
190179 } )
191180
192- it ( 'waits for a local running tool before retrying the claim ' , async ( ) => {
181+ it ( 'waits for local pending tool promises before asking Go to resume ' , async ( ) => {
193182 const localPendingPromise = Promise . resolve ( {
194183 status : 'success' ,
195184 data : { ok : true } ,
196185 } )
197-
198- claimCompletedAsyncToolCall
199- . mockResolvedValueOnce ( null )
200- . mockResolvedValueOnce ( { toolCallId : 'tool-1' } )
201- getAsyncToolCall
202- . mockResolvedValueOnce ( {
203- toolCallId : 'tool-1' ,
204- toolName : 'read' ,
205- status : 'running' ,
206- result : null ,
207- error : null ,
208- } )
209- . mockResolvedValue ( {
210- toolCallId : 'tool-1' ,
211- toolName : 'read' ,
212- status : 'completed' ,
213- result : { ok : true } ,
214- error : null ,
215- } )
186+ fetchMock . mockResolvedValueOnce ( {
187+ ok : true ,
188+ json : async ( ) => ( {
189+ success : true ,
190+ checkpointId : 'checkpoint-1' ,
191+ runId : 'run-1' ,
192+ resumeState : 'ready' ,
193+ ready : true ,
194+ pendingCallIds : [ 'tool-1' ] ,
195+ missingCallIds : [ ] ,
196+ } ) ,
197+ } )
216198
217199 runStreamLoop
218200 . mockImplementationOnce ( async ( _url : string , _opts : RequestInit , context : any ) => {
@@ -226,11 +208,8 @@ describe('orchestrateCopilotStream async continuation', () => {
226208 . mockImplementationOnce ( async ( url : string , opts : RequestInit ) => {
227209 expect ( url ) . toContain ( '/api/tools/resume' )
228210 const body = JSON . parse ( String ( opts . body ) )
229- expect ( body . results [ 0 ] ) . toEqual ( {
230- callId : 'tool-1' ,
231- name : 'read' ,
232- data : { ok : true } ,
233- success : true ,
211+ expect ( body ) . toEqual ( {
212+ checkpointId : 'checkpoint-1' ,
234213 } )
235214 } )
236215
@@ -247,10 +226,22 @@ describe('orchestrateCopilotStream async continuation', () => {
247226
248227 expect ( result . success ) . toBe ( true )
249228 expect ( runStreamLoop ) . toHaveBeenCalledTimes ( 2 )
250- expect ( markAsyncToolDelivered ) . toHaveBeenCalledWith ( 'tool-1' )
251229 } )
252230
253- it ( 'releases claimed rows if the resume stream throws before delivery is marked' , async ( ) => {
231+ it ( 'retries tool resume after an upstream 502 and succeeds' , async ( ) => {
232+ fetchMock . mockResolvedValueOnce ( {
233+ ok : true ,
234+ json : async ( ) => ( {
235+ success : true ,
236+ checkpointId : 'checkpoint-1' ,
237+ runId : 'run-1' ,
238+ resumeState : 'ready' ,
239+ ready : true ,
240+ pendingCallIds : [ 'tool-1' ] ,
241+ missingCallIds : [ ] ,
242+ } ) ,
243+ } )
244+
254245 runStreamLoop
255246 . mockImplementationOnce ( async ( _url : string , _opts : RequestInit , context : any ) => {
256247 context . awaitingAsyncContinuation = {
@@ -260,10 +251,17 @@ describe('orchestrateCopilotStream async continuation', () => {
260251 }
261252 } )
262253 . mockImplementationOnce ( async ( ) => {
263- throw new Error ( 'resume failed' )
254+ throw new Error ( 'Copilot backend error (502): <html><h1>502 Bad Gateway</h1></html>' )
255+ } )
256+ . mockImplementationOnce ( async ( url : string , opts : RequestInit ) => {
257+ expect ( url ) . toContain ( '/api/tools/resume' )
258+ const body = JSON . parse ( String ( opts . body ) )
259+ expect ( body ) . toEqual ( {
260+ checkpointId : 'checkpoint-1' ,
261+ } )
264262 } )
265263
266- const result = await orchestrateCopilotStream (
264+ const resultPromise = orchestrateCopilotStream (
267265 { message : 'hello' } ,
268266 {
269267 userId : 'user-1' ,
@@ -274,45 +272,10 @@ describe('orchestrateCopilotStream async continuation', () => {
274272 }
275273 )
276274
277- expect ( result . success ) . toBe ( false )
278- expect ( releaseCompletedAsyncToolClaim ) . toHaveBeenCalledWith ( 'tool-1' , 'run-1' )
279- expect ( markAsyncToolDelivered ) . not . toHaveBeenCalled ( )
280- } )
281-
282- it ( 'does not send a partial resume payload when only some pending tool calls are claimable' , async ( ) => {
283- claimCompletedAsyncToolCall
284- . mockResolvedValueOnce ( { toolCallId : 'tool-1' } )
285- . mockResolvedValueOnce ( null )
286- . mockResolvedValueOnce ( { toolCallId : 'tool-1' } )
287- . mockResolvedValueOnce ( null )
288- . mockResolvedValueOnce ( { toolCallId : 'tool-1' } )
289- . mockResolvedValueOnce ( null )
290- . mockResolvedValueOnce ( { toolCallId : 'tool-1' } )
291- . mockResolvedValueOnce ( null )
292- getAsyncToolCall . mockResolvedValue ( null )
293-
294- runStreamLoop . mockImplementationOnce ( async ( _url : string , _opts : RequestInit , context : any ) => {
295- context . awaitingAsyncContinuation = {
296- checkpointId : 'checkpoint-1' ,
297- runId : 'run-1' ,
298- pendingToolCallIds : [ 'tool-1' , 'tool-2' ] ,
299- }
300- } )
301-
302- const result = await orchestrateCopilotStream (
303- { message : 'hello' } ,
304- {
305- userId : 'user-1' ,
306- workflowId : 'workflow-1' ,
307- chatId : 'chat-1' ,
308- executionId : 'exec-1' ,
309- runId : 'run-1' ,
310- }
311- )
275+ await vi . runAllTimersAsync ( )
276+ const result = await resultPromise
312277
313278 expect ( result . success ) . toBe ( true )
314- expect ( runStreamLoop ) . toHaveBeenCalledTimes ( 1 )
315- expect ( releaseCompletedAsyncToolClaim ) . toHaveBeenCalledWith ( 'tool-1' , 'run-1' )
316- expect ( markAsyncToolDelivered ) . not . toHaveBeenCalled ( )
279+ expect ( runStreamLoop ) . toHaveBeenCalledTimes ( 3 )
317280 } )
318281} )
0 commit comments