Skip to content

Commit d9db35e

Browse files
authored
Fix staging Stop admission and oversized response uploads (#8257)
1 parent 1fa912f commit d9db35e

7 files changed

Lines changed: 195 additions & 124 deletions

File tree

‎apps/sim/app/api/copilot/chat/stop/route.test.ts‎

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -148,42 +148,70 @@ describe('copilot chat stop route', () => {
148148
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
149149
})
150150

151-
it('appends a stopped assistant message even with no content', async () => {
151+
it('persists a response larger than the HTTP limit from an identifiers-only Stop', async () => {
152152
mockReads({
153153
chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null },
154154
last: { messageId: 'stream-1', role: 'user' },
155155
})
156-
157-
const response = await stopRequest(
158-
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
159-
)
160-
161-
expect(mockReadEvents).toHaveBeenCalledWith('stream-1', '0')
162-
expect(response.status).toBe(200)
163-
expect(await response.json()).toEqual({ success: true })
164-
165-
const setArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record<string, unknown>
166-
expect(setArg.conversationId).toBeNull()
167-
expect(Object.hasOwn(setArg, 'messages')).toBe(false)
168-
169-
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
170-
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
171-
expect(appended[0]).toMatchObject({
172-
role: 'assistant',
173-
content: '',
174-
contentBlocks: [{ type: 'complete', status: 'cancelled' }],
175-
})
176-
177-
expect(mockPublishStatusChanged).toHaveBeenCalledWith(
178-
expect.objectContaining({ workspaceId: 'ws-1' }),
156+
const content = 'x'.repeat(11 * 1024 * 1024)
157+
const envelope = { v: 1, ts: '2026-09-24T19:00:00Z', stream: { streamId: 'stream-1' } }
158+
mockReadEvents.mockResolvedValue([
159+
{ ...envelope, seq: 1, type: 'text', payload: { channel: 'assistant', text: content } },
179160
{
180-
chatId: 'chat-1',
181-
type: 'completed',
182-
streamId: 'stream-1',
183-
}
161+
...envelope,
162+
seq: 2,
163+
type: 'tool',
164+
payload: {
165+
phase: 'call',
166+
toolCallId: 'call-1',
167+
toolName: 'run_code',
168+
arguments: { code: 'preserve me' },
169+
status: 'executing',
170+
},
171+
},
172+
])
173+
const request = createRequest({ chatId: 'chat-1', streamId: 'stream-1' })
174+
expect((await request.clone().text()).length).toBeLessThan(1024)
175+
const response = await stopRequest(request)
176+
expect(response.status).toBe(200)
177+
expect(mockReadEvents).toHaveBeenCalledOnce()
178+
expect(mockAppendCopilotChatMessages).toHaveBeenCalledOnce()
179+
const saved = mockAppendCopilotChatMessages.mock.calls[0][1][0]
180+
expect(saved.content).toBe(content)
181+
expect(saved.contentBlocks).toEqual(
182+
expect.arrayContaining([
183+
expect.objectContaining({
184+
type: 'tool',
185+
toolCall: expect.objectContaining({ id: 'call-1', params: { code: 'preserve me' } }),
186+
}),
187+
{ type: 'complete', status: 'cancelled' },
188+
])
184189
)
190+
expect(dbChainMockFns.set.mock.calls[0][0].conversationId).toBeNull()
191+
expect(mockPublishStatusChanged).toHaveBeenCalledOnce()
185192
})
186193

194+
it.each([{ seqs: [] }, { seqs: [2, 3] }, { seqs: [1, 3] }])(
195+
'leaves incomplete replay $seqs to the run owner without erasing its response',
196+
async ({ seqs }) => {
197+
mockReadEvents.mockResolvedValue(
198+
seqs.map((seq) => ({
199+
v: 1,
200+
seq,
201+
ts: '2026-09-24T19:00:00Z',
202+
stream: { streamId: 'stream-1' },
203+
type: 'text',
204+
payload: { channel: 'assistant', text: 'tail only' },
205+
}))
206+
)
207+
const response = await stopRequest(createRequest({ chatId: 'chat-1', streamId: 'stream-1' }))
208+
expect(response.status).toBe(200)
209+
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
210+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
211+
expect(mockPublishStatusChanged).not.toHaveBeenCalled()
212+
}
213+
)
214+
187215
it('appends a stopped assistant message if the stream marker was already cleared', async () => {
188216
mockReads({
189217
chat: { workspaceId: 'ws-1', conversationId: null, model: null },

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
type PersistedMessage,
1212
withStoppedContentBlock,
1313
} from '@/lib/mothership/chat/persisted-message'
14-
import { finalizeAssistantTurn } from '@/lib/mothership/chat/terminal-state'
14+
import {
15+
finalizeAssistantTurn,
16+
readStoppedAssistantMessage,
17+
} from '@/lib/mothership/chat/terminal-state'
1518
import { publishChatStatusChanged } from '@/lib/mothership/chat-status'
1619
import {
1720
CopilotChatFinalizeOutcome,
@@ -61,23 +64,28 @@ export const POST = withRouteHandler((req: NextRequest) =>
6164
: hasContent
6265
? [{ type: 'text', channel: 'assistant', content }]
6366
: []
64-
const assistantMessage: PersistedMessage = withStoppedContentBlock(
65-
normalizeMessage({
66-
id: generateId(),
67-
role: 'assistant',
68-
content,
69-
timestamp: new Date().toISOString(),
70-
contentBlocks: assistantBlocks,
71-
...(requestId ? { requestId } : {}),
72-
})
73-
)
67+
const assistantMessage: PersistedMessage | null =
68+
hasContent || hasBlocks
69+
? withStoppedContentBlock(
70+
normalizeMessage({
71+
id: generateId(),
72+
role: 'assistant',
73+
content,
74+
timestamp: new Date().toISOString(),
75+
contentBlocks: assistantBlocks,
76+
...(requestId ? { requestId } : {}),
77+
})
78+
)
79+
: await readStoppedAssistantMessage(streamId)
80+
/** The run owner retains the full response if replay was trimmed or has not started. */
81+
if (!assistantMessage) return NextResponse.json({ success: true })
7482
const result = await finalizeAssistantTurn({
7583
chatId,
7684
userId: session.user.id,
7785
userMessageId: streamId,
7886
assistantMessage,
7987
streamMarkerPolicy: 'active-or-cleared',
80-
preferServerReplay: true,
88+
preferServerReplay: hasContent || hasBlocks,
8189
})
8290
span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, result.appendedAssistant)
8391
const stopOutcome = !result.found

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx‎

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ import {
8181
seedDeploymentShape,
8282
} from '@/lib/core/config/deployment-shape'
8383
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
84-
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
8584
import type { MothershipStreamV1EventEnvelope } from '@/lib/mothership/generated/mothership-stream-v1'
8685
import { createSearchResource } from '@/lib/mothership/resources/search'
8786
import { getChatResourceSelectionId } from '@/lib/mothership/resources/types'
@@ -117,6 +116,7 @@ interface NetworkState {
117116
abortSettlements: boolean[]
118117
abortBodies: CopilotChatAbortBody[]
119118
stopBodies: CopilotChatStopBody[]
119+
toolInputPadding?: string
120120
abortTraceparents: Array<string | null>
121121
}
122122

@@ -221,11 +221,14 @@ async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise<
221221
stream: { streamId },
222222
payload: {
223223
phase: 'call',
224-
executor: 'client',
225-
mode: 'async',
226-
toolName: 'run_workflow',
224+
executor: state.toolInputPadding ? 'go' : 'client',
225+
mode: state.toolInputPadding ? 'sync' : 'async',
226+
toolName: state.toolInputPadding ? 'run_code' : 'run_workflow',
227227
toolCallId: 'this-chat-tool',
228-
arguments: { workflowId: 'this-chat-workflow' },
228+
arguments: {
229+
workflowId: 'this-chat-workflow',
230+
...(state.toolInputPadding ? { padding: state.toolInputPadding } : {}),
231+
},
229232
},
230233
}
231234
return new Response(
@@ -751,6 +754,7 @@ describe('useChat remount send recovery', () => {
751754
state.pendingAdmissions.clear()
752755
state.abortSettlements = []
753756
state.abortBodies = []
757+
state.toolInputPadding = undefined
754758
state.stopBodies = []
755759
state.abortTraceparents = []
756760
mockRequestJson.mockResolvedValue({ chats: [] })
@@ -1450,6 +1454,41 @@ describe('useChat remount send recovery', () => {
14501454
}
14511455
)
14521456

1457+
it.each([false, true])(
1458+
'does not interrupt or send a queued edit before submission (explicit ID: %s)',
1459+
async (explicitId) => {
1460+
state.postBehavior = 'task'
1461+
const { getResult } = renderUseChatInChat('chat-a')
1462+
await act(async () => {
1463+
void getResult().sendMessage('Original request')
1464+
})
1465+
await waitFor(() => state.postBodies.length === 1 && getResult().isSending)
1466+
const beforeRender = getResult()
1467+
let queuedId = ''
1468+
await act(async () => {
1469+
void beforeRender.sendMessage('Unfinished correction')
1470+
queuedId = allQueuedMessages()[0].id
1471+
beforeRender.editQueuedMessage(queuedId)
1472+
void beforeRender.sendNow(explicitId ? queuedId : undefined)
1473+
})
1474+
expect(state.abortBodies).toHaveLength(0)
1475+
expect(state.postBodies).toHaveLength(1)
1476+
expect(allQueuedMessages()).toEqual([
1477+
expect.objectContaining({ id: queuedId, content: 'Unfinished correction' }),
1478+
])
1479+
expect(useMothershipQueueStore.getState().editing['chat-a']).toBe(queuedId)
1480+
state.postBehavior = 'hang'
1481+
await act(async () => {
1482+
void getResult().sendMessage('Finished correction')
1483+
void getResult().sendNow()
1484+
})
1485+
await waitFor(() => state.postBodies.length === 2)
1486+
expect(state.postBodies[1].message).toBe('Finished correction')
1487+
expect(state.abortBodies).toHaveLength(1)
1488+
expect(allQueuedMessages()).toHaveLength(0)
1489+
}
1490+
)
1491+
14531492
it('sends the live queue head once without waiting for a render, after Stop settles', async () => {
14541493
state.postBehavior = 'task'
14551494
const { getResult } = renderUseChatInChat('chat-a')
@@ -1713,7 +1752,7 @@ describe('useChat remount send recovery', () => {
17131752
}
17141753
})
17151754

1716-
it('preserves a visible workflow watch when Stop persists the partial response', async () => {
1755+
it('preserves the visible workflow watch while Stop sends only identifiers', async () => {
17171756
state.postBehavior = 'task'
17181757
const { getResult } = renderUseChatInChat('chat-a')
17191758
await act(async () => {
@@ -1730,20 +1769,43 @@ describe('useChat remount send recovery', () => {
17301769
await getResult().stopGeneration()
17311770
})
17321771
expect(state.stopBodies).toHaveLength(1)
1733-
const saved = state.stopBodies[0]
1734-
const restored = normalizeMessage({
1735-
id: 'saved-assistant',
1736-
role: 'assistant',
1737-
content: saved.content,
1738-
contentBlocks: saved.contentBlocks,
1739-
})
1740-
expect(restored.contentBlocks?.find((block) => block.type === 'task')?.task).toEqual({
1741-
taskId: 'watch-1',
1742-
kind: 'workflow_run',
1743-
status: 'pending',
1744-
target: { workflowId: 'workflow-1', executionId: 'watched-execution' },
1745-
note: 'Check the completed invoice run',
1772+
expect(state.stopBodies[0]).toEqual({
1773+
chatId: 'chat-a',
1774+
streamId: state.postBodies[0].userMessageId,
1775+
})
1776+
const task = getResult()
1777+
.messages.flatMap((message) => message.contentBlocks ?? [])
1778+
.find((block) => block.type === 'task')?.task
1779+
expect(task?.taskId).toBe('watch-1')
1780+
})
1781+
1782+
it('sends a queued correction after stopping with more than 10 MiB of tool input', async () => {
1783+
state.postBehavior = 'tool'
1784+
state.toolInputPadding = 'x'.repeat(11 * 1024 * 1024)
1785+
const { getResult } = renderUseChatInChat('chat-a')
1786+
await act(async () => {
1787+
void getResult().sendMessage('Start working')
17461788
})
1789+
await waitFor(() =>
1790+
expect(
1791+
getResult().messages.some((message) =>
1792+
message.contentBlocks?.some((block) => block.toolCall?.id === 'this-chat-tool')
1793+
)
1794+
).toBe(true)
1795+
)
1796+
state.postBehavior = 'hang'
1797+
await act(async () => {
1798+
await getResult().sendMessage('Use the correction')
1799+
void getResult().sendNow()
1800+
})
1801+
await waitFor(() => expect(state.postBodies).toHaveLength(2))
1802+
expect(state.postBodies[1].message).toBe('Use the correction')
1803+
expect(state.stopBodies).toHaveLength(1)
1804+
expect(state.stopBodies[0]).not.toHaveProperty('content')
1805+
expect(state.stopBodies[0]).not.toHaveProperty('contentBlocks')
1806+
expect(new TextEncoder().encode(JSON.stringify(state.stopBodies[0])).length).toBeLessThan(1024)
1807+
expect(allQueuedMessages()).toHaveLength(0)
1808+
expect(getResult().error).toBeNull()
17471809
})
17481810

17491811
it.each([false, true])(

0 commit comments

Comments
 (0)