Skip to content

Commit a7a6b9a

Browse files
committed
Fix hang
1 parent 367415f commit a7a6b9a

File tree

46 files changed

+7178
-7250
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+7178
-7250
lines changed

apps/sim/app/api/copilot/chat/queries.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
createInternalServerErrorResponse,
1212
createUnauthorizedResponse,
1313
} from '@/lib/copilot/request/http'
14-
import { readFilePreviewSessions } from '@/lib/copilot/request/session'
14+
import { deriveReplayTerminalState, readFilePreviewSessions } from '@/lib/copilot/request/session'
1515
import { readEvents } from '@/lib/copilot/request/session/buffer'
1616
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
1717
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -71,6 +71,7 @@ export async function GET(req: NextRequest) {
7171
events: ReturnType<typeof toStreamBatchEvent>[]
7272
previewSessions: Awaited<ReturnType<typeof readFilePreviewSessions>>
7373
status: string
74+
error?: string
7475
} | null = null
7576
if (chat.conversationId) {
7677
try {
@@ -94,15 +95,21 @@ export async function GET(req: NextRequest) {
9495
}),
9596
])
9697

98+
const replayState = deriveReplayTerminalState(events)
99+
const snapshotError =
100+
replayState.error ?? (typeof run?.error === 'string' ? run.error : undefined)
97101
streamSnapshot = {
98102
events: events.map(toStreamBatchEvent),
99103
previewSessions,
100104
status:
101-
typeof run?.status === 'string'
102-
? run.status
103-
: events.length > 0
104-
? 'active'
105-
: 'unknown',
105+
typeof replayState.status === 'string'
106+
? replayState.status
107+
: typeof run?.status === 'string'
108+
? run.status
109+
: events.length > 0
110+
? 'active'
111+
: 'unknown',
112+
...(typeof snapshotError === 'string' ? { error: snapshotError } : {}),
106113
}
107114
} catch (error) {
108115
logger.warn('Failed to load copilot chat stream snapshot', {

apps/sim/app/api/copilot/chat/stream/route.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { NextRequest } from 'next/server'
6-
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import {
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

Comments
 (0)