Skip to content

Commit f83d977

Browse files
authored
refactor(mothership): one re-queue policy and one send payload, and keep a Stop-failed send waiting for the user (#8740)
* refactor(mothership): one re-queue policy for a withdrawn send A withdrawn send's outcome is one reason (withdrawn, offline, unreachable, busy, stop-failed) instead of five exclusive booleans, and a pure requeuedFields(reason, previousAttempts, chatlessSurface) is the policy both re-queue sites apply. The restore strips the hold, retry and surface fields an earlier outcome left before applying it, so a stale heldUntilOnline can no longer let the browser coming online send a message waiting for the user. * refactor(mothership): pass a chat send as one SendPayload A send's content, attachments, contexts, mode and search settings travel as one SendPayload instead of positional parameters (createQueuedMessage, sendMothershipMessage) and about ten hand-spread copies; sendPayload() is the one place that drops the fields a send does not set.
1 parent 435d413 commit f83d977

12 files changed

Lines changed: 328 additions & 245 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/home.tsx‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components
2525
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
2626
import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions'
2727
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
28+
import { sendPayload } from '@/app/workspace/[workspaceId]/home/hooks/send-queue-policy'
2829
import {
2930
useChatResourcePanel,
3031
useResourcePanelController,
@@ -242,13 +243,13 @@ function HomeContent({ chatId, userName, userId }: HomeProps) {
242243
if (!detail?.message) return
243244
e.preventDefault()
244245
prepareResourceViewForAgentTurn()
245-
sendMessage(detail.message, detail.fileAttachments, detail.contexts, {
246+
const { content, fileAttachments, contexts, ...sendOptions } = sendPayload({
247+
...detail,
248+
content: detail.message,
249+
})
250+
sendMessage(content, fileAttachments, contexts, {
251+
...sendOptions,
246252
...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}),
247-
...(detail.requestMode ? { requestMode: detail.requestMode } : {}),
248-
...(detail.assistantSearch ? { assistantSearch: detail.assistantSearch } : {}),
249-
...(detail.assistantSearchLevel !== undefined
250-
? { assistantSearchLevel: detail.assistantSearchLevel }
251-
: {}),
252253
})
253254
}
254255
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
@@ -279,15 +280,15 @@ function HomeContent({ chatId, userName, userId }: HomeProps) {
279280
if (!handoff) return
280281
if (handoff.message) {
281282
prepareResourceViewForAgentTurn()
282-
sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts, {
283+
const { content, fileAttachments, contexts, ...sendOptions } = sendPayload({
284+
...handoff,
285+
content: handoff.message,
286+
})
287+
sendMessage(content, fileAttachments, contexts, {
288+
...sendOptions,
283289
...(handoff.resumeUserMessageId
284290
? { resumeUserMessageId: handoff.resumeUserMessageId }
285291
: {}),
286-
...(handoff.requestMode ? { requestMode: handoff.requestMode } : {}),
287-
...(handoff.assistantSearch ? { assistantSearch: handoff.assistantSearch } : {}),
288-
...(handoff.assistantSearchLevel !== undefined
289-
? { assistantSearchLevel: handoff.assistantSearchLevel }
290-
: {}),
291292
})
292293
return
293294
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
requeuedFields,
4+
sendPayload,
5+
withoutRequeueFields,
6+
} from '@/app/workspace/[workspaceId]/home/hooks/send-queue-policy'
7+
8+
describe('requeuedFields', () => {
9+
it('holds an offline send for the network, on its chatless surface', () => {
10+
expect(requeuedFields('offline', 0, 'ws-1:home')).toEqual({
11+
retryRequired: true,
12+
heldUntilOnline: true,
13+
heldSurface: 'ws-1:home',
14+
})
15+
})
16+
17+
it.each(['unreachable', 'busy'] as const)('retries a %s send on a growing delay', (reason) => {
18+
const before = Date.now()
19+
const fields = requeuedFields(reason, 2, undefined)
20+
21+
expect(fields.sendRetries).toBe(3)
22+
expect(fields.notBefore).toBeGreaterThan(before)
23+
expect(fields.retryRequired).toBeUndefined()
24+
expect(fields.heldSurface).toBeUndefined()
25+
})
26+
27+
it.each(['stop-failed', 'failed'] as const)(
28+
'leaves a %s send for the user, on any surface',
29+
(reason) => {
30+
expect(requeuedFields(reason, 4, 'ws-1:home')).toEqual({ retryRequired: true })
31+
}
32+
)
33+
34+
it('sends a withdrawn message again as soon as the queue drains', () => {
35+
expect(requeuedFields('withdrawn', 1, undefined)).toEqual({})
36+
})
37+
})
38+
39+
describe('withoutRequeueFields', () => {
40+
it('drops every hold, retry and surface field and keeps the message itself', () => {
41+
expect(
42+
withoutRequeueFields({
43+
id: 'm1',
44+
content: 'hello',
45+
resumeUserMessageId: 'attempt-1',
46+
admissionUnknown: true,
47+
retryRequired: true,
48+
heldUntilOnline: true,
49+
sendRetries: 2,
50+
notBefore: 123,
51+
heldSurface: 'ws-1:home',
52+
})
53+
).toEqual({
54+
id: 'm1',
55+
content: 'hello',
56+
resumeUserMessageId: 'attempt-1',
57+
admissionUnknown: true,
58+
})
59+
})
60+
})
61+
62+
describe('sendPayload', () => {
63+
it('keeps only the fields a send sets', () => {
64+
expect(
65+
sendPayload({
66+
content: 'hello',
67+
fileAttachments: undefined,
68+
requestMode: 'assistant',
69+
assistantSearchLevel: undefined,
70+
})
71+
).toEqual({ content: 'hello', requestMode: 'assistant' })
72+
})
73+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { backoffWithJitter } from '@sim/utils/retry'
2+
import type { SendPayload } from '@/app/workspace/[workspaceId]/home/types'
3+
import type { QueuedMothershipMessage, ScheduledRetry } from '@/stores/mothership-queue/types'
4+
5+
/**
6+
* Why a send came back to its caller instead of going out:
7+
* - `withdrawn`: an unmount cleanup withdrew it before the server answered;
8+
* - `offline`: its POST got no answer and the browser is offline;
9+
* - `unreachable`: its POST failed at the network while the browser reports
10+
* itself online (a dropped connection), so no `online` event will come;
11+
* - `busy`: the server refused it because another turn held the chat;
12+
* - `stop-failed`: the Stop it waited on did not settle, so it was not sent.
13+
*/
14+
export type WithdrawalReason = 'withdrawn' | 'offline' | 'unreachable' | 'busy' | 'stop-failed'
15+
16+
/** A withdrawal, or `failed`: the send failed outright and the user decides what next. */
17+
export type RequeueReason = WithdrawalReason | 'failed'
18+
19+
/** The queue fields that say when, and on which surface, a re-queued message goes out. */
20+
type RequeueFields = Pick<
21+
QueuedMothershipMessage,
22+
'retryRequired' | 'heldUntilOnline' | 'sendRetries' | 'notBefore' | 'heldSurface'
23+
>
24+
25+
const SEND_RETRY_BASE_MS = 1_000
26+
const SEND_RETRY_MAX_MS = 30_000
27+
28+
/** Queue fields for the `attempt`th automatic retry of a message: when it may be sent again. */
29+
export function sendRetry(attempt: number): ScheduledRetry {
30+
return {
31+
sendRetries: attempt,
32+
notBefore:
33+
Date.now() +
34+
backoffWithJitter(attempt, null, { baseMs: SEND_RETRY_BASE_MS, maxMs: SEND_RETRY_MAX_MS }),
35+
}
36+
}
37+
38+
/**
39+
* The one re-queue policy, for a message going back to its queue:
40+
* - `offline` waits for the browser to come back online (or the user);
41+
* - `unreachable` and `busy` retry on a growing delay after `previousAttempts`;
42+
* - `stop-failed` and `failed` wait for the user;
43+
* - `withdrawn` goes out again as soon as the queue drains.
44+
*
45+
* A message held by a chatless surface carries that surface (`chatlessSurface`),
46+
* whose queue key dies with its mount, so the next mount of it adopts the
47+
* message. Only sends that wait on the network or the server are held that way.
48+
*/
49+
export function requeuedFields(
50+
reason: RequeueReason,
51+
previousAttempts: number,
52+
chatlessSurface: string | undefined
53+
): RequeueFields {
54+
const surface = chatlessSurface ? { heldSurface: chatlessSurface } : {}
55+
switch (reason) {
56+
case 'offline':
57+
return { retryRequired: true, heldUntilOnline: true, ...surface }
58+
case 'unreachable':
59+
case 'busy':
60+
return { ...sendRetry(previousAttempts + 1), ...surface }
61+
case 'stop-failed':
62+
case 'failed':
63+
return { retryRequired: true }
64+
case 'withdrawn':
65+
return {}
66+
}
67+
}
68+
69+
/**
70+
* A queue entry without the fields an earlier outcome set, so a re-queue applies
71+
* only the policy for the outcome it is handling. A stale `heldUntilOnline`, for
72+
* one, would let the browser coming online send a message waiting for the user.
73+
*/
74+
export function withoutRequeueFields(entry: QueuedMothershipMessage): QueuedMothershipMessage {
75+
const {
76+
retryRequired: _retryRequired,
77+
heldUntilOnline: _heldUntilOnline,
78+
sendRetries: _sendRetries,
79+
notBefore: _notBefore,
80+
heldSurface: _heldSurface,
81+
...rest
82+
} = entry
83+
return rest
84+
}
85+
86+
/** The payload of a send, without fields it does not set. */
87+
export function sendPayload(source: SendPayload): SendPayload {
88+
return {
89+
content: source.content,
90+
...(source.fileAttachments ? { fileAttachments: source.fileAttachments } : {}),
91+
...(source.contexts ? { contexts: source.contexts } : {}),
92+
...(source.requestMode ? { requestMode: source.requestMode } : {}),
93+
...(source.assistantSearch ? { assistantSearch: source.assistantSearch } : {}),
94+
...(source.assistantSearchLevel !== undefined
95+
? { assistantSearchLevel: source.assistantSearchLevel }
96+
: {}),
97+
}
98+
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,6 +2341,44 @@ describe('useChat remount send recovery', () => {
23412341
* settle sends nothing. That says nothing about the earlier attempt the
23422342
* message resumes, so it must stay uneditable.
23432343
*/
2344+
/**
2345+
* A message held for the network that the user then sends by hand, over a turn
2346+
* whose Stop does not settle, goes back waiting for the user. The hold it had
2347+
* before must not outlive that: the browser coming online must not send it.
2348+
*/
2349+
it('keeps a Send-now whose Stop failed waiting for the user when the browser comes online', async () => {
2350+
state.abortSettlements = [false, false, false, false]
2351+
const { getResult } = renderUseChatInChat('chat-a')
2352+
await act(async () => {
2353+
void getResult().sendMessage('Original request')
2354+
})
2355+
await waitFor(() => state.postBodies.length === 1 && getResult().isSending)
2356+
useMothershipQueueStore.getState().enqueue('chat-a', {
2357+
id: 'held-offline',
2358+
content: 'written while offline',
2359+
resumeUserMessageId: 'offline-attempt',
2360+
admissionUnknown: true,
2361+
retryRequired: true,
2362+
heldUntilOnline: true,
2363+
})
2364+
2365+
await act(async () => {
2366+
await getResult()
2367+
.sendNow('held-offline')
2368+
.catch(() => {})
2369+
await sleep(200)
2370+
})
2371+
await act(async () => {
2372+
window.dispatchEvent(new Event('online'))
2373+
await sleep(100)
2374+
})
2375+
2376+
expect(state.postBodies).toHaveLength(1)
2377+
const queued = useMothershipQueueStore.getState().queues['chat-a']?.[0]
2378+
expect(queued).toMatchObject({ id: 'held-offline', retryRequired: true })
2379+
expect(queued?.heldUntilOnline).toBeUndefined()
2380+
})
2381+
23442382
it('keeps a resumed message uneditable when its Send-now Stop does not settle', async () => {
23452383
state.abortSettlements = [false, false, false, false]
23462384
const { getResult } = renderUseChatInChat('chat-a')

0 commit comments

Comments
 (0)