Skip to content

Commit 775daed

Browse files
Sg312waleedlatif1claudelakeesivicecrasher321
authored
fix(mothership): tool call loop (#3729)
* v0 * Fix ppt load * Fixes * Fixes * Fix lint * Fix wid * Download image * Update tools * Fix lint * Fix error msg * Tool fixes * Reenable subagent stream * Subagent stream * Fix edit workflow hydration * Throw func execute error on error * Rewrite * Remove promptForToolApproval flag, fix workflow terminal logs * Fixes * Fix buffer * Fix * Fix claimed by * Cleanup v1 * Tool call loop * Fixes * Fixes * Fix subaget aborts * Fix diff * Add delegating state to subagents * Fix build * Fix sandbox * Fix lint --------- Co-authored-by: Waleed <walif6@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com> Co-authored-by: Theodore Li <teddy@zenobiapay.com>
1 parent 8f793d9 commit 775daed

File tree

42 files changed

+16440
-842
lines changed

Some content is hidden

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

42 files changed

+16440
-842
lines changed

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

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,6 @@ export async function POST(req: NextRequest) {
346346
goRoute: '/api/copilot',
347347
autoExecuteTools: true,
348348
interactive: true,
349-
promptForToolApproval: false,
350349
onComplete: async (result: OrchestratorResult) => {
351350
if (!actualChatId) return
352351

@@ -365,16 +364,25 @@ export async function POST(req: NextRequest) {
365364
const stored: Record<string, unknown> = { type: block.type }
366365
if (block.content) stored.content = block.content
367366
if (block.type === 'tool_call' && block.toolCall) {
367+
const state =
368+
block.toolCall.result?.success !== undefined
369+
? block.toolCall.result.success
370+
? 'success'
371+
: 'error'
372+
: block.toolCall.status
373+
const isSubagentTool = !!block.calledBy
374+
const isNonTerminal =
375+
state === 'cancelled' || state === 'pending' || state === 'executing'
368376
stored.toolCall = {
369377
id: block.toolCall.id,
370378
name: block.toolCall.name,
371-
state:
372-
block.toolCall.result?.success !== undefined
373-
? block.toolCall.result.success
374-
? 'success'
375-
: 'error'
376-
: block.toolCall.status,
377-
result: block.toolCall.result,
379+
state,
380+
...(isSubagentTool && isNonTerminal ? {} : { result: block.toolCall.result }),
381+
...(isSubagentTool && isNonTerminal
382+
? {}
383+
: block.toolCall.params
384+
? { params: block.toolCall.params }
385+
: {}),
378386
...(block.calledBy ? { calledBy: block.calledBy } : {}),
379387
}
380388
}
@@ -426,7 +434,6 @@ export async function POST(req: NextRequest) {
426434
goRoute: '/api/copilot',
427435
autoExecuteTools: true,
428436
interactive: true,
429-
promptForToolApproval: false,
430437
})
431438

432439
const responseData = {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { getStreamMeta, readStreamEvents, authenticateCopilotRequestSessionOnly } = vi.hoisted(
9+
() => ({
10+
getStreamMeta: vi.fn(),
11+
readStreamEvents: vi.fn(),
12+
authenticateCopilotRequestSessionOnly: vi.fn(),
13+
})
14+
)
15+
16+
vi.mock('@/lib/copilot/orchestrator/stream/buffer', () => ({
17+
getStreamMeta,
18+
readStreamEvents,
19+
}))
20+
21+
vi.mock('@/lib/copilot/request-helpers', () => ({
22+
authenticateCopilotRequestSessionOnly,
23+
}))
24+
25+
import { GET } from '@/app/api/copilot/chat/stream/route'
26+
27+
describe('copilot chat stream replay route', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
authenticateCopilotRequestSessionOnly.mockResolvedValue({
31+
userId: 'user-1',
32+
isAuthenticated: true,
33+
})
34+
readStreamEvents.mockResolvedValue([])
35+
})
36+
37+
it('stops replay polling when stream meta becomes cancelled', async () => {
38+
getStreamMeta
39+
.mockResolvedValueOnce({
40+
status: 'active',
41+
userId: 'user-1',
42+
})
43+
.mockResolvedValueOnce({
44+
status: 'cancelled',
45+
userId: 'user-1',
46+
})
47+
48+
const response = await GET(
49+
new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1')
50+
)
51+
52+
const reader = response.body?.getReader()
53+
expect(reader).toBeTruthy()
54+
55+
const first = await reader!.read()
56+
expect(first.done).toBe(true)
57+
expect(getStreamMeta).toHaveBeenCalledTimes(2)
58+
})
59+
})

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

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,33 @@ export async function GET(request: NextRequest) {
8080
async start(controller) {
8181
let lastEventId = Number.isFinite(fromEventId) ? fromEventId : 0
8282
let latestMeta = meta
83+
let controllerClosed = false
84+
85+
const closeController = () => {
86+
if (controllerClosed) return
87+
controllerClosed = true
88+
try {
89+
controller.close()
90+
} catch {
91+
// Controller already closed by runtime/client - treat as normal.
92+
}
93+
}
94+
95+
const enqueueEvent = (payload: Record<string, any>) => {
96+
if (controllerClosed) return false
97+
try {
98+
controller.enqueue(encodeEvent(payload))
99+
return true
100+
} catch {
101+
controllerClosed = true
102+
return false
103+
}
104+
}
105+
106+
const abortListener = () => {
107+
controllerClosed = true
108+
}
109+
request.signal.addEventListener('abort', abortListener, { once: true })
83110

84111
const flushEvents = async () => {
85112
const events = await readStreamEvents(streamId, lastEventId)
@@ -99,37 +126,50 @@ export async function GET(request: NextRequest) {
99126
executionId: latestMeta?.executionId,
100127
runId: latestMeta?.runId,
101128
}
102-
controller.enqueue(encodeEvent(payload))
129+
if (!enqueueEvent(payload)) {
130+
break
131+
}
103132
}
104133
}
105134

106135
try {
107136
await flushEvents()
108137

109-
while (Date.now() - startTime < MAX_STREAM_MS) {
138+
while (!controllerClosed && Date.now() - startTime < MAX_STREAM_MS) {
110139
const currentMeta = await getStreamMeta(streamId)
111140
if (!currentMeta) break
112141
latestMeta = currentMeta
113142

114143
await flushEvents()
115144

116-
if (currentMeta.status === 'complete' || currentMeta.status === 'error') {
145+
if (controllerClosed) {
146+
break
147+
}
148+
if (
149+
currentMeta.status === 'complete' ||
150+
currentMeta.status === 'error' ||
151+
currentMeta.status === 'cancelled'
152+
) {
117153
break
118154
}
119155

120156
if (request.signal.aborted) {
157+
controllerClosed = true
121158
break
122159
}
123160

124161
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
125162
}
126163
} catch (error) {
127-
logger.warn('Stream replay failed', {
128-
streamId,
129-
error: error instanceof Error ? error.message : String(error),
130-
})
164+
if (!controllerClosed && !request.signal.aborted) {
165+
logger.warn('Stream replay failed', {
166+
streamId,
167+
error: error instanceof Error ? error.message : String(error),
168+
})
169+
}
131170
} finally {
132-
controller.close()
171+
request.signal.removeEventListener('abort', abortListener)
172+
closeController()
133173
}
134174
},
135175
})

0 commit comments

Comments
 (0)