Skip to content

Commit 97f5c94

Browse files
committed
fix(mothership): align the one admission fact with the history check after rebase
- one helper names the id a queued entry reuses (its Stop handoff's, else the withdrawn send's), in the order startSendMessage sends it - a conflict naming the send's own id is never read as a refusal - the stored handoff records the flag as it stands, rewritten as the POST goes out, instead of inferring it from stopRequired - the Stop-settlement test's handoff carries the flag the hook writes; a flagless legacy handoff is checked against history before it is resent
1 parent 07b32dd commit 97f5c94

4 files changed

Lines changed: 91 additions & 8 deletions

File tree

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,6 +1871,51 @@ describe('useChat remount send recovery', () => {
18711871
})
18721872
})
18731873

1874+
/**
1875+
* A handoff stored before the flag existed cannot say whether its id was ever
1876+
* sent, so it is checked against the chat's history before it goes out again.
1877+
*/
1878+
it('checks a flagless stored handoff against history before resending it', async () => {
1879+
const order: string[] = []
1880+
const history: MothershipChatHistory = {
1881+
id: 'chat-a',
1882+
mode: 'agent',
1883+
title: 'Invoice inspection',
1884+
messages: [],
1885+
activeStreamId: null,
1886+
resources: [],
1887+
}
1888+
mockRequestJson.mockImplementation(() => {
1889+
order.push('history')
1890+
return Promise.resolve({ chat: history })
1891+
})
1892+
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
1893+
const url = String(input instanceof Request ? input.url : input)
1894+
if (url.includes('/api/copilot/chat/abort')) {
1895+
state.abortBodies.push(JSON.parse(String(init?.body)))
1896+
return Response.json({ aborted: true, settled: true })
1897+
}
1898+
if (url === '/api/mothership/chat' && init?.method === 'POST') order.push('post')
1899+
return fetchStub(input, init)
1900+
})
1901+
writeQueuedSendHandoffState({
1902+
id: 'queued-correction',
1903+
chatId: 'chat-a',
1904+
workspaceId: 'ws-1',
1905+
supersededStreamId: 'previous-response',
1906+
userMessageId: 'prepared-correction-request',
1907+
message: 'inspect the second invoice instead',
1908+
stopRequired: true,
1909+
requestedAt: Date.now(),
1910+
})
1911+
renderUseChatInChat('chat-a', history)
1912+
await waitFor(() => state.postBodies.length === 1, 4_000)
1913+
1914+
expect(state.postBodies[0].userMessageId).toBe('prepared-correction-request')
1915+
expect(order.indexOf('history')).toBeGreaterThanOrEqual(0)
1916+
expect(order.indexOf('history')).toBeLessThan(order.indexOf('post'))
1917+
})
1918+
18741919
it.each([false, true])(
18751920
'the remounted handoff reader requires Stop settlement (settled: %s)',
18761921
async (settled) => {
@@ -1892,6 +1937,8 @@ describe('useChat remount send recovery', () => {
18921937
message: 'inspect the second invoice instead',
18931938
requestMode: 'assistant',
18941939
stopRequired: true,
1940+
/** A fresh id still waiting on its Stop, as the hook records it. */
1941+
admissionUnknown: false,
18951942
requestedAt: Date.now(),
18961943
})
18971944
const { getResult } = renderUseChatInChat('chat-a', {

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ import { workflowKeys } from '@/hooks/queries/workflows'
134134
import { snapAllSmoothText } from '@/hooks/use-smooth-text'
135135
import { useChatPanelStore } from '@/stores/chat-panel/store'
136136
import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
137-
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
137+
import { reusedRequestId, useMothershipQueueStore } from '@/stores/mothership-queue/store'
138138
import type {
139139
QueuedMothershipMessage,
140140
QueuedSendHandoffSeed,
@@ -3635,8 +3635,7 @@ export function useChat(
36353635
organizationId,
36363636
supersededStreamId: queuedSendHandoff.supersededStreamId,
36373637
...(queuedSendHandoff.stopRequired ? { stopRequired: true } : {}),
3638-
/** Without a pending Stop, its POST goes out next. */
3639-
admissionUnknown: admissionUnknown || !queuedSendHandoff.stopRequired,
3638+
admissionUnknown,
36403639
userMessageId,
36413640
message,
36423641
...(fileAttachments ? { fileAttachments } : {}),
@@ -3936,6 +3935,8 @@ export function useChat(
39363935
: await getDesktopChatCapabilities(desktopScopeIdRef.current)
39373936

39383937
admissionUnknown = true
3938+
/** A reload from here on may find the server holding this id. */
3939+
writeQueuedSendHandoff(requestChatId)
39393940
const response = await fetch(apiPathRef.current, {
39403941
method: 'POST',
39413942
headers: { 'Content-Type': 'application/json' },
@@ -4021,7 +4022,11 @@ export function useChat(
40214022
/** Whether this view still shows the send; otherwise only its own chat changes. */
40224023
const viewOnSend = streamGenRef.current === gen
40234024
const supersededStreamId = queuedSendHandoff?.supersededStreamId ?? pendingStopStreamId
4024-
if (supersededStreamId && conflictStreamId === supersededStreamId) {
4025+
if (
4026+
supersededStreamId &&
4027+
conflictStreamId === supersededStreamId &&
4028+
conflictStreamId !== userMessageId
4029+
) {
40254030
rollbackOptimisticSend()
40264031
if (streamGenRef.current === gen) {
40274032
streamGenRef.current++
@@ -5156,7 +5161,7 @@ export function useChat(
51565161
*/
51575162
const mustNotResend = useCallback(
51585163
async (chatKey: string, msg: QueuedMothershipMessage): Promise<boolean> => {
5159-
const requestId = msg.queuedSendHandoff?.userMessageId ?? msg.resumeUserMessageId
5164+
const requestId = reusedRequestId(msg)
51605165
if (!msg.admissionUnknown || !requestId || chatKey.startsWith(PENDING_CHAT_KEY_PREFIX))
51615166
return false
51625167
const history = await queryClient
@@ -5358,7 +5363,7 @@ export function useChat(
53585363
const accepted = acceptedMessageIds(chatHistory)
53595364
for (const queued of messageQueue) {
53605365
if (queuedMessageDispatchIds.has(queued.id)) continue
5361-
const requestId = queued.queuedSendHandoff?.userMessageId ?? queued.resumeUserMessageId
5366+
const requestId = reusedRequestId(queued)
53625367
if (!requestId || !accepted.has(requestId)) continue
53635368
clearQueuedSendHandoffState(queued.id)
53645369
clearQueuedSendHandoffClaim(queued.id)

‎apps/sim/stores/mothership-queue/store.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,27 @@ describe('useMothershipQueueStore', () => {
3030
})
3131

3232
describe('replaceAt', () => {
33+
it('treats a flagless handoff still waiting on its Stop as possibly sent', () => {
34+
/** Its id may be a re-queued message's earlier attempt, which a pending Stop says nothing about. */
35+
useMothershipQueueStore.getState().enqueue('chat-A', {
36+
id: 'legacy',
37+
content: 'original',
38+
queuedSendHandoff: {
39+
id: 'legacy',
40+
chatId: 'chat-A',
41+
supersededStreamId: 'previous-response',
42+
userMessageId: 'earlier-attempt',
43+
stopRequired: true,
44+
},
45+
})
46+
useMothershipQueueStore.getState().replaceAt('chat-A', 'legacy', { content: 'edited' })
47+
48+
expect(useMothershipQueueStore.getState().queues['chat-A']?.[0]).toMatchObject({
49+
content: 'original',
50+
admissionUnknown: true,
51+
})
52+
})
53+
3354
it('reads a reused handoff id as possibly sent unless the entry says otherwise', () => {
3455
useMothershipQueueStore.getState().enqueue('chat-A', {
3556
id: 'sent',

‎apps/sim/stores/mothership-queue/store.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const initialState = {
5151
cleared: {} as Record<string, number>,
5252
}
5353

54+
/**
55+
* The earlier attempt's id a queued message goes out under, if it reuses one:
56+
* its Stop handoff's, else the withdrawn send's. `startSendMessage` picks the id
57+
* in the same order.
58+
*/
59+
export function reusedRequestId(message: QueuedMothershipMessage): string | undefined {
60+
return message.queuedSendHandoff?.userMessageId ?? message.resumeUserMessageId
61+
}
62+
5463
/**
5564
* `admissionUnknown` is decided where a send chooses its id (`startSendMessage`)
5665
* and carried on the entry. A writer that has no say (a session saved before the
@@ -59,8 +68,9 @@ const initialState = {
5968
* write goes through this, so no path can queue such a message as editable.
6069
*/
6170
function withAdmissionGuard(message: QueuedMothershipMessage): QueuedMothershipMessage {
62-
const reusedId = message.resumeUserMessageId ?? message.queuedSendHandoff?.userMessageId
63-
if (reusedId === undefined || message.admissionUnknown !== undefined) return message
71+
if (reusedRequestId(message) === undefined || message.admissionUnknown !== undefined) {
72+
return message
73+
}
6474
return { ...message, admissionUnknown: true }
6575
}
6676

0 commit comments

Comments
 (0)