Skip to content

Commit 0c67c36

Browse files
committed
Fix mothership boundary
1 parent 41a7d24 commit 0c67c36

5 files changed

Lines changed: 388 additions & 421 deletions

File tree

apps/sim/lib/copilot/orchestrator/index.test.ts

Lines changed: 80 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,18 @@
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'
66
import type { OrchestratorOptions } from './types'
77

88
const {
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

3828
vi.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 () => {
5641
import { orchestrateCopilotStream } from './index'
5742

5843
describe('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

Comments
 (0)