Skip to content

Commit ef65480

Browse files
committed
test(mothership): cover the pre-POST handoff write and the own-id conflict
- a fresh Send-now reloaded with its POST unanswered restores uneditable (red without the handoff rewrite as the POST goes out) - a conflict naming the resent id itself is a duplicate, never a refusal (red without the conflictStreamId !== userMessageId check)
1 parent 97f5c94 commit ef65480

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2562,6 +2562,143 @@ describe('useChat remount send recovery', () => {
25622562
expect(state.postBodies[1].userMessageId).toBe(state.postBodies[0].userMessageId)
25632563
})
25642564

2565+
/**
2566+
* A fresh-id Send-now is unsent while it waits on its Stop. Once its POST goes
2567+
* out the server may hold it, even before the chat's history shows it, so a
2568+
* reload with that POST unanswered must restore it uneditable.
2569+
*/
2570+
it('keeps a fresh Send-now uneditable when the page reloads with its POST unanswered', async () => {
2571+
const first = renderUseChatInChat('chat-a')
2572+
await act(async () => {
2573+
void first.getResult().sendMessage('Original request')
2574+
})
2575+
await waitFor(() => state.postBodies.length === 1 && first.getResult().isSending)
2576+
await act(async () => {
2577+
void first.getResult().sendMessage('Use the latest report')
2578+
})
2579+
await waitFor(() => useMothershipQueueStore.getState().queues['chat-a']?.length === 1)
2580+
await act(async () => {
2581+
void first.getResult().sendNow()
2582+
})
2583+
/** The Send-now POST is out, and held open. */
2584+
await waitFor(() => state.postBodies.length === 2)
2585+
const storedAtReload = readQueuedSendHandoffState()
2586+
expect(storedAtReload?.userMessageId).toBe(state.postBodies[1].userMessageId)
2587+
first.unmount()
2588+
await act(async () => {
2589+
await sleep(50)
2590+
})
2591+
/* A reload runs no cleanup: the queue comes back as the session saved it (the
2592+
dispatched entry had left it) and the handoff as it was stored. */
2593+
useMothershipQueueStore.getState().reset()
2594+
if (storedAtReload) writeQueuedSendHandoffState(storedAtReload)
2595+
2596+
const history: MothershipChatHistory = {
2597+
id: 'chat-a',
2598+
mode: 'agent',
2599+
title: 'Reloaded',
2600+
messages: [],
2601+
activeStreamId: null,
2602+
resources: [],
2603+
}
2604+
let loadHistory: (() => void) | undefined
2605+
mockRequestJson.mockImplementation(
2606+
() =>
2607+
new Promise((resolve) => {
2608+
loadHistory = () => resolve({ chat: history })
2609+
})
2610+
)
2611+
const reloaded = renderUseChatInChat('chat-a')
2612+
const editAtRestore: Array<ReturnType<ReturnType<typeof useChat>['editQueuedMessage']>> = []
2613+
let tried = false
2614+
const unsubscribe = useMothershipQueueStore.subscribe((queueState) => {
2615+
if (tried) return
2616+
const restored = queueState.queues['chat-a']?.find(
2617+
(message) => message.content === 'Use the latest report'
2618+
)
2619+
if (!restored) return
2620+
tried = true
2621+
editAtRestore.push(reloaded.getResult().editQueuedMessage(restored.id))
2622+
})
2623+
try {
2624+
await waitFor(() => loadHistory !== undefined)
2625+
await act(async () => {
2626+
loadHistory?.()
2627+
await sleep(50)
2628+
})
2629+
await waitFor(() => tried)
2630+
} finally {
2631+
unsubscribe()
2632+
}
2633+
2634+
expect(editAtRestore[0]).toBeUndefined()
2635+
})
2636+
2637+
/**
2638+
* A resumed message whose earlier attempt is the very turn now running, sent
2639+
* now: its Stop and its resend share one id, and the server answers the resend
2640+
* as a duplicate of that turn. That is not a refusal, so the message must
2641+
* never come back editable.
2642+
*/
2643+
it('never treats a conflict naming the resent id itself as a refusal', async () => {
2644+
const history: MothershipChatHistory = {
2645+
id: 'chat-own-id',
2646+
mode: 'agent',
2647+
title: 'Own id',
2648+
messages: [],
2649+
activeStreamId: 'earlier-attempt',
2650+
resources: [],
2651+
}
2652+
mockRequestJson.mockImplementation(() => Promise.resolve({ chat: history }))
2653+
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
2654+
const url = String(input)
2655+
if (url.includes('/api/copilot/chat/abort')) {
2656+
state.abortBodies.push(JSON.parse(String(init?.body)))
2657+
return Response.json({ aborted: true, settled: true })
2658+
}
2659+
if (url === '/api/mothership/chat' && init?.method === 'POST') {
2660+
state.postBodies.push(JSON.parse(String(init.body)))
2661+
return Response.json(
2662+
{ error: 'This message was already sent.', activeStreamId: 'earlier-attempt' },
2663+
{ status: 409 }
2664+
)
2665+
}
2666+
if (url.includes('/api/mothership/chat/stream')) {
2667+
if (url.includes('batch=true')) {
2668+
return Response.json({ success: true, events: [], status: 'streaming' })
2669+
}
2670+
return new Response(new ReadableStream<Uint8Array>(), {
2671+
headers: { 'Content-Type': 'text/event-stream' },
2672+
})
2673+
}
2674+
return fetchStub(input, init)
2675+
})
2676+
const { getResult } = renderUseChatInChat(history.id, history)
2677+
await waitFor(() => getResult().isSending)
2678+
useMothershipQueueStore.getState().enqueue(history.id, {
2679+
id: 'resumed',
2680+
content: 'sent earlier with no answer',
2681+
resumeUserMessageId: 'earlier-attempt',
2682+
admissionUnknown: true,
2683+
})
2684+
await act(async () => {
2685+
/** Reattaches to the running turn, which stays open. */
2686+
void getResult()
2687+
.sendNow('resumed')
2688+
.catch(() => {})
2689+
})
2690+
await waitFor(() => state.postBodies.length === 1)
2691+
await act(async () => {
2692+
await sleep(300)
2693+
})
2694+
2695+
expect(state.postBodies.map((body) => body.userMessageId)).toEqual(['earlier-attempt'])
2696+
const requeued = useMothershipQueueStore
2697+
.getState()
2698+
.queues[history.id]?.find((message) => message.content === 'sent earlier with no answer')
2699+
expect(requeued === undefined || requeued.admissionUnknown === true).toBe(true)
2700+
})
2701+
25652702
/**
25662703
* A held message the server then refuses as busy is known not to be a turn
25672704
* there: the server answers a retry of an admitted id as a duplicate, never

0 commit comments

Comments
 (0)